mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
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:
+34
-10
@@ -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
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user