mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 04:37:29 -06:00
A blend whose keys are blends draws each of its steps as a copy of a key, and that copy makes its own steps when it is drawn, so step counts capped per pair multiplied with nesting: five levels of 15 steps were 1.3 million steps from a 23 KB file, 3.4 GB to export. A blend now makes at most 100,000 steps in all (MAX_BLEND_STEPS), counting the steps of the blends inside its keys and steps. Past the budget each pair of keys keeps the same share of its steps; a copy of a blend spaced by distance counts the most steps its spacing can make, since scaling it up adds steps. Blends within the budget draw every step as before.
1561 lines
70 KiB
Rust
1561 lines
70 KiB
Rust
//! Live blends: interpolating key objects and laying the steps out along the spine.
|
|
//!
|
|
//! - **Paths** are matched anchor by anchor ([`PathPair`]): subpaths paired in order (a missing
|
|
//! one grows out of a point), resampled to equal anchor counts, closed ones turned to the same
|
|
//! winding and to the start points that keep them from twisting, or to the anchors the Blend
|
|
//! tool clicked.
|
|
//! - **Paint**: colours, gradients (stops resampled to a common set when their counts differ),
|
|
//! opacity, stroke weight, dashes, miter limit and width profiles interpolate; what can't
|
|
//! (caps, joins, brushes, fonts, symbols, text content) switches halfway.
|
|
//! - **Groups** pair their children in stacking order; the children one group has more of grow
|
|
//! out of the other group's centre. Compound paths stay compound paths.
|
|
//! - **Text, symbols and images** interpolate their transforms (rotation, scale, shear and
|
|
//! position separately); text interpolates its colours and sizes run by run.
|
|
//! - **Spine** ([`blend_spine`]): the straight lines between the key centres until it is edited
|
|
//! or replaced; then a path whose anchors the keys sit on (`key_anchors`). Steps follow it by
|
|
//! arc length; Align to Path turns them to its direction.
|
|
|
|
use std::f64::consts::PI;
|
|
use std::sync::Arc;
|
|
|
|
use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint};
|
|
use vectorcraft_geom::kurbo::ParamCurveArclen;
|
|
use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, SubPath, Vec2};
|
|
|
|
use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile};
|
|
use crate::live::{BlendOrientation, BlendSpacing, BlendSpec};
|
|
use crate::node::{Node, NodeId, NodeKind};
|
|
use crate::text::TextObject;
|
|
|
|
// =====================================================================================
|
|
// Colour / paint interpolation
|
|
// =====================================================================================
|
|
|
|
/// Interpolate two colours in their shared model (display RGB when the models differ).
|
|
pub fn lerp_color(a: &Color, b: &Color, t: f32) -> Color {
|
|
a.lerp_keep_model(b, t)
|
|
}
|
|
|
|
fn lerp_f32(x: f32, y: f32, t: f32) -> f32 {
|
|
x + (y - x) * t
|
|
}
|
|
|
|
fn lerp_f64(x: f64, y: f64, t: f64) -> f64 {
|
|
x + (y - x) * t
|
|
}
|
|
|
|
/// `like` with every stop painting `c`, linked as the solid is (`swatch` at `tint`).
|
|
fn solid_gradient(c: Color, swatch: Option<&str>, tint: f32, like: &Gradient) -> Gradient {
|
|
let mut g = like.clone();
|
|
for s in &mut g.stops {
|
|
s.color = c;
|
|
s.opacity = 1.0;
|
|
s.swatch = swatch.map(str::to_string);
|
|
s.tint = tint;
|
|
}
|
|
g
|
|
}
|
|
|
|
/// The swatch and tint `g` paints at `t`: a stop's own link, or the shared swatch of the two stops
|
|
/// around `t` at the tint between theirs (unlinked when they differ), as [`Gradient::sample_with`] walks.
|
|
fn link_at(g: &Gradient, t: f32) -> (Option<String>, f32) {
|
|
let stops = &g.stops;
|
|
let link = |s: &GradientStop| (s.swatch.clone(), s.tint);
|
|
let Some(first) = stops.first() else { return (None, 1.0) };
|
|
if t <= first.offset {
|
|
return link(first);
|
|
}
|
|
for w in stops.windows(2) {
|
|
let (a, b) = (&w[0], &w[1]);
|
|
if t <= b.offset {
|
|
let span = (b.offset - a.offset).max(1e-6);
|
|
let u = (t - a.offset) / span;
|
|
let m = a.midpoint.clamp(0.01, 0.99);
|
|
let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) };
|
|
return match (&a.swatch, &b.swatch) {
|
|
(Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(a.tint, b.tint, v)),
|
|
_ if v <= 0.0 => link(a),
|
|
_ if v >= 1.0 => link(b),
|
|
_ => (None, 1.0),
|
|
};
|
|
}
|
|
}
|
|
stops.last().map_or((None, 1.0), link)
|
|
}
|
|
|
|
/// `g` with its stops at `offsets`, each painting what `g` paints there (midpoints centred).
|
|
fn resample_stops(g: &Gradient, offsets: &[f32]) -> Gradient {
|
|
let mut out = g.clone();
|
|
out.stops = offsets
|
|
.iter()
|
|
.map(|&o| {
|
|
let (color, opacity) = g.sample_keep_model(o);
|
|
let (swatch, tint) = link_at(g, o);
|
|
GradientStop { opacity, swatch, tint, ..GradientStop::new(o, color) }
|
|
})
|
|
.collect();
|
|
out
|
|
}
|
|
|
|
/// Where a gradient changes: its stop offsets and its off-centre midpoints.
|
|
fn gradient_breaks(g: &Gradient, out: &mut Vec<f32>) {
|
|
for (i, s) in g.stops.iter().enumerate() {
|
|
out.push(s.offset);
|
|
if let Some(n) = g.stops.get(i + 1)
|
|
&& (s.midpoint - 0.5).abs() > 1e-3
|
|
{
|
|
out.push(s.offset + (n.offset - s.offset) * s.midpoint);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Two gradients of one kind with the same number of stops: as they are when the counts match,
|
|
/// else both resampled at every place either changes.
|
|
fn common_stops(a: &Gradient, b: &Gradient) -> Option<(Gradient, Gradient)> {
|
|
if a.kind != b.kind {
|
|
return None;
|
|
}
|
|
if a.stops.len() == b.stops.len() {
|
|
return Some((a.clone(), b.clone()));
|
|
}
|
|
if a.stops.is_empty() || b.stops.is_empty() {
|
|
return None;
|
|
}
|
|
let mut offs = Vec::with_capacity(2 * (a.stops.len() + b.stops.len()));
|
|
gradient_breaks(a, &mut offs);
|
|
gradient_breaks(b, &mut offs);
|
|
offs.retain(|o| o.is_finite());
|
|
offs.sort_by(f32::total_cmp);
|
|
offs.dedup_by(|x, y| (*x - *y).abs() < 1e-4);
|
|
Some((resample_stops(a, &offs), resample_stops(b, &offs)))
|
|
}
|
|
|
|
fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option<GradientPaint> {
|
|
let (ga, gb) = common_stops(&a.gradient, &b.gradient)?;
|
|
let mut out = if t < 0.5 { a.clone() } else { b.clone() };
|
|
out.gradient.stops = ga
|
|
.stops
|
|
.iter()
|
|
.zip(&gb.stops)
|
|
.map(|(sa, sb)| {
|
|
// Stops on two tints of one swatch stay linked to it, as solids do (#3234).
|
|
let (swatch, tint) = match (&sa.swatch, &sb.swatch) {
|
|
(Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(sa.tint, sb.tint, t)),
|
|
_ => (None, 1.0),
|
|
};
|
|
GradientStop {
|
|
opacity: lerp_f32(sa.opacity, sb.opacity, t),
|
|
midpoint: lerp_f32(sa.midpoint, sb.midpoint, t),
|
|
swatch,
|
|
tint,
|
|
..GradientStop::new(lerp_f32(sa.offset, sb.offset, t), lerp_color(&sa.color, &sb.color, t))
|
|
}
|
|
})
|
|
.collect();
|
|
out.angle = a.angle + (b.angle - a.angle) * t as f64;
|
|
out.swatch = None;
|
|
if let (Some(ga), Some(gb)) = (a.geom, b.geom) {
|
|
let mut g = ga;
|
|
g.start = ga.start.lerp(gb.start, t as f64);
|
|
g.end = ga.end.lerp(gb.end, t as f64);
|
|
g.aspect = ga.aspect + (gb.aspect - ga.aspect) * t as f64;
|
|
g.focal = (ga.focal.is_some() || gb.focal.is_some()).then(|| ga.focal_point().lerp(gb.focal_point(), t as f64));
|
|
out.geom = Some(g);
|
|
} else {
|
|
out.geom = None;
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
/// Interpolate paints: solid↔solid, gradients of one kind (stops resampled to a common set),
|
|
/// solid↔gradient; otherwise switch halfway.
|
|
pub fn lerp_paint(a: &Paint, b: &Paint, t: f32) -> Paint {
|
|
match (a, b) {
|
|
// Two tints of one swatch blend into tints of that swatch (a spot stays a spot on export).
|
|
(Paint::Solid { color: x, swatch: Some(sa), tint: ta }, Paint::Solid { color: y, swatch: Some(sb), tint: tb }) if sa == sb => {
|
|
Paint::Solid { color: lerp_color(x, y, t), swatch: Some(sa.clone()), tint: lerp_f32(*ta, *tb, t) }
|
|
}
|
|
(Paint::Solid { color: x, .. }, Paint::Solid { color: y, .. }) => Paint::solid(lerp_color(x, y, t)),
|
|
(Paint::Gradient(ga), Paint::Gradient(gb)) => match lerp_gradient(ga, gb, t) {
|
|
Some(g) => Paint::Gradient(Box::new(g)),
|
|
None => (if t < 0.5 { a } else { b }).clone(),
|
|
},
|
|
(Paint::Solid { color, swatch, tint }, Paint::Gradient(g)) => {
|
|
let ga = GradientPaint { gradient: solid_gradient(*color, swatch.as_deref(), *tint, &g.gradient), ..(**g).clone() };
|
|
Paint::Gradient(Box::new(lerp_gradient(&ga, g, t).unwrap_or_else(|| (**g).clone())))
|
|
}
|
|
(Paint::Gradient(g), Paint::Solid { color, swatch, tint }) => {
|
|
let gb = GradientPaint { gradient: solid_gradient(*color, swatch.as_deref(), *tint, &g.gradient), ..(**g).clone() };
|
|
Paint::Gradient(Box::new(lerp_gradient(g, &gb, t).unwrap_or_else(|| (**g).clone())))
|
|
}
|
|
_ => (if t < 0.5 { a } else { b }).clone(),
|
|
}
|
|
}
|
|
|
|
/// Interpolate dash patterns. A solid stroke counts as the other's pattern with its gaps closed,
|
|
/// so dashes open out of a solid line; patterns of different lengths repeat to a common one.
|
|
fn lerp_dash(x: Option<&Dash>, y: Option<&Dash>, t: f64) -> Option<Dash> {
|
|
let solid_like = |d: &Dash| Dash {
|
|
pattern: d.pattern.chunks(2).flat_map(|c| [c.iter().sum::<f64>(), 0.0]).take(d.pattern.len().max(2)).collect(),
|
|
offset: d.offset,
|
|
align_corners: d.align_corners,
|
|
};
|
|
let (x, y) = match (x.filter(|d| d.is_dashed()), y.filter(|d| d.is_dashed())) {
|
|
(None, None) => return None,
|
|
(Some(d), None) => (d.clone(), solid_like(d)),
|
|
(None, Some(d)) => (solid_like(d), d.clone()),
|
|
(Some(a), Some(b)) => (a.clone(), b.clone()),
|
|
};
|
|
// An odd pattern repeats twice per period (dash, gap, dash / gap, dash, gap).
|
|
let even = |p: &[f64]| if p.len() % 2 == 1 { p.repeat(2) } else { p.to_vec() };
|
|
let (pa, pb) = (even(&x.pattern), even(&y.pattern));
|
|
let n = if pa.is_empty() || pb.is_empty() { 0 } else { lcm(pa.len(), pb.len()).min(12) };
|
|
let at = |p: &[f64], i: usize| p.get(i % p.len().max(1)).copied().unwrap_or(0.0);
|
|
Some(Dash {
|
|
pattern: (0..n).map(|i| lerp_f64(at(&pa, i), at(&pb, i), t)).collect(),
|
|
offset: lerp_f64(x.offset, y.offset, t),
|
|
align_corners: if t < 0.5 { x.align_corners } else { y.align_corners },
|
|
})
|
|
}
|
|
|
|
fn lcm(a: usize, b: usize) -> usize {
|
|
let (mut x, mut y) = (a, b);
|
|
while y != 0 {
|
|
(x, y) = (y, x % y);
|
|
}
|
|
a.checked_div(x).map_or(0, |q| q * b)
|
|
}
|
|
|
|
/// Interpolate width profiles (none is uniform) at every position either has a width point.
|
|
fn lerp_profile(x: Option<&WidthProfile>, y: Option<&WidthProfile>, t: f64) -> Option<WidthProfile> {
|
|
if x.is_none() && y.is_none() {
|
|
return None;
|
|
}
|
|
let uniform = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] };
|
|
let (x, y) = (x.unwrap_or(&uniform), y.unwrap_or(&uniform));
|
|
let mut at: Vec<f64> = x.points.iter().chain(&y.points).map(|p| p.0).filter(|p| p.is_finite()).collect();
|
|
at.sort_by(f64::total_cmp);
|
|
at.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
|
|
let points = at
|
|
.into_iter()
|
|
.map(|p| {
|
|
let ((l1, r1), (l2, r2)) = (x.at(p), y.at(p));
|
|
(p, lerp_f64(l1, l2, t), lerp_f64(r1, r2, t))
|
|
})
|
|
.collect();
|
|
Some(WidthProfile { points })
|
|
}
|
|
|
|
/// Interpolate a stroke's paint, weight, opacity, miter limit, dashes, arrowhead scale and width
|
|
/// profile into `o` (a copy of the nearer stroke, so caps, joins, alignment, arrowheads and
|
|
/// brushes switch halfway).
|
|
fn lerp_stroke(o: &mut StrokeLayer, x: &StrokeLayer, y: &StrokeLayer, t: f64) {
|
|
let tf = t as f32;
|
|
o.paint = lerp_paint(&x.paint, &y.paint, tf);
|
|
o.width = lerp_f64(x.width, y.width, t);
|
|
o.opacity = lerp_f32(x.opacity, y.opacity, tf);
|
|
o.miter_limit = lerp_f64(x.miter_limit, y.miter_limit, t);
|
|
o.dash = lerp_dash(x.dash.as_ref(), y.dash.as_ref(), t);
|
|
o.arrow_scale = (lerp_f64(x.arrow_scale.0, y.arrow_scale.0, t), lerp_f64(x.arrow_scale.1, y.arrow_scale.1, t));
|
|
o.profile = lerp_profile(x.profile.as_ref(), y.profile.as_ref(), t);
|
|
}
|
|
|
|
/// Interpolate appearance stacks: item by item when their structure matches, else the top fill
|
|
/// and stroke only (on the structure of the nearer key).
|
|
pub fn lerp_appearance(a: &Appearance, b: &Appearance, t: f64) -> Appearance {
|
|
let tf = t as f32;
|
|
let mut out = if t < 0.5 { a.clone() } else { b.clone() };
|
|
let same = a.items.len() == b.items.len()
|
|
&& a.items.iter().zip(&b.items).all(|(x, y)| {
|
|
matches!((x, y), (AppearanceItem::Fill(_), AppearanceItem::Fill(_)) | (AppearanceItem::Stroke(_), AppearanceItem::Stroke(_)))
|
|
});
|
|
if same {
|
|
for ((it, x), y) in out.items.iter_mut().zip(&a.items).zip(&b.items) {
|
|
match (it, x, y) {
|
|
(AppearanceItem::Fill(o), AppearanceItem::Fill(x), AppearanceItem::Fill(y)) => {
|
|
o.paint = lerp_paint(&x.paint, &y.paint, tf);
|
|
o.opacity = lerp_f32(x.opacity, y.opacity, tf);
|
|
}
|
|
(AppearanceItem::Stroke(o), AppearanceItem::Stroke(x), AppearanceItem::Stroke(y)) => lerp_stroke(o, x, y, t),
|
|
_ => {}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
let (fa, fb) = (a.fill_paint(), b.fill_paint());
|
|
if !(fa.is_none() && fb.is_none()) {
|
|
out.set_fill(lerp_paint(&fa, &fb, tf));
|
|
}
|
|
let (sa, sb) = (a.stroke_paint(), b.stroke_paint());
|
|
if !(sa.is_none() && sb.is_none()) {
|
|
out.set_stroke(lerp_paint(&sa, &sb, tf));
|
|
let w = a.stroke_width() + (b.stroke_width() - a.stroke_width()) * t;
|
|
let (xs, ys) = (a.stroke().cloned(), b.stroke().cloned());
|
|
if let Some(s) = out.stroke_mut() {
|
|
if let (Some(x), Some(y)) = (&xs, &ys) {
|
|
lerp_stroke(s, x, y, t);
|
|
}
|
|
s.width = w;
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
// =====================================================================================
|
|
// Transforms
|
|
// =====================================================================================
|
|
|
|
/// An affine split into translation, rotation, x scale, shear and y scale (`R · [sx sh; 0 sy]`).
|
|
fn decompose(m: Affine) -> [f64; 6] {
|
|
let [a, b, c, d, e, f] = m.as_coeffs();
|
|
let sx = a.hypot(b);
|
|
let ang = if sx > 1e-12 { b.atan2(a) } else { 0.0 };
|
|
let (s, co) = ang.sin_cos();
|
|
[e, f, ang, sx, co * c + s * d, -s * c + co * d]
|
|
}
|
|
|
|
fn compose([e, f, ang, sx, sh, sy]: [f64; 6]) -> Affine {
|
|
let (s, co) = ang.sin_cos();
|
|
Affine::new([co * sx, s * sx, co * sh - s * sy, s * sh + co * sy, e, f])
|
|
}
|
|
|
|
/// Interpolate two transforms by their parts: the rotation takes the shorter way round.
|
|
pub fn lerp_affine(x: Affine, y: Affine, t: f64) -> Affine {
|
|
let (p, mut q) = (decompose(x), decompose(y));
|
|
let turn = q[2] - p[2];
|
|
q[2] = p[2] + (turn + PI).rem_euclid(2.0 * PI) - PI;
|
|
let mut out = [0.0; 6];
|
|
for (o, (a, b)) in out.iter_mut().zip(p.iter().zip(&q)) {
|
|
*o = lerp_f64(*a, *b, t);
|
|
}
|
|
compose(out)
|
|
}
|
|
|
|
// =====================================================================================
|
|
// Path interpolation
|
|
// =====================================================================================
|
|
|
|
/// Grow a subpath to `n` anchors by splitting its longest segments.
|
|
fn grow(sp: &mut SubPath, n: usize) {
|
|
let mut guard = 0;
|
|
while sp.anchors.len() < n && guard < 20_000 {
|
|
guard += 1;
|
|
if sp.segment_count() == 0 {
|
|
match sp.anchors.last().copied() {
|
|
Some(a) => sp.anchors.push(Anchor::corner(a.p)),
|
|
None => return,
|
|
}
|
|
continue;
|
|
}
|
|
let seg = (0..sp.segment_count())
|
|
.max_by(|i, j| {
|
|
let (ci, cj) = (sp.segment(*i), sp.segment(*j));
|
|
(ci.p3 - ci.p0).hypot().total_cmp(&(cj.p3 - cj.p0).hypot())
|
|
})
|
|
.unwrap_or(0);
|
|
sp.insert_anchor(seg, 0.5);
|
|
}
|
|
}
|
|
|
|
fn lerp_anchor(x: &Anchor, y: &Anchor, t: f64) -> Anchor {
|
|
Anchor { p: x.p.lerp(y.p, t), h_in: x.h_in.lerp(y.h_in, t), h_out: x.h_out.lerp(y.h_out, t), kind: if t < 0.5 { x.kind } else { y.kind } }
|
|
}
|
|
|
|
/// Start `sp` at anchor `i`: a closed subpath turns round to it, an open one clicked at its last
|
|
/// anchor runs the other way (a click on any other anchor of an open path is ignored).
|
|
fn start_at(sp: &mut SubPath, i: usize) {
|
|
let n = sp.anchors.len();
|
|
if i >= n {
|
|
return;
|
|
}
|
|
if sp.closed {
|
|
sp.anchors.rotate_left(i);
|
|
} else if i + 1 == n && n > 1 {
|
|
sp.reverse();
|
|
}
|
|
}
|
|
|
|
/// Reverse a closed subpath keeping its start anchor first.
|
|
fn reverse_closed(sp: &mut SubPath) {
|
|
sp.reverse();
|
|
sp.anchors.rotate_right(1);
|
|
}
|
|
|
|
/// Anchor positions of a subpath relative to its box, scaled to unit size.
|
|
fn normalised(sp: &SubPath) -> Vec<Vec2> {
|
|
let pts = sp.anchors.iter().map(|a| a.p);
|
|
let (mut lo, mut hi) = (Point::new(f64::MAX, f64::MAX), Point::new(f64::MIN, f64::MIN));
|
|
for p in pts.clone() {
|
|
lo = Point::new(lo.x.min(p.x), lo.y.min(p.y));
|
|
hi = Point::new(hi.x.max(p.x), hi.y.max(p.y));
|
|
}
|
|
let c = lo.midpoint(hi);
|
|
let s = (hi.x - lo.x).max(hi.y - lo.y).max(1e-9);
|
|
pts.map(|p| (p - c) / s).collect()
|
|
}
|
|
|
|
/// The rotation of `b`'s anchors that best matches `a`'s (least squared distance between
|
|
/// corresponding anchors, each shape centred and scaled to unit size): the start point that keeps
|
|
/// a closed blend from twisting. Long subpaths try every few rotations first, then refine.
|
|
fn best_rotation(a: &SubPath, b: &SubPath) -> usize {
|
|
let (qa, qb) = (normalised(a), normalised(b));
|
|
let m = qa.len().min(qb.len());
|
|
if m < 2 {
|
|
return 0;
|
|
}
|
|
let cost = |s: usize| -> f64 { (0..m).map(|j| (qa[j] - qb[(j + s) % m]).hypot2()).sum() };
|
|
let stride = m.div_ceil(512);
|
|
let mut best = (0..m).step_by(stride).min_by(|x, y| cost(*x).total_cmp(&cost(*y))).unwrap_or(0);
|
|
if stride > 1 {
|
|
best = (best + m - stride..=best + m + stride).map(|s| s % m).min_by(|x, y| cost(*x).total_cmp(&cost(*y))).unwrap_or(best);
|
|
}
|
|
best
|
|
}
|
|
|
|
/// Two paths prepared for interpolation: subpaths paired in order, with equal anchor counts, the
|
|
/// same winding and matching start points.
|
|
#[derive(Clone, Debug)]
|
|
pub struct PathPair {
|
|
a: Vec<SubPath>,
|
|
b: Vec<SubPath>,
|
|
}
|
|
|
|
impl PathPair {
|
|
/// `starts`: the anchor of each path's first subpath its blend starts from (the Blend tool's
|
|
/// clicks); with neither, closed subpaths start where they twist least.
|
|
pub fn new(a: &PathData, b: &PathData, starts: (Option<usize>, Option<usize>)) -> Self {
|
|
let n = a.subpaths.len().max(b.subpaths.len());
|
|
let ca = a.bounds().map(|r| r.center()).unwrap_or_default();
|
|
let cb = b.bounds().map(|r| r.center()).unwrap_or_default();
|
|
let degenerate = |like: &SubPath, c: Point| SubPath::new(vec![Anchor::corner(c); like.anchors.len().max(1)], like.closed);
|
|
let (mut va, mut vb) = (Vec::with_capacity(n), Vec::with_capacity(n));
|
|
for i in 0..n {
|
|
let (mut sa, mut sb) = match (a.subpaths.get(i), b.subpaths.get(i)) {
|
|
(Some(x), Some(y)) => (x.clone(), y.clone()),
|
|
(Some(x), None) => (x.clone(), degenerate(x, cb)),
|
|
(None, Some(y)) => (degenerate(y, ca), y.clone()),
|
|
(None, None) => continue,
|
|
};
|
|
let explicit = i == 0 && (starts.0.is_some() || starts.1.is_some());
|
|
if explicit {
|
|
if let Some(s) = starts.0 {
|
|
start_at(&mut sa, s);
|
|
}
|
|
if let Some(s) = starts.1 {
|
|
start_at(&mut sb, s);
|
|
}
|
|
}
|
|
let closed = sa.closed && sb.closed && sa.anchors.len() > 2 && sb.anchors.len() > 2;
|
|
if closed && (sa.area() * sb.area()) < 0.0 {
|
|
reverse_closed(&mut sb);
|
|
}
|
|
let m = sa.anchors.len().max(sb.anchors.len());
|
|
grow(&mut sa, m);
|
|
grow(&mut sb, m);
|
|
if closed && !explicit {
|
|
let r = best_rotation(&sa, &sb);
|
|
sb.anchors.rotate_left(r);
|
|
}
|
|
va.push(sa);
|
|
vb.push(sb);
|
|
}
|
|
Self { a: va, b: vb }
|
|
}
|
|
|
|
/// The path at `t` (0 = `a`, 1 = `b`).
|
|
pub fn at(&self, t: f64) -> PathData {
|
|
let subs = self
|
|
.a
|
|
.iter()
|
|
.zip(&self.b)
|
|
.map(|(sa, sb)| {
|
|
let anchors = sa.anchors.iter().zip(&sb.anchors).map(|(x, y)| lerp_anchor(x, y, t)).collect();
|
|
SubPath::new(anchors, if t < 0.5 { sa.closed } else { sb.closed })
|
|
})
|
|
.collect();
|
|
PathData::new(subs)
|
|
}
|
|
|
|
/// Subpaths at `t`, in order (see [`Self::at`]).
|
|
fn len(&self) -> usize {
|
|
self.a.len()
|
|
}
|
|
}
|
|
|
|
/// Interpolate two paths anchor-by-anchor (see [`PathPair`]).
|
|
pub fn lerp_path(a: &PathData, b: &PathData, t: f64) -> PathData {
|
|
PathPair::new(a, b, (None, None)).at(t)
|
|
}
|
|
|
|
/// Path data of a path or compound path node (compound children concatenated), its fill rule and
|
|
/// the number of subpaths each compound member holds.
|
|
pub(crate) fn node_path(n: &Node) -> Option<(PathData, FillRule, Option<Vec<usize>>)> {
|
|
match &n.kind {
|
|
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule, None)),
|
|
NodeKind::Compound { children, rule } => {
|
|
let members: Vec<&PathData> = children.iter().filter_map(|c| c.path_data()).collect();
|
|
let counts = members.iter().map(|p| p.subpaths.len()).collect();
|
|
let subs = members.iter().flat_map(|p| p.subpaths.iter().cloned()).collect();
|
|
Some((PathData::new(subs), *rule, Some(counts)))
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
// =====================================================================================
|
|
// Object interpolation
|
|
// =====================================================================================
|
|
|
|
/// How two key objects interpolate.
|
|
#[derive(Clone, Debug)]
|
|
enum Shape {
|
|
/// Groups of one kind: children paired in stacking order.
|
|
Group { children: Vec<Lerp>, clip: bool },
|
|
/// Gradient meshes with the same grid.
|
|
Mesh,
|
|
/// Paths and compound paths; `members`: the subpath count of each member when both keys are
|
|
/// compound paths.
|
|
Path { pair: PathPair, rules: (FillRule, FillRule), members: Option<(Vec<usize>, Vec<usize>)> },
|
|
/// Text, symbols and images: their transforms interpolate.
|
|
Placed,
|
|
/// Anything else: a copy of the nearer key moved and scaled into the interpolated box.
|
|
Other,
|
|
}
|
|
|
|
/// Two key objects prepared for interpolation, so each step of a blend only interpolates.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Lerp {
|
|
a: Node,
|
|
b: Node,
|
|
shape: Shape,
|
|
}
|
|
|
|
/// `n` shrunk to a point at `c`: the counterpart of an object only one of two groups has.
|
|
fn collapsed(n: &Node, c: Point) -> Node {
|
|
let mut x = n.clone();
|
|
if let Some(b) = n.geometric_bounds() {
|
|
x.transform(Affine::translate(c.to_vec2()) * Affine::scale(1e-6) * Affine::translate(-b.center().to_vec2()), false);
|
|
}
|
|
x
|
|
}
|
|
|
|
fn center_of(n: &Node) -> Point {
|
|
n.geometric_bounds().map(|b| b.center()).unwrap_or_default()
|
|
}
|
|
|
|
impl Lerp {
|
|
/// Prepare `a` → `b`; `starts` as in [`PathPair::new`].
|
|
pub fn new(a: &Node, b: &Node, starts: (Option<usize>, Option<usize>)) -> Self {
|
|
let shape = match (&a.kind, &b.kind) {
|
|
(NodeKind::Group { children: ca, clip: k1 }, NodeKind::Group { children: cb, clip: k2 })
|
|
if k1 == k2 && !(*k1 && ca.len() != cb.len()) =>
|
|
{
|
|
let (oa, ob) = (center_of(a), center_of(b));
|
|
let n = ca.len().max(cb.len());
|
|
let children = (0..n)
|
|
.filter_map(|i| match (ca.get(i), cb.get(i)) {
|
|
(Some(x), Some(y)) => Some(Lerp::new(x, y, (None, None))),
|
|
(Some(x), None) => Some(Lerp::new(x, &collapsed(x, ob), (None, None))),
|
|
(None, Some(y)) => Some(Lerp::new(&collapsed(y, oa), y, (None, None))),
|
|
(None, None) => None,
|
|
})
|
|
.collect();
|
|
Shape::Group { children, clip: *k1 }
|
|
}
|
|
(NodeKind::Mesh(ma), NodeKind::Mesh(mb)) if ma.rows == mb.rows && ma.cols == mb.cols && ma.points.len() == mb.points.len() => Shape::Mesh,
|
|
(NodeKind::Text(_), NodeKind::Text(_))
|
|
| (NodeKind::SymbolInstance { .. }, NodeKind::SymbolInstance { .. })
|
|
| (NodeKind::Image(_), NodeKind::Image(_))
|
|
| (NodeKind::PlacedDocument(_), NodeKind::PlacedDocument(_)) => Shape::Placed,
|
|
_ => match (node_path(a), node_path(b)) {
|
|
(Some((pa, ra, ma)), Some((pb, rb, mb))) => {
|
|
Shape::Path { pair: PathPair::new(&pa, &pb, starts), rules: (ra, rb), members: ma.zip(mb) }
|
|
}
|
|
_ => Shape::Other,
|
|
},
|
|
};
|
|
Self { a: a.clone(), b: b.clone(), shape }
|
|
}
|
|
|
|
/// The object at `t` (0 = `a`, 1 = `b`), with id 0.
|
|
pub fn at(&self, t: f64) -> Node {
|
|
self.at_eased(t, t)
|
|
}
|
|
|
|
/// The object whose shape is at `t` and whose colours (fills, strokes, opacity, mesh and text
|
|
/// colours) are at `tc`: a blend with its colours eased apart from its spacing.
|
|
pub fn at_eased(&self, t: f64, tc: f64) -> Node {
|
|
let (a, b) = (&self.a, &self.b);
|
|
let base = if t < 0.5 { a } else { b };
|
|
let mut n = match &self.shape {
|
|
Shape::Group { children, clip } => {
|
|
let mut g =
|
|
Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at_eased(t, tc))).collect(), clip: *clip });
|
|
g.isolate = base.isolate;
|
|
g.knockout = base.knockout;
|
|
g
|
|
}
|
|
Shape::Mesh => {
|
|
let mut n = base.clone();
|
|
if let (NodeKind::Mesh(m), NodeKind::Mesh(ma), NodeKind::Mesh(mb)) = (&mut n.kind, &a.kind, &b.kind) {
|
|
for ((p, x), y) in m.points.iter_mut().zip(&ma.points).zip(&mb.points) {
|
|
p.p = x.p.lerp(y.p, t);
|
|
p.color = lerp_color(&x.color, &y.color, tc as f32);
|
|
p.opacity = lerp_f32(x.opacity, y.opacity, tc as f32);
|
|
for h in 0..4 {
|
|
p.handles[h] = x.handles[h].lerp(y.handles[h], t);
|
|
}
|
|
}
|
|
}
|
|
n.extras_mut().mask = None;
|
|
n
|
|
}
|
|
Shape::Path { pair, rules, members } => {
|
|
let path = pair.at(t);
|
|
let rule = if t < 0.5 { rules.0 } else { rules.1 };
|
|
match members {
|
|
Some((ma, mb)) => compound(path, rule, if t < 0.5 { ma } else { mb }, pair.len()),
|
|
None => {
|
|
let mut n = Node::path(NodeId(0), path, Appearance::default());
|
|
if let NodeKind::Path { rule: r, .. } = &mut n.kind {
|
|
*r = rule;
|
|
}
|
|
n
|
|
}
|
|
}
|
|
}
|
|
Shape::Placed => {
|
|
let mut n = base.clone();
|
|
match (&mut n.kind, &a.kind, &b.kind) {
|
|
(NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t, tc),
|
|
(NodeKind::SymbolInstance { xf: o, .. }, NodeKind::SymbolInstance { xf: x, .. }, NodeKind::SymbolInstance { xf: y, .. }) => {
|
|
*o = lerp_affine(*x, *y, t)
|
|
}
|
|
(NodeKind::Image(o), NodeKind::Image(x), NodeKind::Image(y)) => o.xf = lerp_affine(x.xf, y.xf, t),
|
|
(NodeKind::PlacedDocument(o), NodeKind::PlacedDocument(x), NodeKind::PlacedDocument(y)) => o.xf = lerp_affine(x.xf, y.xf, t),
|
|
_ => {}
|
|
}
|
|
n.extras_mut().mask = None;
|
|
n
|
|
}
|
|
Shape::Other => {
|
|
// Different structure: move/scale a copy of the nearer key into the interpolated box.
|
|
let mut n = base.clone();
|
|
if let (Some(ba), Some(bb), Some(bn)) = (a.geometric_bounds(), b.geometric_bounds(), base.geometric_bounds()) {
|
|
let w = ba.width() + (bb.width() - ba.width()) * t;
|
|
let h = ba.height() + (bb.height() - ba.height()) * t;
|
|
let c = ba.center().lerp(bb.center(), t);
|
|
let sx = if bn.width() > 1e-9 { w / bn.width() } else { 1.0 };
|
|
let sy = if bn.height() > 1e-9 { h / bn.height() } else { 1.0 };
|
|
n.transform(
|
|
Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-bn.center().to_vec2()),
|
|
false,
|
|
);
|
|
}
|
|
if let NodeKind::Path { live, .. } = &mut n.kind {
|
|
*live = None;
|
|
}
|
|
// A copy's members are new objects too.
|
|
for c in n.children_mut().into_iter().flatten() {
|
|
zero_ids(Arc::make_mut(c));
|
|
}
|
|
n.extras_mut().mask = None;
|
|
n
|
|
}
|
|
};
|
|
n.id = NodeId(0);
|
|
n.appearance = lerp_appearance(&a.appearance, &b.appearance, tc).into();
|
|
n.opacity = lerp_f32(a.opacity, b.opacity, tc as f32);
|
|
n.blend = base.blend;
|
|
n.visible = true;
|
|
n.locked = false;
|
|
n.name = None;
|
|
n
|
|
}
|
|
}
|
|
|
|
/// `n` and its descendants with id 0 (generated objects).
|
|
fn zero_ids(n: &mut Node) {
|
|
n.id = NodeId(0);
|
|
for c in n.children_mut().into_iter().flatten() {
|
|
zero_ids(Arc::make_mut(c));
|
|
}
|
|
}
|
|
|
|
/// A compound path of `path`'s subpaths, split into members as `counts` says (one member per
|
|
/// subpath when the counts don't add up to `total`).
|
|
fn compound(path: PathData, rule: FillRule, counts: &[usize], total: usize) -> Node {
|
|
let mut subs = path.subpaths.into_iter();
|
|
let counts: Vec<usize> = if counts.iter().sum::<usize>() == total && !counts.contains(&0) { counts.to_vec() } else { vec![1; total] };
|
|
let children =
|
|
counts.iter().map(|&c| Arc::new(Node::path(NodeId(0), PathData::new(subs.by_ref().take(c).collect()), Appearance::default()))).collect();
|
|
Node::new(NodeId(0), NodeKind::Compound { children, rule })
|
|
}
|
|
|
|
/// Interpolate text `x` → `y` into `o` (a copy of the nearer): the transform by its parts, an
|
|
/// area frame anchor by anchor, and each run's colours, stroke weight and size from the runs at
|
|
/// the same place (by character) of both.
|
|
fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64) {
|
|
o.xf = lerp_affine(x.xf, y.xf, t);
|
|
if let (crate::text::TextKind::Area { frame: f }, crate::text::TextKind::Area { frame: fx }, crate::text::TextKind::Area { frame: fy }) =
|
|
(&mut o.kind, &x.kind, &y.kind)
|
|
{
|
|
*f = lerp_path(fx, fy, t);
|
|
}
|
|
// The run of `of` at fraction `u` of its characters.
|
|
fn run_at(of: &TextObject, u: f64) -> Option<&crate::text::TextRun> {
|
|
let total: usize = of.runs.iter().map(|r| r.text.chars().count()).sum();
|
|
let target = (u * total as f64) as usize;
|
|
let mut seen = 0;
|
|
for r in &of.runs {
|
|
seen += r.text.chars().count();
|
|
if seen > target {
|
|
return Some(r);
|
|
}
|
|
}
|
|
of.runs.last()
|
|
}
|
|
let total: usize = o.runs.iter().map(|r| r.text.chars().count()).sum::<usize>().max(1);
|
|
let mut start = 0usize;
|
|
let tf = tc as f32;
|
|
let mut resized = false;
|
|
for r in &mut o.runs {
|
|
let len = r.text.chars().count();
|
|
let u = (start as f64 + len as f64 / 2.0) / total as f64;
|
|
start += len;
|
|
let (Some(rx), Some(ry)) = (run_at(x, u), run_at(y, u)) else { continue };
|
|
let (sx, sy) = (&rx.style, &ry.style);
|
|
r.style.fill = lerp_paint(&sx.fill, &sy.fill, tf);
|
|
r.style.stroke = lerp_paint(&sx.stroke, &sy.stroke, tf);
|
|
r.style.stroke_width = lerp_f64(sx.stroke_width, sy.stroke_width, t);
|
|
let size = lerp_f64(sx.size, sy.size, t);
|
|
resized |= size != r.style.size;
|
|
r.style.size = size;
|
|
}
|
|
if resized {
|
|
o.cached_bounds = None;
|
|
o.cached_baselines.clear();
|
|
}
|
|
}
|
|
|
|
/// Interpolate two objects at `t` (0 = `a`, 1 = `b`). A blend prepares each pair once with
|
|
/// [`Lerp`] instead.
|
|
pub fn lerp_node(a: &Node, b: &Node, t: f64) -> Node {
|
|
Lerp::new(a, b, (None, None)).at(t)
|
|
}
|
|
|
|
// =====================================================================================
|
|
// Step counts
|
|
// =====================================================================================
|
|
|
|
/// Largest channel difference (0..1) between the solid colours of two objects' top fill and stroke.
|
|
fn color_distance(a: &Node, b: &Node) -> f32 {
|
|
let mut d = 0.0f32;
|
|
let mut cmp = |x: &Paint, y: &Paint| {
|
|
let cols = |p: &Paint| -> Vec<Color> {
|
|
match p {
|
|
Paint::Solid { color, .. } => vec![*color],
|
|
Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color).collect(),
|
|
_ => vec![],
|
|
}
|
|
};
|
|
let (cx, cy) = (cols(x), cols(y));
|
|
for (i, c) in cx.iter().enumerate() {
|
|
if let Some(o) = cy.get(i).or(cy.first()) {
|
|
let (p, q) = (c.to_rgb(), o.to_rgb());
|
|
for k in 0..3 {
|
|
d = d.max((p[k] - q[k]).abs());
|
|
}
|
|
}
|
|
}
|
|
};
|
|
cmp(&a.appearance.fill_paint(), &b.appearance.fill_paint());
|
|
cmp(&a.appearance.stroke_paint(), &b.appearance.stroke_paint());
|
|
d
|
|
}
|
|
|
|
/// Number of intermediate steps between two keys `len` apart (along the spine).
|
|
pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) -> usize {
|
|
match spacing {
|
|
BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize,
|
|
BlendSpacing::Distance(d) => {
|
|
let d = d.max(0.01);
|
|
((len / d).round() as i64 - 1).clamp(0, 1000) as usize
|
|
}
|
|
BlendSpacing::SmoothColor => {
|
|
let cd = color_distance(a, b);
|
|
if cd > 1.0 / 255.0 {
|
|
((cd * 255.0 / 2.0).ceil() as usize).clamp(1, 256)
|
|
} else {
|
|
// Same colours: base the count on the distance between the objects.
|
|
let (ba, bb) = (a.geometric_bounds(), b.geometric_bounds());
|
|
let dist = match (ba, bb) {
|
|
(Some(x), Some(y)) => (x.x0 - y.x0).abs().max((x.x1 - y.x1).abs()).max((x.y0 - y.y0).abs()).max((x.y1 - y.y1).abs()),
|
|
_ => len,
|
|
};
|
|
((dist / 2.0).ceil() as usize).clamp(1, 256)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// =====================================================================================
|
|
// Spine
|
|
// =====================================================================================
|
|
|
|
/// A spine (the first subpath of a path) flattened to a polyline, with cumulative lengths and the
|
|
/// arc length at each of its anchors.
|
|
pub struct Spine {
|
|
pts: Vec<Point>,
|
|
cum: Vec<f64>,
|
|
anchors: Vec<f64>,
|
|
closed: bool,
|
|
}
|
|
|
|
impl Spine {
|
|
pub fn new(path: &PathData) -> Option<Self> {
|
|
let sp = path.subpaths.first()?;
|
|
let mut pts: Vec<Point> = Vec::new();
|
|
let mut cum: Vec<f64> = Vec::new();
|
|
let mut anchors = Vec::with_capacity(sp.anchors.len());
|
|
for i in 0..sp.segment_count() {
|
|
anchors.push(cum.last().copied().unwrap_or(0.0));
|
|
let c = sp.segment(i);
|
|
let mut bp = BezPath::new();
|
|
bp.move_to(c.p0);
|
|
if sp.segment_is_line(i) {
|
|
bp.line_to(c.p3);
|
|
} else {
|
|
bp.curve_to(c.p1, c.p2, c.p3);
|
|
}
|
|
vectorcraft_geom::kurbo::flatten(bp.iter(), 0.1, |el| {
|
|
if let vectorcraft_geom::PathEl::MoveTo(p) | vectorcraft_geom::PathEl::LineTo(p) = el
|
|
&& p.x.is_finite()
|
|
&& p.y.is_finite()
|
|
&& pts.last().is_none_or(|q| q.distance(p) > 1e-9)
|
|
{
|
|
let s = match (pts.last(), cum.last()) {
|
|
(Some(q), Some(l)) => l + q.distance(p),
|
|
_ => 0.0,
|
|
};
|
|
pts.push(p);
|
|
cum.push(s);
|
|
}
|
|
});
|
|
}
|
|
if !sp.closed {
|
|
anchors.push(cum.last().copied().unwrap_or(0.0));
|
|
}
|
|
if pts.len() < 2 {
|
|
return None;
|
|
}
|
|
Some(Self { pts, cum, anchors, closed: sp.closed })
|
|
}
|
|
pub fn length(&self) -> f64 {
|
|
*self.cum.last().unwrap_or(&0.0)
|
|
}
|
|
/// Arc length at anchor `i`.
|
|
pub fn anchor_length(&self, i: usize) -> Option<f64> {
|
|
self.anchors.get(i).copied()
|
|
}
|
|
/// Point and tangent angle (radians) at arc-length fraction `f` (0..1).
|
|
pub fn at(&self, f: f64) -> (Point, f64) {
|
|
self.at_length(f.clamp(0.0, 1.0) * self.length())
|
|
}
|
|
/// Point and tangent angle (radians) at arc length `s` (clamped to the spine).
|
|
pub fn at_length(&self, s: f64) -> (Point, f64) {
|
|
let last = self.pts.len().saturating_sub(2);
|
|
let s = if s.is_finite() { s.clamp(0.0, self.length()) } else { 0.0 };
|
|
let i = match self.cum.binary_search_by(|c| c.total_cmp(&s)) {
|
|
Ok(i) => i.min(last),
|
|
Err(i) => i.saturating_sub(1).min(last),
|
|
};
|
|
let (Some(p0), Some(p1), Some(c0), Some(c1)) = (self.pts.get(i), self.pts.get(i + 1), self.cum.get(i), self.cum.get(i + 1)) else {
|
|
return (self.pts.first().copied().unwrap_or_default(), 0.0);
|
|
};
|
|
let u = ((s - c0) / (c1 - c0).max(1e-12)).clamp(0.0, 1.0);
|
|
let d = *p1 - *p0;
|
|
(p0.lerp(*p1, u), d.y.atan2(d.x))
|
|
}
|
|
}
|
|
|
|
/// The anchor of the first subpath each key sits on, when they are all valid for `n` anchors.
|
|
fn pinned(spec: &BlendSpec, keys: usize, n: usize) -> Option<Vec<usize>> {
|
|
(spec.key_anchors.len() == keys && keys >= 2 && spec.key_anchors.iter().all(|a| (*a as usize) < n))
|
|
.then(|| spec.key_anchors.iter().map(|a| *a as usize).collect())
|
|
}
|
|
|
|
/// The spine of a blend of `keys` and the anchor of it each key sits on:
|
|
/// - without a stored spine: the straight lines between the key centres;
|
|
/// - a pinned spine (`key_anchors`): the stored spine's first subpath with each key's anchor (and
|
|
/// its handles) moved onto the key's centre, so moving a key moves its end of the spine;
|
|
/// - a spine from Replace Spine: its first subpath, `None` for the anchors (the keys spread
|
|
/// evenly along it by arc length).
|
|
pub fn blend_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Option<Vec<usize>>)> {
|
|
if keys.len() < 2 {
|
|
return None;
|
|
}
|
|
let Some(stored) = &spec.spine else {
|
|
let centers: Vec<Point> = keys.iter().map(|k| center_of(k)).collect();
|
|
return Some((PathData::single(SubPath::polyline(¢ers, false)), Some((0..keys.len()).collect())));
|
|
};
|
|
let mut first = stored.subpaths.first()?.clone();
|
|
let Some(ka) = pinned(spec, keys.len(), first.anchors.len()) else { return Some((PathData::single(first), None)) };
|
|
for (k, &ai) in keys.iter().zip(&ka) {
|
|
let c = center_of(k);
|
|
if let Some(a) = first.anchors.get_mut(ai) {
|
|
let d = c - a.p;
|
|
a.translate(d);
|
|
}
|
|
}
|
|
Some((PathData::single(first), Some(ka)))
|
|
}
|
|
|
|
/// [`blend_spine`] with every key on an anchor: a spine from Replace Spine gets anchors where its
|
|
/// keys sit (by arc length), so editing it keeps them in place.
|
|
pub fn pin_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Vec<usize>)> {
|
|
let (mut path, anchors) = blend_spine(keys, spec)?;
|
|
if let Some(a) = anchors {
|
|
return Some((path, a));
|
|
}
|
|
let k = keys.len();
|
|
let total = Spine::new(&path)?.length();
|
|
let closed = path.subpaths.first().is_some_and(|s| s.closed);
|
|
let mut out = vec![0usize];
|
|
for i in 1..k.saturating_sub(1) {
|
|
let s = total * i as f64 / (k - 1) as f64;
|
|
out.push(anchor_at_length(&mut path, s)?);
|
|
}
|
|
let last = path.subpaths.first().map_or(0, |sp| if closed { 0 } else { sp.anchors.len().saturating_sub(1) });
|
|
out.push(last);
|
|
Some((path, out))
|
|
}
|
|
|
|
/// The anchor of the first subpath at arc length `s`: an existing one within a hundredth of a
|
|
/// point, else a new one splitting the segment there.
|
|
fn anchor_at_length(path: &mut PathData, s: f64) -> Option<usize> {
|
|
let spine = Spine::new(path)?;
|
|
let sp = path.subpaths.first_mut()?;
|
|
let n = sp.segment_count();
|
|
let seg = (0..n).rev().find(|i| spine.anchor_length(*i).is_some_and(|l| l <= s)).unwrap_or(0);
|
|
let from = spine.anchor_length(seg).unwrap_or(0.0);
|
|
let to = spine.anchor_length(seg + 1).unwrap_or(spine.length());
|
|
if s - from < 0.01 {
|
|
return Some(seg);
|
|
}
|
|
if to - s < 0.01 {
|
|
return Some((seg + 1) % sp.anchors.len().max(1));
|
|
}
|
|
let c = sp.segment(seg);
|
|
// The flattened length and the curve's own differ slightly: scale into the curve's.
|
|
let arc = c.arclen(1e-4).max(1e-12);
|
|
let t = c.inv_arclen((s - from) / (to - from).max(1e-12) * arc, 1e-4).clamp(1e-4, 1.0 - 1e-4);
|
|
Some(sp.insert_anchor(seg, t))
|
|
}
|
|
|
|
/// Where a blend's keys and steps go along its spine (see [`blend_spine`]).
|
|
struct Rail {
|
|
spine: Spine,
|
|
/// Arc length of each key.
|
|
stops: Vec<f64>,
|
|
centers: Vec<Point>,
|
|
rotate: bool,
|
|
}
|
|
|
|
impl Rail {
|
|
/// `None` when nothing moves: no stored spine and the steps keep the page's orientation.
|
|
fn new(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<Self> {
|
|
let rotate = spec.orientation == BlendOrientation::AlignToPath;
|
|
if spec.spine.is_none() && !rotate {
|
|
return None;
|
|
}
|
|
let (path, anchors) = blend_spine(keys, spec)?;
|
|
let spine = Spine::new(&path)?;
|
|
let total = spine.length();
|
|
let k = keys.len();
|
|
let stops = match anchors {
|
|
Some(ka) => {
|
|
let mut v: Vec<f64> = Vec::with_capacity(k);
|
|
for a in ka {
|
|
let mut s = spine.anchor_length(a).unwrap_or(0.0);
|
|
// On a closed spine the key back at the start sits at its far end.
|
|
if spine.closed
|
|
&& let Some(prev) = v.last()
|
|
&& s <= *prev + 1e-9
|
|
{
|
|
s += total;
|
|
}
|
|
v.push(s);
|
|
}
|
|
v
|
|
}
|
|
None => (0..k).map(|i| total * i as f64 / (k - 1).max(1) as f64).collect(),
|
|
};
|
|
Some(Self { spine, stops, centers: keys.iter().map(|k| center_of(k)).collect(), rotate })
|
|
}
|
|
|
|
/// Arc length between keys `i` and `i + 1`.
|
|
fn len(&self, i: usize) -> f64 {
|
|
match (self.stops.get(i), self.stops.get(i + 1)) {
|
|
(Some(a), Some(b)) => (b - a).abs(),
|
|
_ => 0.0,
|
|
}
|
|
}
|
|
|
|
/// Move `n`, the object at `t` between keys `i` and `i + 1` (a key at `t` = 0), onto the
|
|
/// spine: by how far the spine there is from the straight line between the key centres (so
|
|
/// steps keep their own interpolated position on a straight spine), turned to the spine's
|
|
/// direction with Align to Path.
|
|
fn place(&self, n: &mut Node, i: usize, t: f64) {
|
|
let (Some(&s0), Some(&c0)) = (self.stops.get(i), self.centers.get(i)) else { return };
|
|
let (s, base) = match (self.stops.get(i + 1), self.centers.get(i + 1)) {
|
|
(Some(&s1), Some(&c1)) if t > 0.0 => (lerp_f64(s0, s1, t), c0.lerp(c1, t)),
|
|
_ => (s0, c0),
|
|
};
|
|
let (p, ang) = self.spine.at_length(if self.spine.closed { s.rem_euclid(self.spine.length().max(1e-12)) } else { s });
|
|
let off = p - base;
|
|
let mut m = if off.hypot() > 1e-9 { Affine::translate(off) } else { Affine::IDENTITY };
|
|
if self.rotate && ang.abs() > 1e-12 {
|
|
let c = (center_of(n) + off).to_vec2();
|
|
m = Affine::translate(c) * Affine::rotate(ang) * Affine::translate(-c) * m;
|
|
}
|
|
if m != Affine::IDENTITY {
|
|
n.transform(m, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// =====================================================================================
|
|
// Blend evaluation
|
|
// =====================================================================================
|
|
|
|
/// Whether `n`, every part of it, paints opaquely with Normal blending and no opacity mask or
|
|
/// effect: knocking such objects out of each other changes nothing.
|
|
fn paints_opaquely(n: &Node) -> bool {
|
|
let paint = |p: &Paint| match p {
|
|
Paint::Gradient(g) => g.gradient.stops.iter().all(|s| s.opacity >= 1.0),
|
|
Paint::Pattern { .. } => false,
|
|
_ => true,
|
|
};
|
|
n.opacity >= 1.0
|
|
&& n.blend == vectorcraft_color::BlendMode::Normal
|
|
&& n.extras().mask.is_none()
|
|
&& n.appearance.effects.is_empty()
|
|
&& n.appearance
|
|
.items
|
|
.iter()
|
|
.all(|i| i.opacity() >= 1.0 && i.blend() == vectorcraft_color::BlendMode::Normal && i.effects().is_empty() && paint(i.paint()))
|
|
&& n.children().is_none_or(|c| c.iter().all(|c| paints_opaquely(c)))
|
|
}
|
|
|
|
/// Whether knocking out the children of `n` (a blend's evaluated steps) shows: some step is
|
|
/// translucent or blends.
|
|
pub fn knockout_shows(n: &Node) -> bool {
|
|
n.children().is_some_and(|c| !c.iter().all(|c| paints_opaquely(c)))
|
|
}
|
|
|
|
/// [`knockout_shows`] for evaluated steps.
|
|
pub fn steps_knockout_shows(steps: &[Arc<Node>]) -> bool {
|
|
!steps.iter().all(|c| paints_opaquely(c))
|
|
}
|
|
|
|
/// Most steps a blend makes in all, the steps of the blends inside its keys and steps included.
|
|
/// Each step of a blend whose keys are blends is a copy of one of them, which makes its own steps
|
|
/// when drawn, so nesting multiplies the step counts capped pair by pair: five levels of 15 steps
|
|
/// are 1.3 million steps from a 23 KB file, 3.4 GB to draw (#3579). Past it, a blend makes
|
|
/// proportionally fewer steps between each pair of keys ([`fit_steps`]).
|
|
pub const MAX_BLEND_STEPS: usize = 100_000;
|
|
|
|
/// The steps between each pair of keys, as the blend's spacing asks for them.
|
|
fn requested_steps(keys: &[Arc<Node>], spec: &BlendSpec, rail: Option<&Rail>) -> Vec<usize> {
|
|
keys.windows(2)
|
|
.enumerate()
|
|
.map(|(i, w)| match w {
|
|
[a, b] => blend_step_count(a, b, spec.spacing, rail.map_or_else(|| center_of(a).distance(center_of(b)), |r| r.len(i))),
|
|
_ => 0,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Most steps `spacing` makes between two keys, whatever their size and distance
|
|
/// ([`blend_step_count`]'s clamps).
|
|
fn most_steps(spacing: BlendSpacing) -> usize {
|
|
match spacing {
|
|
BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize,
|
|
BlendSpacing::Distance(_) => 1000,
|
|
BlendSpacing::SmoothColor => 256,
|
|
}
|
|
}
|
|
|
|
/// `want` steps between each pair of keys, fewer where they would make more than
|
|
/// [`MAX_BLEND_STEPS`] in all with `keys` steps already made by the keys and `each[i]` made by
|
|
/// one step of pair `i` (itself and what the blends in it make): every pair keeps the same share
|
|
/// of its steps, rounded down, and none when the keys alone fill the budget.
|
|
fn fit_steps(want: &[usize], each: &[usize], keys: usize) -> Vec<usize> {
|
|
let room = MAX_BLEND_STEPS.saturating_sub(keys) as u128;
|
|
let demand = want.iter().zip(each).map(|(&n, &c)| n as u128 * c as u128).fold(0u128, u128::saturating_add);
|
|
if demand <= room {
|
|
return want.to_vec();
|
|
}
|
|
// `demand` is more than `room`, so it isn't 0, and each share is below `n`.
|
|
want.iter().map(|&n| usize::try_from(n as u128 * room / demand).unwrap_or(0)).collect()
|
|
}
|
|
|
|
/// Counts the steps blends make in all ([`MAX_BLEND_STEPS`]), each object counted once by its
|
|
/// address while the count runs.
|
|
#[derive(Default)]
|
|
struct StepCount {
|
|
known: std::collections::HashMap<(usize, bool), usize>,
|
|
}
|
|
|
|
impl StepCount {
|
|
/// Steps the blends in `n` make in all when drawn, each keeping to [`MAX_BLEND_STEPS`].
|
|
/// `copy`: `n` is part of a blend's step, a copy moved and scaled into place. A blend spaced
|
|
/// by distance (or by distance where the colours match) makes more steps scaled up, so a
|
|
/// copy counts the most its spacing can make between each pair, an upper bound for any scale.
|
|
fn node(&mut self, n: &Node, copy: bool) -> usize {
|
|
let at = (std::ptr::from_ref(n) as usize, copy);
|
|
if let Some(&known) = self.known.get(&at) {
|
|
return known;
|
|
}
|
|
let count = match &n.kind {
|
|
NodeKind::Blend { children, spec } if copy => self.most(children, spec),
|
|
NodeKind::Blend { children, spec } => self.blend(children, spec, Rail::new(children, spec).as_ref()).1,
|
|
// Each instance draws the source again.
|
|
NodeKind::Repeat(r) => r.instances(None).saturating_mul(self.nodes(&r.source, copy)),
|
|
_ => self.nodes(n.children().map_or(&[], Vec::as_slice), copy),
|
|
};
|
|
self.known.insert(at, count);
|
|
count
|
|
}
|
|
|
|
fn nodes(&mut self, nodes: &[Arc<Node>], copy: bool) -> usize {
|
|
nodes.iter().map(|c| self.node(c, copy)).fold(0, usize::saturating_add)
|
|
}
|
|
|
|
/// What one step between keys `a` and `b` makes: itself and the steps of the blends in it.
|
|
/// Groups pair their children as [`Lerp::new`] does; anything else is a copy of the nearer key.
|
|
fn step(&mut self, a: &Node, b: &Node) -> usize {
|
|
let inner = match (&a.kind, &b.kind) {
|
|
(NodeKind::Group { children: ca, clip: k1 }, NodeKind::Group { children: cb, clip: k2 })
|
|
if k1 == k2 && !(*k1 && ca.len() != cb.len()) =>
|
|
{
|
|
(0..ca.len().max(cb.len()))
|
|
.map(|i| match (ca.get(i), cb.get(i)) {
|
|
(Some(x), Some(y)) => self.step(x, y).saturating_sub(1),
|
|
(Some(x), None) | (None, Some(x)) => self.node(x, true),
|
|
(None, None) => 0,
|
|
})
|
|
.fold(0, usize::saturating_add)
|
|
}
|
|
_ => self.node(a, true).max(self.node(b, true)),
|
|
};
|
|
inner.saturating_add(1)
|
|
}
|
|
|
|
/// What each step between each pair of `keys` makes ([`Self::step`]).
|
|
fn steps(&mut self, keys: &[Arc<Node>]) -> Vec<usize> {
|
|
keys.windows(2)
|
|
.map(|w| match w {
|
|
[a, b] => self.step(a, b),
|
|
_ => 1,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The steps between each pair of keys a blend makes ([`fit_steps`]), and the steps it makes
|
|
/// in all, its keys' included.
|
|
fn blend(&mut self, keys: &[Arc<Node>], spec: &BlendSpec, rail: Option<&Rail>) -> (Vec<usize>, usize) {
|
|
let inside = self.nodes(keys, false);
|
|
let each = self.steps(keys);
|
|
let kept = fit_steps(&requested_steps(keys, spec, rail), &each, inside);
|
|
let total = kept.iter().zip(&each).map(|(&n, &c)| n.saturating_mul(c)).fold(inside, usize::saturating_add);
|
|
(kept, total)
|
|
}
|
|
|
|
/// The most steps a copy of a blend makes in all, at any scale: what [`Self::blend`] counts
|
|
/// with [`most_steps`] between each pair, before rounding.
|
|
fn most(&mut self, keys: &[Arc<Node>], spec: &BlendSpec) -> usize {
|
|
let inside = self.nodes(keys, true);
|
|
let each = self.steps(keys);
|
|
let demand = each.iter().map(|&c| c.saturating_mul(most_steps(spec.spacing))).fold(0, usize::saturating_add);
|
|
inside.saturating_add(demand.min(MAX_BLEND_STEPS.saturating_sub(inside)))
|
|
}
|
|
}
|
|
|
|
/// How many steps `keys` blended with `spec` make between each pair of keys: as many as the
|
|
/// spacing asks for, fewer when they and the steps of the blends nested in them would pass
|
|
/// [`MAX_BLEND_STEPS`] ([`blend_expand`] makes these).
|
|
pub fn blend_steps(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<usize> {
|
|
StepCount::default().blend(keys, spec, Rail::new(keys, spec).as_ref()).0
|
|
}
|
|
|
|
/// Evaluate a blend: the keys and generated steps in paint order. The steps keep to
|
|
/// [`MAX_BLEND_STEPS`] in all ([`blend_steps`]).
|
|
pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
|
|
let k = keys.len();
|
|
if k < 2 {
|
|
return keys.iter().map(|n| (**n).clone()).collect();
|
|
}
|
|
let rail = Rail::new(keys, spec);
|
|
let (counts, _) = StepCount::default().blend(keys, spec, rail.as_ref());
|
|
let mut out = Vec::new();
|
|
for (i, a) in keys.iter().enumerate() {
|
|
let mut key = (**a).clone();
|
|
if let Some(r) = &rail {
|
|
r.place(&mut key, i, 0.0);
|
|
}
|
|
out.push(key);
|
|
let Some(b) = keys.get(i + 1) else { break };
|
|
let n = counts.get(i).copied().unwrap_or(0);
|
|
let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1)));
|
|
for j in 1..=n {
|
|
// Even position of the step, then where the easing puts its shape and its colours.
|
|
let u = j as f64 / (n + 1) as f64;
|
|
let (t, tc) = (if rail.is_some() { spec.ease.apply(u) } else { u }, spec.color_position(u));
|
|
let mut s = lerp.at_eased(t, tc);
|
|
if let Some(r) = &rail {
|
|
r.place(&mut s, i, t);
|
|
}
|
|
out.push(s);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use vectorcraft_geom::{Rect, shapes};
|
|
|
|
#[test]
|
|
fn clicked_start_points_pair_up() {
|
|
let a = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
|
|
let mut b = shapes::rectangle(Rect::new(100.0, 0.0, 110.0, 10.0));
|
|
b.subpaths[0].anchors.rotate_left(2);
|
|
// Starting both at their anchor 0 pairs each corner of `a` with the opposite one of `b`:
|
|
// the middle step shrinks to a point.
|
|
assert!(PathPair::new(&a, &b, (Some(0), Some(0))).at(0.5).bounds().unwrap().height() < 1e-6);
|
|
// Starting `b` at its anchor 2 (its first corner again) pairs like corners.
|
|
let r = PathPair::new(&a, &b, (Some(0), Some(2))).at(0.5).bounds().unwrap();
|
|
assert!((r.width() - 10.0).abs() < 1e-6 && (r.height() - 10.0).abs() < 1e-6, "{r:?}");
|
|
// An open path clicked at its end runs the other way.
|
|
let line = |x: f64| PathData::single(SubPath::polyline(&[Point::new(x, 0.0), Point::new(x + 10.0, 0.0)], false));
|
|
let m = PathPair::new(&line(0.0), &line(100.0), (None, Some(1))).at(0.5);
|
|
assert_eq!(m.subpaths[0].anchors[0].p, Point::new(55.0, 0.0));
|
|
}
|
|
|
|
fn key(x: f64) -> Arc<Node> {
|
|
Arc::new(Node::path(NodeId(1), vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default()))
|
|
}
|
|
|
|
#[test]
|
|
fn a_replaced_spine_gets_anchors_where_its_keys_sit() {
|
|
let keys = vec![key(0.0), key(100.0), key(200.0)];
|
|
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 50.0), Point::new(300.0, 50.0)], false));
|
|
let spec = BlendSpec { spine: Some(line), ..Default::default() };
|
|
let (path, anchors) = pin_spine(&keys, &spec).unwrap();
|
|
assert_eq!(anchors, vec![0, 1, 2]);
|
|
assert!((path.subpaths[0].anchors[1].p.x - 150.0).abs() < 1e-6, "the middle key sits halfway");
|
|
// Pinned, each key's anchor follows it.
|
|
let pinned = BlendSpec { spine: Some(path), key_anchors: vec![0, 1, 2], ..Default::default() };
|
|
let (moved, _) = blend_spine(&[key(0.0), key(100.0), key(300.0)], &pinned).unwrap();
|
|
assert_eq!(moved.subpaths[0].anchors[2].p, Point::new(305.0, 5.0));
|
|
}
|
|
|
|
#[test]
|
|
fn steps_follow_a_bent_spine() {
|
|
let keys = vec![key(0.0), key(100.0)];
|
|
// A spine through the key centres bent up through (55, -95).
|
|
let spine = PathData::single(SubPath::polyline(&[Point::new(5.0, 5.0), Point::new(55.0, -95.0), Point::new(105.0, 5.0)], false));
|
|
let spec = BlendSpec { spacing: BlendSpacing::Steps(1), spine: Some(spine), key_anchors: vec![0, 2], ..Default::default() };
|
|
let out = blend_expand(&keys, &spec);
|
|
let mid = out[1].geometric_bounds().unwrap().center();
|
|
assert!(mid.distance(Point::new(55.0, -95.0)) < 1e-6, "{mid:?}");
|
|
assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put");
|
|
}
|
|
|
|
#[test]
|
|
fn easing_keeps_the_ends_and_stays_monotone() {
|
|
use crate::live::{BlendEase, BlendEaseType as K};
|
|
for kind in [K::In, K::Out, K::InOut, K::OutIn] {
|
|
for ramp in [0.0, 0.5, 1.0, f64::NAN, f64::INFINITY] {
|
|
let e = BlendEase { kind, ramp };
|
|
assert_eq!((e.apply(0.0), e.apply(1.0)), (0.0, 1.0));
|
|
let ys: Vec<f64> = (0..=100).map(|i| e.apply(i as f64 / 100.0)).collect();
|
|
assert!(ys.windows(2).all(|w| w[0] <= w[1]));
|
|
assert_eq!(e.apply(f64::NAN), 0.0);
|
|
if ramp == 0.0 {
|
|
assert_eq!(e.apply(0.25), 0.25);
|
|
}
|
|
}
|
|
}
|
|
let e = |kind| BlendEase { kind, ramp: 1.0 };
|
|
assert!(e(K::In).apply(0.25) < 0.25);
|
|
assert!(e(K::Out).apply(0.25) > 0.25);
|
|
assert!(e(K::InOut).apply(0.25) < 0.25 && e(K::InOut).apply(0.75) > 0.75);
|
|
assert!(e(K::OutIn).apply(0.25) > 0.25 && e(K::OutIn).apply(0.75) < 0.75);
|
|
}
|
|
|
|
#[test]
|
|
fn colour_easing_moves_colours_apart_from_positions() {
|
|
let rect = |x: f64, c: Color| {
|
|
let mut n = Node::path(NodeId(1), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default());
|
|
n.appearance = Appearance::basic(Paint::solid(c), Paint::None, 0.0).into();
|
|
Arc::new(n)
|
|
};
|
|
let keys = vec![rect(0.0, Color::BLACK), rect(100.0, Color::WHITE)];
|
|
let mid = |spec: &BlendSpec| {
|
|
let out = blend_expand(&keys, spec);
|
|
let n = &out[1];
|
|
(n.geometric_bounds().unwrap().x0, n.appearance.fill_paint())
|
|
};
|
|
// Position easing runs along a spine: Align to Path gives the blend one.
|
|
let base = BlendSpec { spacing: BlendSpacing::Steps(1), orientation: BlendOrientation::AlignToPath, ..Default::default() };
|
|
let (x0, p0) = mid(&base);
|
|
// Colour easing alone leaves the position and moves the colour.
|
|
let colour = BlendSpec { color_shift: 50.0, ..base.clone() };
|
|
let (x1, p1) = mid(&colour);
|
|
assert!((x0 - x1).abs() < 1e-9 && p0 != p1);
|
|
// Position easing alone leaves the colour and moves the position.
|
|
let pos = BlendSpec { ease: crate::live::BlendEase { kind: crate::live::BlendEaseType::In, ramp: 1.0 }, ..base.clone() };
|
|
let (x2, p2) = mid(&pos);
|
|
assert!((x0 - x2).abs() > 1e-3 && p0 == p2);
|
|
assert_eq!(mid(&pos).0, x2, "deterministic");
|
|
}
|
|
|
|
#[test]
|
|
fn affine_lerp_turns_the_short_way() {
|
|
let x = Affine::rotate(170f64.to_radians());
|
|
let y = Affine::rotate(-170f64.to_radians());
|
|
let m = lerp_affine(x, y, 0.5);
|
|
let ang = decompose(m)[2].to_degrees();
|
|
assert!((ang.abs() - 180.0).abs() < 1e-6, "{ang}");
|
|
let s = lerp_affine(Affine::scale(1.0), Affine::translate((10.0, 0.0)) * Affine::scale(3.0), 0.5);
|
|
assert!((s.as_coeffs()[0] - 2.0).abs() < 1e-9 && (s.as_coeffs()[4] - 5.0).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn closed_paths_start_where_they_twist_least() {
|
|
let a = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
|
|
let mut b = shapes::rectangle(Rect::new(100.0, 0.0, 110.0, 10.0));
|
|
b.subpaths[0].anchors.rotate_left(2);
|
|
let mid = lerp_path(&a, &b, 0.5);
|
|
let r = mid.bounds().unwrap();
|
|
assert!((r.width() - 10.0).abs() < 1e-6 && (r.height() - 10.0).abs() < 1e-6, "no twist: {r:?}");
|
|
// Clicked start points win over the automatic match.
|
|
let pair = PathPair::new(&a, &b, (Some(0), Some(0)));
|
|
assert!(pair.at(0.5).bounds().unwrap().height() < 1e-6, "the clicked anchors pair up");
|
|
}
|
|
|
|
#[test]
|
|
fn blend_between_tints_of_one_swatch_keeps_the_link() {
|
|
let tinted = |tint: f32| Paint::Solid { color: Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint), swatch: Some("Spot".into()), tint };
|
|
let Paint::Solid { swatch, tint, .. } = lerp_paint(&tinted(0.2), &tinted(1.0), 0.5) else { panic!() };
|
|
assert_eq!(swatch.as_deref(), Some("Spot"));
|
|
assert!((tint - 0.6).abs() < 1e-6, "{tint}");
|
|
let other = Paint::Solid { color: Color::BLACK, swatch: Some("Other".into()), tint: 1.0 };
|
|
assert!(matches!(lerp_paint(&tinted(0.2), &other, 0.5), Paint::Solid { swatch: None, .. }));
|
|
|
|
// Gradient stops on tints of one swatch stay linked too; a process solid unlinks them.
|
|
let spot_gradient = |tint: f32| {
|
|
let mut stop = GradientStop::new(0.0, Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint));
|
|
stop.swatch = Some("Spot".into());
|
|
stop.tint = tint;
|
|
let stops = vec![stop.clone(), GradientStop { offset: 1.0, ..stop }];
|
|
Paint::Gradient(Box::new(GradientPaint::new(Gradient { stops, ..Gradient::default() })))
|
|
};
|
|
let Paint::Gradient(m) = lerp_paint(&spot_gradient(0.2), &spot_gradient(1.0), 0.5) else { panic!() };
|
|
assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot") && (s.tint - 0.6).abs() < 1e-6), "{:?}", m.gradient.stops);
|
|
let Paint::Gradient(m) = lerp_paint(&tinted(0.2), &spot_gradient(1.0), 0.5) else { panic!() };
|
|
assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot") && (s.tint - 0.6).abs() < 1e-6), "{:?}", m.gradient.stops);
|
|
let Paint::Gradient(m) = lerp_paint(&Paint::solid(Color::BLACK), &spot_gradient(1.0), 0.5) else { panic!() };
|
|
assert!(m.gradient.stops.iter().all(|s| s.swatch.is_none()), "{:?}", m.gradient.stops);
|
|
}
|
|
|
|
#[test]
|
|
fn resampled_gradient_stops_keep_the_swatch_link() {
|
|
let spot = |offset: f32, tint: f32| {
|
|
let mut stop = GradientStop::new(offset, Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint));
|
|
stop.swatch = Some("Spot".into());
|
|
stop.tint = tint;
|
|
stop
|
|
};
|
|
let paint = |stops: Vec<GradientStop>| Paint::Gradient(Box::new(GradientPaint::new(Gradient { stops, ..Gradient::default() })));
|
|
let two = paint(vec![spot(0.0, 0.2), spot(1.0, 1.0)]);
|
|
let three = paint(vec![spot(0.0, 0.2), spot(0.5, 0.6), spot(1.0, 1.0)]);
|
|
let Paint::Gradient(m) = lerp_paint(&two, &three, 0.5) else { panic!() };
|
|
assert_eq!(m.gradient.stops.len(), 3);
|
|
assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot")), "{:?}", m.gradient.stops);
|
|
let tints: Vec<f32> = m.gradient.stops.iter().map(|s| s.tint).collect();
|
|
assert!((tints[1] - 0.6).abs() < 1e-6 && (tints[2] - 1.0).abs() < 1e-6, "{tints:?}");
|
|
|
|
// Spot on one side, process on the other: the stops are unlinked.
|
|
let mut plain = spot(0.5, 1.0);
|
|
plain.swatch = None;
|
|
let mixed = paint(vec![spot(0.0, 1.0), plain, spot(1.0, 1.0)]);
|
|
let Paint::Gradient(m) = lerp_paint(&two, &mixed, 0.5) else { panic!() };
|
|
assert!(m.gradient.stops.iter().any(|s| s.swatch.is_none()), "{:?}", m.gradient.stops);
|
|
}
|
|
|
|
#[test]
|
|
fn gradients_with_different_stop_counts_interpolate() {
|
|
let g = |stops: &[(f32, Color)]| {
|
|
let gradient = Gradient { stops: stops.iter().map(|(o, c)| GradientStop::new(*o, *c)).collect(), ..Gradient::default() };
|
|
Paint::Gradient(Box::new(GradientPaint::new(gradient)))
|
|
};
|
|
let a = g(&[(0.0, Color::BLACK), (1.0, Color::BLACK)]);
|
|
let b = g(&[(0.0, Color::WHITE), (0.5, Color::WHITE), (1.0, Color::WHITE)]);
|
|
let Paint::Gradient(m) = lerp_paint(&a, &b, 0.25) else { panic!() };
|
|
assert_eq!(m.gradient.stops.len(), 3);
|
|
assert!(m.gradient.stops.iter().all(|s| (s.color.to_rgb()[0] - 0.25).abs() < 1e-4), "{:?}", m.gradient.stops);
|
|
}
|
|
|
|
#[test]
|
|
fn dashes_and_profiles_interpolate() {
|
|
let d = lerp_dash(Some(&Dash { pattern: vec![10.0, 10.0], ..Default::default() }), None, 0.5).unwrap();
|
|
assert_eq!(d.pattern, vec![15.0, 5.0]);
|
|
let d =
|
|
lerp_dash(Some(&Dash { pattern: vec![4.0], ..Default::default() }), Some(&Dash { pattern: vec![2.0, 6.0], ..Default::default() }), 0.5);
|
|
assert_eq!(d.unwrap().pattern, vec![3.0, 5.0]);
|
|
let p = lerp_profile(Some(&WidthProfile::lens()), None, 0.5).unwrap();
|
|
assert_eq!(p.at(0.0), (0.5, 0.5));
|
|
assert_eq!(p.at(0.5), (1.0, 1.0));
|
|
}
|
|
|
|
#[test]
|
|
fn copied_steps_get_new_members() {
|
|
let rect = |x: f64| Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default()));
|
|
let a = Node::new(NodeId(1), NodeKind::Group { children: vec![rect(0.0), rect(20.0)], clip: true });
|
|
let b = Node::new(NodeId(2), NodeKind::Group { children: vec![rect(100.0)], clip: true });
|
|
let mid = lerp_node(&a, &b, 0.4);
|
|
let mut ids = vec![];
|
|
mid.walk(&mut |n| ids.push(n.id));
|
|
assert!(ids.iter().all(|i| *i == NodeId(0)), "{ids:?}");
|
|
}
|
|
|
|
/// A blend of `a` and `b` spaced by `spacing`.
|
|
fn blend_of(a: Node, b: Node, spacing: BlendSpacing) -> Node {
|
|
let spec = BlendSpec { spacing, ..Default::default() };
|
|
Node::new(NodeId(1), NodeKind::Blend { children: vec![Arc::new(a), Arc::new(b)], spec: Box::new(spec) })
|
|
}
|
|
|
|
/// A blend's keys and options.
|
|
fn blend_parts(n: &Node) -> Option<(&Vec<Arc<Node>>, &BlendSpec)> {
|
|
match &n.kind {
|
|
NodeKind::Blend { children, spec } => Some((children, spec)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// A square of side `side` at (`x`, `y`).
|
|
fn square(x: f64, y: f64, side: f64) -> Node {
|
|
Node::path(NodeId(1), shapes::rectangle(Rect::new(x, y, x + side, y + side)), Appearance::default())
|
|
}
|
|
|
|
/// `levels` of blends of 15 steps whose keys are the blends a level down (#3579's input).
|
|
fn nested(levels: u32, x: f64) -> Node {
|
|
if levels == 0 {
|
|
return square(x, 0.0, 4.0);
|
|
}
|
|
let w = 40.0 * f64::from(levels);
|
|
blend_of(nested(levels - 1, x), nested(levels - 1, x + w), BlendSpacing::Steps(15))
|
|
}
|
|
|
|
/// The steps drawing `n` makes in all, each blend evaluated as the renderer does: one level at
|
|
/// a time, the blends in its keys and steps evaluated again.
|
|
fn drawn_steps(n: &Node) -> usize {
|
|
match &n.kind {
|
|
NodeKind::Blend { children, spec } => {
|
|
let out = blend_expand(children, spec);
|
|
out.len() - children.len() + out.iter().map(drawn_steps).sum::<usize>()
|
|
}
|
|
_ => n.children().map_or(0, |c| c.iter().map(|c| drawn_steps(c)).sum()),
|
|
}
|
|
}
|
|
|
|
/// Steps `levels` of [`nested`] blends of 15 steps make in all: each level its 15 steps,
|
|
/// and again the steps of the level below in each key and each step.
|
|
fn nested_steps(levels: u32) -> usize {
|
|
(17usize.pow(levels) - 1) * 15 / 16
|
|
}
|
|
|
|
/// #3579: blends whose keys are blends draw their steps again in every step above them. Five
|
|
/// levels of 15 steps made 1.3 million steps from a 23 KB file. Now a blend makes fewer steps
|
|
/// where they would pass `MAX_BLEND_STEPS`, and none where its keys already fill it, so each
|
|
/// level past that adds only what its keys draw.
|
|
#[test]
|
|
fn nested_blends_keep_to_the_step_budget() {
|
|
assert_eq!(nested_steps(5), 1_331_115);
|
|
let four = nested_steps(4);
|
|
assert!(four < MAX_BLEND_STEPS && 2 * four > MAX_BLEND_STEPS);
|
|
let five = nested(5, 0.0);
|
|
let (children, spec) = blend_parts(&five).unwrap();
|
|
assert_eq!(blend_steps(children, spec), vec![0]);
|
|
assert_eq!(drawn_steps(&five), 2 * four);
|
|
assert_eq!(drawn_steps(&nested(6, 0.0)), 4 * four);
|
|
// A blend whose keys leave room keeps the same share of its steps between each pair.
|
|
let mut three = nested(3, 0.0);
|
|
if let NodeKind::Blend { spec, .. } = &mut three.kind {
|
|
spec.spacing = BlendSpacing::Steps(1000);
|
|
}
|
|
let (children, spec) = blend_parts(&three).unwrap();
|
|
let inside = 2 * nested_steps(2);
|
|
let each = 1 + nested_steps(2);
|
|
let kept = (MAX_BLEND_STEPS - inside) * 1000 / (1000 * each);
|
|
assert_eq!(blend_steps(children, spec), vec![kept]);
|
|
let drawn = drawn_steps(&three);
|
|
assert_eq!(drawn, inside + kept * each);
|
|
assert!(drawn <= MAX_BLEND_STEPS && drawn > MAX_BLEND_STEPS - each, "{drawn}");
|
|
}
|
|
|
|
/// Nested blends within the budget make every step their spacing asks for, as before.
|
|
#[test]
|
|
fn nested_blends_within_the_budget_draw_every_step() {
|
|
for levels in 1..=4 {
|
|
let n = nested(levels, 0.0);
|
|
let (children, spec) = blend_parts(&n).unwrap();
|
|
assert_eq!(blend_steps(children, spec), vec![15]);
|
|
assert_eq!(drawn_steps(&n), nested_steps(levels), "{levels}");
|
|
}
|
|
// The keys, then 15 steps, then the last key, each a blend whose keys moved with it.
|
|
let two = nested(2, 0.0);
|
|
let (children, spec) = blend_parts(&two).unwrap();
|
|
let out = blend_expand(children, spec);
|
|
assert_eq!(out.len(), 17);
|
|
assert_eq!(out[0], *children[0]);
|
|
assert!(out.iter().all(|s| matches!(s.kind, NodeKind::Blend { .. })));
|
|
assert_eq!(out[8].geometric_bounds(), children[0].geometric_bounds().map(|b| b + Vec2::new(40.0, 0.0)));
|
|
}
|
|
|
|
/// A blend spaced by distance makes more steps scaled up, so a step's copy of one counts the
|
|
/// most its spacing can make. Here a tiny blend of one step is blended with a large blend of
|
|
/// one step: copies of the tiny one, scaled up toward the large one's size, make up to 1000
|
|
/// steps each, which neither key's own count shows.
|
|
#[test]
|
|
fn scaled_copies_of_blends_spaced_by_distance_keep_to_the_budget() {
|
|
let tiny = blend_of(square(0.0, 0.0, 1.0), square(2.0, 0.0, 1.0), BlendSpacing::Distance(1.0));
|
|
let large = blend_of(square(0.0, 100.0, 1000.0), square(2000.0, 100.0, 1000.0), BlendSpacing::Steps(1));
|
|
assert_eq!((drawn_steps(&tiny), drawn_steps(&large)), (1, 1));
|
|
let n = blend_of(tiny, large, BlendSpacing::Steps(1000));
|
|
let (children, spec) = blend_parts(&n).unwrap();
|
|
let steps = blend_steps(children, spec);
|
|
assert!(steps[0] < 1000, "{steps:?}");
|
|
let drawn = drawn_steps(&n);
|
|
assert!(drawn <= MAX_BLEND_STEPS && drawn > 1000, "{drawn}");
|
|
}
|
|
|
|
/// A flat blend of many keys shares the budget evenly between its pairs.
|
|
#[test]
|
|
fn many_keys_share_the_step_budget() {
|
|
let keys: Vec<Arc<Node>> = (0..300).map(|i| key(f64::from(i) * 20.0)).collect();
|
|
let spec = BlendSpec { spacing: BlendSpacing::Steps(1000), ..Default::default() };
|
|
let steps = blend_steps(&keys, &spec);
|
|
assert!(steps.iter().all(|&n| n == MAX_BLEND_STEPS * 1000 / (299 * 1000)), "{:?}", &steps[..3]);
|
|
assert_eq!(blend_expand(&keys, &spec).len(), 300 + steps.iter().sum::<usize>());
|
|
// Within the budget, every step stays.
|
|
assert_eq!(blend_steps(&keys[..50], &spec), vec![1000; 49]);
|
|
assert_eq!(fit_steps(&[10, 20], &[1, 1], MAX_BLEND_STEPS), vec![0, 0]);
|
|
}
|
|
}
|