mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 04:37:29 -06:00
Brushes: bent art keeps its curves (#3926)
Pattern, art and auto-corner brushes used to flatten the art into chords wherever the path bends, so Expand Brush Strokes left polygons with hundreds of corner anchors. Each art segment now comes out as cubics matched to the bent segment's ends and derivatives and halved until they hold to a quarter of the flattening tolerance (never below the 0.05 pt the path is flattened to). Anchors stay anchors, lines that stay straight stay lines, and the halving is capped in depth and in anchors: where the fit would take more anchors than the chords, or the map gives points that are not finite, that subpath falls back to the chords, now without the non-finite points. A 4-anchor circle tile round a 400 pt circle: 68 anchors a tile -> about 9, all with handles, 0.04 pt from the bent art against 0.19 for the chords; stroke_pieces 169 -> 244 us for the 31-tile stroke.
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