diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 069e1bf3c..884dd27cf 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.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"); + } } /// The 10 × 4 pt side tile of [`pattern_brush`] with the given corner sources and no corner art. @@ -1141,19 +1147,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); + // 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); - // 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)); + // 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. @@ -1184,3 +1193,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"); + } + } +} 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 066797176..95632ad49 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -155,12 +155,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 = 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 { + 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 { + 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 { + 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> = 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 { let cy = b.center().y; @@ -168,6 +267,20 @@ 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"); + } +}