diff --git a/crates/brush/src/track.rs b/crates/brush/src/track.rs index 85ccbd990..7d8799186 100644 --- a/crates/brush/src/track.rs +++ b/crates/brush/src/track.rs @@ -29,6 +29,12 @@ fn unit(v: Vec2) -> Vec2 { if l < 1e-12 { Vec2::new(1.0, 0.0) } else { v / l } } +/// Whether two unit vectors point the same way, to within the slack a unit vector carries after +/// a few multiplications (a bend of even a thousandth of a degree fails this). +fn aligned(a: Vec2, b: Vec2) -> bool { + a.dot(b) > 1.0 - 1e-12 +} + /// 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) @@ -154,6 +160,59 @@ impl Track { let (p, t) = self.frame(s); p + normal(t) * d } + + /// Whether [`Self::frame`] holds one constant tangent over arc lengths `s0..s1`, so mapping + /// through it is an affine of the art and nothing more. + /// + /// Read off the track, not sampled. A bend in the span is two segments with different + /// directions, however short the bent part is, so this cannot miss one; a sampled check can, + /// because a wiggle narrower than the sample spacing passes between the samples (#3926). + /// + /// Fails closed: a span that needs reasoning this does not do (a closed track's seam, a + /// non-finite bound, a track with no segments) is reported as not straight, and the caller + /// bends it the long way. + pub fn is_straight(&self, s0: f64, s1: f64) -> bool { + // Checked before the min/max, which quietly drop a NaN rather than propagate it. + let n = self.segments(); + if !(s0.is_finite() && s1.is_finite()) || n == 0 { + return false; + } + let (lo, hi) = (s0.min(s1), s0.max(s1)); + let l = self.len(); + // A closed track wraps, so a span reaching past either end covers segments twice. + 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. + 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)); + matches!((a, b), (Some(a), Some(b)) if *a < hi + 1e-9 && *b > lo - 1e-9) + }); + let mut dir: Option = 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 { + return false; + }; + if dir.is_some_and(|first: Vec2| !aligned(first, d)) { + return false; + } + 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)) { + return false; + } + } + any + } } /// The most flattened elements of a path kept. @@ -253,6 +312,47 @@ pub fn seed_of(bp: &BezPath, salt: &str) -> u64 { mod tests { use super::*; + /// A straight run is straight; a bend in the span is not, however short it is. This is the + /// property a sampled check cannot offer, so it is worth pinning on its own (#3926 review). + #[test] + fn straightness_is_read_off_the_track_not_sampled() { + let line = |pts: &[(f64, f64)], closed: bool| { + let mut bp = BezPath::new(); + bp.move_to(pts[0]); + for p in &pts[1..] { + bp.line_to(*p); + } + if closed { + bp.close_path(); + } + tracks(&bp, 0.1).remove(0) + }; + + let straight = line(&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)], false); + assert!(straight.is_straight(0.0, 200.0), "two collinear segments are one straight run"); + // Open tracks extrapolate along the end directions, which is still the same line. + assert!(straight.is_straight(-50.0, 250.0), "and so is the extrapolation past both ends"); + + // A detour of a fifth of a point, 0.1% of the run: far below any sampling grid this code + // would choose, and still a bend. + let wiggle = line(&[(0.0, 0.0), (99.9, 0.0), (100.0, 0.2), (100.1, 0.0), (200.0, 0.0)], false); + assert!(!wiggle.is_straight(0.0, 200.0), "a 0.2 pt detour is a bend"); + assert!(!wiggle.is_straight(99.0, 101.0), "and is caught when the span is only around it"); + assert!(wiggle.is_straight(0.0, 50.0), "while a span before it is straight"); + + // `frame` blends vertex tangents, so the segment before a bend is not straight all the way + // to the vertex: the tangent there is already turning. Pinning this because checking the + // segment directions alone would call that span straight and be wrong. + let kink = line(&[(0.0, 0.0), (100.0, 0.0), (160.0, 60.0)], false); + assert!(!kink.is_straight(0.0, 100.0), "the last segment's tangent is already turning at its far vertex"); + assert!(kink.is_straight(0.0, 50.0), "a span clear of the turning vertex is straight"); + + // Fails closed where it does not reason: a closed track's seam, and nonsense bounds. + let ring = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true); + assert!(!ring.is_straight(-10.0, 10.0), "a span across a closed track's seam is refused"); + assert!(ring.is_straight(10.0, 90.0), "a span inside one of its sides is straight"); + assert!(!straight.is_straight(f64::NAN, 10.0), "a non-finite bound is refused"); + } #[test] fn straight_track_frames() { let mut bp = BezPath::new(); diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index 088128e7f..95632ad49 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -155,6 +155,32 @@ impl AlongMap { let w = self.xs[i + 1] - self.xs[i]; if w <= 1e-12 { 1.0 } else { (self.ss[i + 1] - self.ss[i]) / w } } + /// Whether `at` is one linear function across the art, so it adds no bend of its own. + /// + /// [`AlongMap::linear`] always is: its outer two pieces have no width and the middle one + /// spans everything. Between Guides is not, once its three pieces really do differ, which is + /// the whole point of it. + fn is_linear(&self) -> bool { + let mut slope: Option = None; + for i in 0..3 { + // A piece with no width constrains nothing: no art x lands inside it. + if self.xs[i + 1] - self.xs[i] <= 1e-12 { + continue; + } + let s = self.slope(i); + if slope.is_some_and(|first: f64| (first - s).abs() > 1e-9 * first.abs().max(1.0)) { + return false; + } + slope = Some(s); + } + slope.is_some() + } + + /// The arc lengths the art spans, low to high. + fn span(&self) -> (f64, f64) { + (self.ss[0].min(self.ss[3]), self.ss[0].max(self.ss[3])) + } + /// Largest along-scale (arc length per art unit) of the pieces. fn max_scale(&self) -> f64 { (0..3).map(|i| self.slope(i).abs()).fold(1e-6, f64::max) @@ -167,9 +193,9 @@ impl AlongMap { /// normal offset are both linear, so the art is just moved, turned and scaled, and flattening it /// into chords throws its curves away for nothing (#3926). /// -/// Derived from three corners and then *checked* against a grid: it is used only where it -/// reproduces the real map at least as closely as the flattening would have, so where it does not -/// hold the result is exactly what it was before. +/// Derived from three corners and then checked against a grid. The check is a second opinion, not +/// the gate: `bend` only calls this once the track and the along map have been read and found +/// straight, because sampling can reject a bend and cannot promise there is none (#3926 review). fn affine_fit(f: &dyn Fn(Point) -> Point, b: Rect, tol: f64) -> Option { let (w, h) = (b.width(), b.height()); if !(w > 1e-9 && h > 1e-9 && tol.is_finite() && tol > 0.0) { @@ -242,7 +268,17 @@ fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option Vec } out.out } + +#[cfg(test)] +mod tests { + use super::*; + use crate::track::tracks; + + /// #3926 review: the five by five grid cannot promise a run is straight, only that it looks + /// straight where it was asked. A zigzag whose vertices land on the sample positions is + /// invisible to it: every sample sits on a y = 0 vertex whose blended tangent is flat, so all + /// 25 points agree with the affine exactly, while the art between them is thrown 4 pt off. + /// + /// This is why the gate is `Track::is_straight` and the grid is only a second opinion. + #[test] + fn a_bend_shorter_than_the_sample_grid_is_still_a_bend() { + // Half-period 20 pt, amplitude 4 pt. The 11.3 degree turn is under `CORNER_DEG`, so the + // vertices are not corners and their tangents blend flat, which is what hides them. + let (d, a) = (20.0, 4.0); + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + for i in 1..=16 { + bp.line_to((d * i as f64, if i % 2 == 1 { a } else { 0.0 })); + } + let t = tracks(&bp, 0.01).remove(0); + assert_eq!(t.corner.iter().filter(|c| **c).count(), 0, "no vertex is a corner, so every tangent is blended"); + + // A span over four full periods, held clear of both ends so no sample hits the + // extrapolation branch: the five samples land on vertices 4, 6, 8, 10 and 12, all at y = 0. + let seglen = (d * d + a * a).sqrt(); + let (s0, s1) = (4.0 * seglen, 12.0 * seglen); + let b = Rect::new(0.0, -20.0, 40.0, 20.0); + let along = AlongMap::linear(b.width(), s0, s1); + let (cy, x0, cross) = (b.center().y, b.x0, 1.0); + let f = |p: Point| t.map(along.at(p.x - x0), (p.y - cy) * cross); + let scale = along.max_scale().max(cross.abs()).max(1e-6); + let flat_tol = tolerance(b.height().max(b.width()) * scale) / scale; + + // The grid is satisfied, and it should not be. + let fit = affine_fit(&f, b, flat_tol.max(1e-9)).expect("the sampled check accepts this bend"); + + // How wrong it is: an art x landing on a peak of the zigzag, which no sample reaches. + let peak_x = x0 + (5.0 * seglen - s0) / (s1 - s0) * b.width(); + let at_peak = Point::new(peak_x, cy); + let off = (f(at_peak) - fit * at_peak).hypot(); + assert!( + off > 3.0 && off > flat_tol * 20.0, + "the affine the grid approved is {off:.3} pt out at the peak, against a {flat_tol:.4} pt tolerance" + ); + + // Reading the track instead refuses it, and still admits the straight run it is made of. + assert!(!t.is_straight(s0, s1), "a bend between the samples is still a bend"); + let straight = tracks( + &{ + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((320.0, 0.0)); + bp + }, + 0.01, + ) + .remove(0); + assert!(straight.is_straight(s0, s1), "and the same span of an actually straight track is straight"); + + // End to end through `bend`: the aliased bend is subdivided, the straight run keeps its + // curves. This is the gate itself, not the two predicates it is made of. + let anchors = |n: &Node| match &n.kind { + NodeKind::Path { path, .. } => path.subpaths.iter().map(|s| s.anchors.len()).sum::(), + _ => 0, + }; + let circle = Node::path( + vectorcraft_doc::NodeId(0), + vectorcraft_geom::shapes::ellipse(b), + vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0), + ); + assert_eq!(anchors(&circle), 4, "the art is a 4-anchor circle to begin with"); + let bent = bend(&t, &circle, b, along, cross).expect("the zigzag still draws something"); + assert!(anchors(&bent) > 8, "the aliased bend is subdivided, not affine-mapped: {} anchors", anchors(&bent)); + 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"); + } +}