Browse / github.com/storytold/vectorcraft / patch
Pattern Brush: keep the tile's curves on a straight run (#3926)
Pattern Brush: keep the tile's curves on a straight run (#3926) ## What this changes Fixes the first of the problems in #3926: Pattern Brush draws tile curves as polygons. `warp_path` runs `kurbo::flatten` and emits `SubPath::polyline`, so every path it maps loses its Béziers. A circle tile is drawn as a polygon, visibly in the live stroke rather than only after *Expand Brush Strokes*, and it happens even on a perfectly straight path where the tile needs nothing more than an affine. Bending is only non-linear **where the track bends**. On a straight run the arc-length map and the normal offset are both linear, so there is nothing to approximate. `affine
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- saad-mughal435 (imported from pull request #3954, open)
- Base
50d976e1e6b92ab3633d6c8cc174268e5e6ffef9- Size
- +413 −20 in 3 files
- Review
- pending
- Shared
- 2026-10-28
- Id
529f0d21e0c2b820d45b890e
How it went
No other operator has reported using it yet.
Diff
raw · crates/brush/src/tests.rs crates/brush/src/track.rs crates/brush/src/warp.rs
@@ -1,6 +1,6 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, StrokeLayer};-use vectorcraft_geom::{BezPath, Point, Rect, shapes};+use vectorcraft_geom::{Affine, BezPath, Point, Rect, shapes}; use super::*; @@ -273,15 +273,21 @@ fn pattern_tile_count_by_fit() { fn pattern_places_corner_tiles() { let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); let out = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0));- let corners = out- .iter()- .filter(- |n| matches!(&n.kind, NodeKind::Path { path, .. } if path.subpaths[0].anchors.len() == 4 && n.geometric_bounds().unwrap().width() < 7.0),- )- .count();- assert_eq!(corners, 4);+ // The corner tile is an ellipse and the side tile a rectangle, so a kept handle tells them+ // apart. Anchor count no longer does: the side tiles keep their own four anchors now that a+ // straight run is not flattened (#3926), where they used to arrive as many-anchor polylines.+ let curved = |n: &Node| {+ matches!(&n.kind, NodeKind::Path { path, .. }+ if path.subpaths.iter().any(|s| s.anchors.iter().any(|a| a.h_in != a.p || a.h_out != a.p)))+ };+ let corners = out.iter().filter(|n| curved(n)).count();+ assert_eq!(corners, 4, "the four corner ellipses, still curves"); // Sides lose half a corner tile at each end: 94 pt per side → 9 tiles each. assert_eq!(out.len() - corners, 36);+ for n in out.iter().filter(|n| !curved(n)) {+ let NodeKind::Path { path, .. } = &n.kind else { continue };+ assert_eq!(path.subpaths[0].anchors.len(), 4, "a rectangle tile on a straight run stays four anchors");+ } } #[test]@@ -722,19 +728,22 @@ fn pattern_output_is_capped_across_subpaths_not_only_per_run() { let a1 = anchors(&one); assert!(a1 < 2_000_000, "one track stays under the total budget: {a1}"); - // Sixteen tracks would be sixteen times that without a total budget.- // Four tracks are 4 x 320,000 anchors, still inside the budget, so nothing is cut: the- // budget must not bite before the per-factor caps have actually multiplied past it.- let four = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(4), &stroke(1.0));- assert_eq!(four.len(), 4 * 20_000, "four tracks are under the budget and keep every piece");- assert_eq!(anchors(&four), 4 * a1);-- // Sixteen would be 5,120,000 anchors. The budget is what stops it, not the per-run caps.- let many = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(16), &stroke(1.0));+ // Two tracks are well inside the budget, so nothing is cut: it must not bite before the+ // per-factor caps have actually multiplied past it.+ let two = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(2), &stroke(1.0));+ assert_eq!(two.len(), 2 * 20_000, "two tracks are under the budget and keep every piece");+ assert_eq!(anchors(&two), 2 * a1);++ // Enough tracks that the per-run caps alone would pass the budget. Derived from what one+ // track costs rather than written down, because how many anchors a tile holds is not fixed:+ // a tile on a straight run keeps its own anchors instead of being flattened (#3926), which+ // moved this by a factor of four.+ let tracks = 2_000_000 / a1 + 2;+ let many = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(tracks), &stroke(1.0)); let an = anchors(&many);- assert!(an <= 2_000_000, "sixteen subpaths must not exceed the budget: {an}");- assert!(an > anchors(&four), "but more is drawn than four tracks' worth: {an}");- assert!(many.len() < 16 * 20_000, "and fewer pieces than the per-run caps alone would allow: {}", many.len());+ assert!(an <= 2_000_000, "{tracks} subpaths must not exceed the budget: {an}");+ assert!(an > anchors(&two), "but more is drawn than two tracks' worth: {an}");+ assert!(many.len() < tracks * 20_000, "and fewer pieces than the per-run caps alone would allow: {}", many.len()); } /// The same for scatter, whose per-track guard is also 20,000 copies.@@ -765,3 +774,94 @@ fn an_ordinary_stroke_is_unaffected_by_the_budget() { assert_eq!(out.len(), 40, "ten tiles on each of the four sides, as before the budget"); assert!(anchors(&out) < 2_000_000); }++/// 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) };+ let all: Vec<_> = path.subpaths.iter().flat_map(|s| s.anchors.iter()).collect();+ (all.len(), all.iter().filter(|a| a.h_in != a.p || a.h_out != a.p).count())+}++/// #3926: a Pattern Brush on a straight path only moves, turns and scales its tile, so the+/// tile's curves survive. They used to be flattened into chords by the bending code, which is+/// visible in the stroke itself and leaves no handles after Expand Brush Strokes.+#[test]+fn a_pattern_tile_keeps_its_curves_on_a_straight_path() {+ 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 (tile_anchors, tile_curved) = anchors_and_curved(&circle);+ assert_eq!((tile_anchors, tile_curved), (4, 4), "the tile is a 4-anchor smooth circle");++ let brush = Brush {+ name: "p".into(),+ kind: BrushKind::Pattern(PatternBrush { side: circle, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }),+ };+ let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &line(0.0, 0.0, 480.0, 0.0), &stroke(1.0));+ assert!(!out.is_empty(), "the brush drew something");+ for n in &out {+ assert_eq!(anchors_and_curved(n), (4, 4), "each circle is still a 4-anchor curve, not a polygon");+ }+ // The ink is in the same place: a circle tile still covers the path it was laid along.+ let b = bounds(&out);+ assert!(near(b.width(), 480.0, 1.0), "the tiles still span the path: {}", b.width());+ 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.+#[test]+fn a_pattern_tile_on_a_bent_path_is_still_subdivided() {+ 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 {+ name: "p".into(),+ kind: BrushKind::Pattern(PatternBrush { side: circle, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }),+ };+ // 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());+}++/// The kind of every node in a piece of brushed art, in paint order.+fn kinds(n: &Node) -> Vec<&'static str> {+ let mut out = vec![match &n.kind {+ NodeKind::Path { .. } => "path",+ NodeKind::Group { .. } => "group",+ NodeKind::SymbolInstance { .. } => "symbol",+ _ => "other",+ }];+ for c in n.children().into_iter().flatten() {+ out.extend(kinds(c));+ }+ out+}++/// The straight-run route differs from the bending route in the tile's curves and in nothing else,+/// which includes the node kinds it keeps. A tile holding a kind neither route can map (here a+/// symbol instance, whose art lives in the symbol and not in the node) loses it on a straight run+/// exactly as it does on a bend: an affine is not a way for unmappable art to arrive untouched.+#[test]+fn a_straight_run_keeps_the_same_node_kinds_as_a_bend() {+ 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 sym = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY });+ let tile = Node::group(NodeId(2), vec![Arc::new(circle), Arc::new(sym)]);+ let brush = Brush {+ name: "p".into(),+ kind: BrushKind::Pattern(PatternBrush { side: tile, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }),+ };+ let doc = Document::new(1.0, 1.0);+ let straight = stroke_pieces(&doc, &brush, &line(0.0, 0.0, 480.0, 0.0), &stroke(1.0));+ let round = shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath();+ let bent = stroke_pieces(&doc, &brush, &round, &stroke(1.0));+ assert!(!straight.is_empty() && !bent.is_empty(), "both paths drew something");+ for (what, out) in [("a straight run", &straight), ("a bend", &bent)] {+ for n in out {+ assert_eq!(kinds(n), ["group", "path"], "{what} keeps the path and drops the symbol instance");+ }+ }+}@@ -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<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 {+ 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();@@ -153,12 +153,111 @@ 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<f64> = 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) } } +/// The affine that reproduces `f` over `b` to within `tol`, if one does.+///+/// Bending is only non-linear where the track bends. On a straight run the arc-length map and the+/// 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. 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<Affine> {+ let (w, h) = (b.width(), b.height());+ if !(w > 1e-9 && h > 1e-9 && tol.is_finite() && tol > 0.0) {+ return None;+ }+ let origin = Point::new(b.x0, b.y0);+ let q0 = f(origin);+ // Images of the unit x and y directions.+ let u = (f(Point::new(b.x1, b.y0)) - q0) / w;+ let v = (f(Point::new(b.x0, b.y1)) - q0) / h;+ if ![q0.x, q0.y, u.x, u.y, v.x, v.y].iter().all(|k| k.is_finite()) {+ return None;+ }+ let a = Affine::translate(q0.to_vec2()) * Affine::new([u.x, u.y, v.x, v.y, 0.0, 0.0]) * Affine::translate(-origin.to_vec2());+ // Five by five including the corners the fit came from, so a map that merely agrees at those+ // three is rejected.+ for i in 0..=4 {+ for j in 0..=4 {+ let p = Point::new(b.x0 + w * i as f64 / 4.0, b.y0 + h * j as f64 / 4.0);+ let (want, got) = (f(p), a * p);+ if !(want.x.is_finite() && want.y.is_finite()) || want.distance(got) > tol {+ return None;+ }+ }+ }+ Some(a)+}++/// [`warp_node`] for a map that is affine: the art is transformed rather than flattened, so its+/// curves survive.+///+/// Everything else matches [`warp_node`] deliberately, so curves are the only difference between+/// the two routes: the same node kinds are kept and the rest dropped, `live` is cleared on every+/// path, an empty path or a group left with no children is dropped, and strokes are scaled by+/// `width_scale` at every level rather than by the transform's own factor.+fn affine_node(n: &Node, a: Affine, width_scale: f64) -> Option<Node> {+ let mut out = prepared(n, width_scale)?;+ out.transform(a, false);+ Some(out)+}++/// The part of [`affine_node`] that mirrors [`warp_node`]'s walk, before the single transform.+fn prepared(n: &Node, width_scale: f64) -> Option<Node> {+ let mut out = n.clone();+ out.appearance.scale_strokes(width_scale);+ match &mut out.kind {+ NodeKind::Path { path, live, .. } => {+ *live = None;+ if path.is_empty() {+ return None;+ }+ }+ NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => {+ let kids: Vec<Arc<Node>> = children.iter().filter_map(|c| prepared(c, width_scale)).map(Arc::new).collect();+ if kids.is_empty() {+ return None;+ }+ *children = kids;+ }+ _ => return None,+ }+ Some(out)+}+ /// Bend `art` (already oriented, bounds `b`) along `t` using `along` and cross scale `cross`. fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option<Node> { let cy = b.center().y;@@ -166,6 +265,20 @@ fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option<N let f = move |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;+ // A straight run needs no bending: move the art and keep its curves.+ //+ // Straightness is read off the track and the along map, not sampled. A bend anywhere in the+ // span is two segment directions that differ, so this cannot miss one however short the bent+ // part is; the five by five grid below could, since a wiggle narrower than its spacing passes+ // between the samples (#3926 review). The grid now only checks the affine that structure has+ // already said exists, so it can reject and never admit.+ let (s_lo, s_hi) = along.span();+ if along.is_linear()+ && t.is_straight(s_lo, s_hi)+ && let Some(a) = affine_fit(&f, b, flat_tol.max(1e-9))+ {+ return affine_node(art, a, cross.abs());+ } warp_node(art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs()) } @@ -479,3 +592,83 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node> } 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::<usize>(),+ _ => 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");+ }+}