//! 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, 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_with(o, lerp_color); 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) { 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 { 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 { let solid_like = |d: &Dash| Dash { pattern: d.pattern.chunks(2).flat_map(|c| [c.iter().sum::(), 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 { 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 = 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 { 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, b: Vec, } 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, Option)) -> 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>)> { 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, 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, Vec)> }, /// 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, Option)) -> 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 = if counts.iter().sum::() == 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::().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 { 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, cum: Vec, anchors: Vec, closed: bool, } impl Spine { pub fn new(path: &PathData) -> Option { let sp = path.subpaths.first()?; let mut pts: Vec = Vec::new(); let mut cum: Vec = 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 { 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> { (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], spec: &BlendSpec) -> Option<(PathData, Option>)> { if keys.len() < 2 { return None; } let Some(stored) = &spec.spine else { let centers: Vec = 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], spec: &BlendSpec) -> Option<(PathData, Vec)> { 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 { 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, centers: Vec, rotate: bool, } impl Rail { /// `None` when nothing moves: no stored spine and the steps keep the page's orientation. fn new(keys: &[Arc], spec: &BlendSpec) -> Option { 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 = 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]) -> bool { !steps.iter().all(|c| paints_opaquely(c)) } /// Evaluate a blend: the keys and generated steps in paint order. pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { let k = keys.len(); if k < 2 { return keys.iter().map(|n| (**n).clone()).collect(); } let rail = Rail::new(keys, spec); 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 len = rail.as_ref().map_or_else(|| center_of(a).distance(center_of(b)), |r| r.len(i)); let n = blend_step_count(a, b, spec.spacing, len); 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 { 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 = (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| 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 = 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:?}"); } }