Merge pull request #4606 from storytold/fix/brush-curves-on-bends

Fix #3926: Art, Scatter and Pattern brush art keeps its curves where the path bends
This commit is contained in:
BFlat
2026-10-29 23:12:04 +07:00
committed by GitHub
2 changed files with 302 additions and 66 deletions
+34 -10
View File
@@ -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<Point>) {
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.
+268 -56
View File
@@ -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<Node> {
/// 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<Node> {
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<Arc<Node>> = children.iter().filter_map(|c| warp_node(c, f, flat_tol, max_du, width_scale)).map(Arc::new).collect();
let kids: Vec<Arc<Node>> =
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<Point> = vec![];
let mut els = vec![];
kurbo::flatten(bp.iter(), flat_tol.max(1e-4), |e| els.push(e));
let finish = |cur: &mut Vec<Point>, closed: bool, subs: &mut Vec<SubPath>| {
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<Point> = 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<Point> = 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<Vec<Point>> {
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<SubPath> {
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<Point>,
(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<N
{
return affine_node(art, a, cross.abs());
}
warp_node(art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
warp_node(art, &f, flat_tol, MAX_SEG / along.max_scale(), flat_tol * scale, cross.abs())
}
/// [`bend`], with the cross scale at arc length `s` multiplied by `widen(s)` (0..=1). The strokes
@@ -297,7 +405,7 @@ fn bend_widened(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64, wid
let art = widen_strokes(art, &factor);
let scale = along.max_scale().max(cross.abs()).max(1e-6);
let flat_tol = tolerance(b.height().max(b.width()) * scale) / scale;
warp_node(&art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
warp_node(&art, &f, flat_tol, MAX_SEG / along.max_scale(), flat_tol * scale, cross.abs())
}
/// `n` with the strokes of it and of everything inside it scaled by `factor` of the object they
@@ -377,7 +485,7 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&Pre
let width = light.max(heavy);
let cross = weight * width / 100.0;
let mut out = vec![];
for t in tracks(bp, 0.05) {
for t in tracks(bp, TRACK_TOL) {
let l = t.len();
if l <= 1e-9 {
continue;
@@ -419,7 +527,7 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<
let mut rng = Rng::new(seed_of(bp, name));
let [size_mode, spacing_mode, scatter_mode, rotation_mode] = sc.modes();
let mut out = Budget::default();
for t in tracks(bp, 0.05) {
for t in tracks(bp, TRACK_TOL) {
if out.full() {
break;
}
@@ -588,7 +696,7 @@ fn auto_corner(t: &Track, i: usize, source: CornerSource, side: &(Node, Rect), k
}
let s = if tw > 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<Node>
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<Point> {
(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<Point> = 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"),
}
}
}
}