diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 489b5286..762bb04e 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1,24 +1,31 @@ //! Live blends: interpolating key objects and laying the steps out along the spine. //! -//! Key objects are matched anchor by anchor ([`PathPair`]): subpaths paired in order (a missing -//! one grows out of a point) and resampled to equal anchor counts, closed ones turned to the same -//! winding; the Blend tool's clicks on anchor points choose where each path starts. Paints, -//! strokes and opacity interpolate. A blend prepares each pair of keys once ([`Lerp`]) and only -//! interpolates per step. -//! -//! The spine ([`blend_spine`]) is 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. +//! - **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}; +use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, SubPath, Vec2}; -use crate::appearance::{Appearance, AppearanceItem}; +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 @@ -52,11 +59,54 @@ fn solid_gradient(c: Color, like: &Gradient) -> Gradient { g } -fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option { - if a.gradient.kind != b.gradient.kind || a.gradient.stops.len() != b.gradient.stops.len() { +/// `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); + GradientStop { opacity, ..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; } - let (ga, gb) = (&a.gradient, &b.gradient); + 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 @@ -83,7 +133,8 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option Paint { match (a, b) { (Paint::Solid { color: x, .. }, Paint::Solid { color: y, .. }) => Paint::solid(lerp_color(x, y, t)), @@ -103,6 +154,74 @@ pub fn lerp_paint(a: &Paint, b: &Paint, t: f32) -> Paint { } } +/// 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 { @@ -119,11 +238,7 @@ pub fn lerp_appearance(a: &Appearance, b: &Appearance, t: f64) -> Appearance { 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)) => { - 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); - } + (AppearanceItem::Stroke(o), AppearanceItem::Stroke(x), AppearanceItem::Stroke(y)) => lerp_stroke(o, x, y, t), _ => {} } } @@ -137,13 +252,47 @@ pub fn lerp_appearance(a: &Appearance, b: &Appearance, t: f64) -> Appearance { 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 // ===================================================================================== @@ -194,8 +343,39 @@ fn reverse_closed(sp: &mut SubPath) { 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 the clicked start points. +/// same winding and matching start points. #[derive(Clone, Debug)] pub struct PathPair { a: Vec, @@ -204,7 +384,7 @@ pub struct PathPair { impl PathPair { /// `starts`: the anchor of each path's first subpath its blend starts from (the Blend tool's - /// clicks). + /// 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(); @@ -218,7 +398,8 @@ impl PathPair { (None, Some(y)) => (degenerate(y, ca), y.clone()), (None, None) => continue, }; - if i == 0 { + 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); } @@ -233,6 +414,10 @@ impl PathPair { 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); } @@ -252,6 +437,11 @@ impl PathPair { .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`]). @@ -259,13 +449,16 @@ 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). -fn node_path(n: &Node) -> Option<(PathData, FillRule)> { +/// Path data of a path or compound path node (compound children concatenated), its fill rule and +/// the number of subpaths each compound member holds. +fn node_path(n: &Node) -> Option<(PathData, FillRule, Option>)> { match &n.kind { - NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)), + NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule, None)), NodeKind::Compound { children, rule } => { - let subs = children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect(); - Some((PathData::new(subs), *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, } @@ -278,12 +471,15 @@ fn node_path(n: &Node) -> Option<(PathData, FillRule)> { /// How two key objects interpolate. #[derive(Clone, Debug)] enum Shape { - /// Groups with as many children: children paired in stacking order. + /// 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. - Path { pair: PathPair, rules: (FillRule, FillRule) }, + /// 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, } @@ -296,6 +492,15 @@ pub struct Lerp { 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() } @@ -304,12 +509,29 @@ 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 ca.len() == cb.len() && k1 == k2 => { - Shape::Group { children: ca.iter().zip(cb).map(|(x, y)| Lerp::new(x, y, (None, None))).collect(), clip: *k1 } + (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(_)) => Shape::Placed, _ => match (node_path(a), node_path(b)) { - (Some((pa, ra)), Some((pb, rb))) => Shape::Path { pair: PathPair::new(&pa, &pb, starts), rules: (ra, rb) }, + (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, }, }; @@ -322,7 +544,10 @@ impl Lerp { let base = if t < 0.5 { a } else { b }; let mut n = match &self.shape { Shape::Group { children, clip } => { - Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at(t))).collect(), clip: *clip }) + let mut g = Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at(t))).collect(), clip: *clip }); + g.isolate = base.isolate; + g.knockout = base.knockout; + g } Shape::Mesh => { let mut n = base.clone(); @@ -336,13 +561,34 @@ impl Lerp { } } } + n.mask = None; n } - Shape::Path { pair, rules } => { - let mut n = Node::path(NodeId(0), pair.at(t), Appearance::default()); - if let NodeKind::Path { rule, .. } = &mut n.kind { - *rule = if t < 0.5 { rules.0 } else { rules.1 }; + 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), + (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), + _ => {} + } + n.mask = None; n } Shape::Other => { @@ -362,6 +608,11 @@ impl Lerp { 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.mask = None; n } }; @@ -370,10 +621,75 @@ impl Lerp { n.opacity = lerp_f32(a.opacity, b.opacity, t 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) { + 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 = t 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; + } +} + /// 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 { @@ -665,6 +981,36 @@ impl Rail { // 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.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(); @@ -705,8 +1051,9 @@ mod tests { 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); - // Each anchor of `a` meets the opposite corner of `b`: the middle step shrinks to a point. - assert!(PathPair::new(&a, &b, (None, None)).at(0.5).bounds().unwrap().height() < 1e-6); + // 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:?}"); @@ -745,4 +1092,64 @@ mod tests { 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 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 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:?}"); + } } diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index fe9f7b5b..0c49ef22 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -258,6 +258,7 @@ pub fn default_fidelity() -> f64 { pub use crate::blend::{ Spine, blend_expand, blend_spine, blend_step_count, lerp_appearance, lerp_color, lerp_node, lerp_paint, lerp_path, pin_spine, + steps_knockout_shows, }; // ===================================================================================== @@ -1214,6 +1215,10 @@ pub fn expanded_group(n: &Node, outline: Outliner) -> Node { g.blend = n.blend; g.isolate = n.isolate; g.knockout = n.knockout; + // Knocking out opaque blend steps changes nothing: don't make the outputs work for it. + if matches!(n.kind, NodeKind::Blend { .. }) && g.knockout == crate::Knockout::On && !crate::blend::knockout_shows(&g) { + g.knockout = crate::Knockout::Off; + } g.knockout_shape = n.knockout_shape; g.mask = n.mask.clone(); g diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index d42fe168..c8db5897 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (type → outlines, live shapes → paths, effects baked, envelopes → their distorted content), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh), both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}", + "{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, envelopes → their distorted content), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh), both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}", has_selection, expand ), @@ -86,6 +86,8 @@ struct Expandable { stroke: bool, /// An envelope (Object expands it into its distorted content). envelope: bool, + /// Live blends (Object expands them into groups of their steps). + blends: bool, } impl Expandable { @@ -98,6 +100,7 @@ impl Expandable { e.live |= matches!(c.kind, NodeKind::Path { live: Some(_), .. }); e.effects |= !c.appearance.effects.is_empty(); e.envelope |= matches!(c.kind, NodeKind::Envelope { .. }); + e.blends |= matches!(c.kind, NodeKind::Blend { .. }); e.stroke |= shape && !c.appearance.stroke_paint().is_none(); e.fill |= match &c.kind { NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))), @@ -109,7 +112,7 @@ impl Expandable { } fn object(&self) -> bool { - self.text || self.live || self.effects || self.envelope + self.text || self.live || self.effects || self.envelope || self.blends } } @@ -132,6 +135,11 @@ fn expand(s: &mut Session, p: &Value) -> Result { let from = s.doc()?.history.undo.len(); let rev0 = s.doc()?.revision; let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); + // Blends first: what their steps hold (type, effects, strokes, gradients) expands below. + if object && e.blends { + let roots = selected_roots(s)?; + s.edit("Expand", |d, _| super::live::expand_blends(d, &roots).map(|_| ()))?; + } if object && e.effects { let _ = run_raw(s, "effect.expandAppearance", &json!({})); } diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index d10c602b..797534e1 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Release", ["Object", "Blend"], Some("Cmd+Alt+Shift+B"), - "{} release the selected blends: the key objects come back and the spine stays as a path with no fill or stroke, below them → {ids (the keys), spines: [ids]}", + "{} release the selected blends: the key objects come back and the spine stays as a path with no fill or stroke, below them; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [ids]}", has_blend, blend_release ), @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Expand", ["Object", "Blend"], None, - "{} replace the selected blends by groups of their steps → {ids}", + "{} replace the selected blends by groups of their keys and steps, each keeping the blend's id, name, transparency (knockout and isolation included), opacity mask and appearance → {ids}", has_blend, blend_expand ), @@ -513,8 +513,13 @@ fn blend_make(s: &mut Session, p: &Value) -> Result { let id = s.edit("Make Blend", |d, sel| { wrap(d, sel, &roots, |id, nodes| { let (children, starts) = merge_keys(nodes, &starts); - // A blend taking more keys keeps its name, transparency and spine. - let mut n = base.unwrap_or_else(|| Node::new(id, NodeKind::Group { children: vec![], clip: false })); + // A blend taking more keys keeps its name, transparency and spine; a new one is a + // knockout group, so translucent steps don't show through each other. + let mut n = base.unwrap_or_else(|| { + let mut n = Node::new(id, NodeKind::Group { children: vec![], clip: false }); + n.knockout = vectorcraft_doc::Knockout::On; + n + }); let mut spec = BlendSpec { spacing, orientation, starts, ..Default::default() }; if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind { extend_spine(&mut spec, old, old_spec, &children); @@ -529,8 +534,8 @@ fn blend_make(s: &mut Session, p: &Value) -> Result { fn blend_release(s: &mut Session, _: &Value) -> Result { let blends = selected_of(s, is_blend); - let (ids, spines) = s.edit("Release Blend", |d, sel| { - let (mut keys, mut spines) = (vec![], vec![]); + let (ids, spines, groups) = s.edit("Release Blend", |d, sel| { + let (mut keys, mut spines, mut groups) = (vec![], vec![], vec![]); for b in &blends { let Some(n) = d.node(*b).cloned() else { continue }; let NodeKind::Blend { children, spec } = &n.kind else { continue }; @@ -543,13 +548,32 @@ fn blend_release(s: &mut Session, _: &Value) -> Result { } keys.extend(children.iter().map(|c| c.id)); nodes.extend(children.iter().map(|c| (**c).clone())); + // What the blend itself carries stays on a group around what it gave back. + if n.name.is_some() + || n.opacity < 1.0 + || n.blend != vectorcraft_color::BlendMode::Normal + || n.isolate + || n.mask.is_some() + || !n.appearance.items.is_empty() + || !n.appearance.effects.is_empty() + { + let mut g = n.clone(); + g.kind = NodeKind::Group { children: nodes.into_iter().map(Arc::new).collect(), clip: false }; + groups.push(g.id); + nodes = vec![g]; + } replace_with(d, *b, nodes)?; } - sel.set(spines.iter().chain(&keys).copied()); - Ok((keys, spines)) + if groups.is_empty() { + sel.set(spines.iter().chain(&keys).copied()); + } else { + sel.set(groups.iter().copied()); + } + Ok((keys, spines, groups)) })?; let mut r = ids_json(&ids); r["spines"] = json!(spines.iter().map(|i| i.0).collect::>()); + r["groups"] = json!(groups.iter().map(|i| i.0).collect::>()); Ok(r) } @@ -732,23 +756,46 @@ fn blend_info(s: &mut Session, _: &Value) -> Result { fn blend_expand(s: &mut Session, _: &Value) -> Result { let blends = selected_of(s, is_blend); let ids = s.edit("Expand Blend", |d, sel| { - let mut out = vec![]; for b in &blends { - let Some(n) = d.node(*b).cloned() else { continue }; - let children = vectorcraft_render::expand_live(&n).into_iter().map(Arc::new).collect(); - let mut g = Node::new(n.id, NodeKind::Group { children, clip: false }); - g.opacity = n.opacity; - g.blend = n.blend; - g.visible = n.visible; - fix_ids(d, &mut g); - out.extend(replace_with(d, *b, vec![g])?); + expand_blend(d, *b)?; } - sel.set(out.iter().copied()); - Ok(out) + sel.set(blends.iter().copied()); + Ok(blends.clone()) })?; Ok(ids_json(&ids)) } +/// Replace blend `id` by a group of its keys and steps in place, keeping everything about the +/// blend itself: id, name, visibility, lock, transparency (knockout and isolation included), +/// opacity mask and appearance. +fn expand_blend(d: &mut Document, id: NodeId) -> Result<()> { + let Some(n) = d.node(id).filter(|n| is_blend(n)).cloned() else { return Ok(()) }; + let mut g = n.clone(); + g.kind = NodeKind::Group { children: vectorcraft_render::expand_live(&n).into_iter().map(Arc::new).collect(), clip: false }; + fix_ids(d, &mut g); + *d.node_mut(id).ok_or(EngineError::NoNode(id))? = g; + Ok(()) +} + +/// Object › Expand: every blend in the subtrees of `roots` (outer ones first) becomes a group of +/// its steps ([`expand_blend`]). Returns how many. +pub(crate) fn expand_blends(d: &mut Document, roots: &[NodeId]) -> Result { + let mut blends = vec![]; + for r in roots { + if let Some(n) = d.node(*r) { + n.walk(&mut |c| { + if is_blend(c) { + blends.push(c.id); + } + }); + } + } + for b in &blends { + expand_blend(d, *b)?; + } + Ok(blends.len()) +} + /// Move the keys onto their spine positions (so what's stored matches what's drawn): onto their /// anchors, or spread evenly by arc length along a spine from Replace Spine. fn keys_to_spine(children: &mut [Arc], spec: &BlendSpec) { diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 88ccc4a1..25df95aa 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -273,7 +273,9 @@ pub(crate) fn put_below(d: &mut Document, m: &mut Node, image: Node) { let mut inner = m.clone(); inner.id = d.alloc_id(); (inner.name, inner.opacity, inner.blend, inner.isolate, inner.mask) = (None, 1.0, Default::default(), false, None); - inner.knockout = Default::default(); + // Knockout is between the object's own parts (a blend's steps): it stays with them, + // so the image doesn't knock out the art over it. + m.knockout = Default::default(); inner.knockout_shape = false; m.appearance = Default::default(); m.kind = NodeKind::Group { children: vec![Arc::new(image), Arc::new(inner)], clip: false }; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fefb612a..25abe170 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1156,6 +1156,8 @@ mod tests_attributes; #[cfg(test)] mod tests_bboxrotate; #[cfg(test)] +mod tests_blendfidelity; +#[cfg(test)] mod tests_blendopts; #[cfg(test)] mod tests_blendspine; diff --git a/crates/engine/src/tests_blendfidelity.rs b/crates/engine/src/tests_blendfidelity.rs new file mode 100644 index 00000000..b71f6eb6 --- /dev/null +++ b/crates/engine/src/tests_blendfidelity.rs @@ -0,0 +1,206 @@ +//! Blend fidelity (M8.20): start points that don't twist, strokes, groups, compound paths, text +//! and symbols interpolating, knockout on the canvas, and Expand / Release keeping what the +//! blend carries. + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::{Knockout, Node, NodeKind}; +use vectorcraft_geom::{Point, Rect}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn steps(s: &Session, g: NodeId) -> Vec { + vectorcraft_doc::live::expand_live(&node(s, g)) +} + +fn make(s: &mut Session, ids: &[NodeId], steps: u32) -> NodeId { + id_of(&s.execute("object.blend.make", &json!({"ids": ids.iter().map(|i| i.0).collect::>(), "steps": steps})).unwrap()) +} + +#[test] +fn closed_shapes_start_where_they_dont_twist() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 40.0, 40.0); + // The same square drawn from its bottom right corner. + let b = id_of(&s.execute("path.create", &json!({"d": "M240 40 L200 40 L200 0 L240 0 Z"})).unwrap()); + let g = make(&mut s, &[a, b], 1); + let mid = steps(&s, g)[1].geometric_bounds().unwrap(); + assert!((mid.width() - 40.0).abs() < 1e-6 && (mid.height() - 40.0).abs() < 1e-6, "no twist: {mid:?}"); +} + +#[test] +fn dashes_caps_and_width_profiles_interpolate() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 40.0, 40.0); + s.execute("stroke.set", &json!({"ids": [a.0], "weight": 2, "dash": [10, 10], "cap": "butt"})).unwrap(); + let b = rect(&mut s, 200.0, 0.0, 40.0, 40.0); + s.execute("stroke.set", &json!({"ids": [b.0], "weight": 6, "cap": "round", "profile": "lens"})).unwrap(); + let g = make(&mut s, &[a, b], 3); + let st = steps(&s, g); + let stroke = |n: &Node| n.appearance.stroke().cloned().unwrap(); + let (first, last) = (stroke(&st[1]), stroke(&st[3])); + assert_eq!(first.dash.as_ref().unwrap().pattern, vec![12.5, 7.5], "the gaps close towards the solid key"); + assert!(last.dash.as_ref().unwrap().pattern[1] < first.dash.as_ref().unwrap().pattern[1]); + assert_eq!((first.cap, last.cap), (vectorcraft_doc::LineCap::Butt, vectorcraft_doc::LineCap::Round), "caps switch halfway"); + assert_eq!(stroke(&st[2]).profile.unwrap().at(0.0), (0.5, 0.5), "the lens profile comes in gradually"); +} + +#[test] +fn groups_with_different_member_counts_interpolate_member_by_member() { + let mut s = session(); + let a1 = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let a2 = rect(&mut s, 0.0, 40.0, 20.0, 20.0); + s.execute("select.set", &json!({"ids": [a1.0, a2.0]})).unwrap(); + let ga = id_of(&s.execute("object.group", &json!({})).unwrap()); + let b1 = rect(&mut s, 200.0, 0.0, 20.0, 20.0); + s.execute("select.set", &json!({"ids": [b1.0]})).unwrap(); + let gb = id_of(&s.execute("object.group", &json!({})).unwrap()); + let g = make(&mut s, &[ga, gb], 1); + let mid = &steps(&s, g)[1]; + let NodeKind::Group { children, .. } = &mid.kind else { panic!("a group step") }; + assert_eq!(children.len(), 2, "the member only one group has grows out of the other's centre"); + let c = children[0].geometric_bounds().unwrap(); + assert!((c.x0 - 100.0).abs() < 1e-6 && (c.width() - 20.0).abs() < 1e-6, "{c:?}"); + assert!(children[1].geometric_bounds().unwrap().width() < 15.0); +} + +#[test] +fn compound_steps_stay_compound_paths() { + let mut s = session(); + let mk = |s: &mut Session, x: f64| { + let o = rect(s, x, 0.0, 40.0, 40.0); + let i = rect(s, x + 10.0, 10.0, 20.0, 20.0); + s.execute("select.set", &json!({"ids": [o.0, i.0]})).unwrap(); + id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()) + }; + let (a, b) = (mk(&mut s, 0.0), mk(&mut s, 200.0)); + let g = make(&mut s, &[a, b], 1); + let mid = &steps(&s, g)[1]; + assert!(matches!(&mid.kind, NodeKind::Compound { children, .. } if children.len() == 2), "{}", mid.kind_label()); +} + +#[test] +fn text_moves_and_recolours_while_its_words_switch_halfway() { + let mut s = session(); + let a = id_of(&s.execute("text.create", &json!({"x": 0, "y": 100, "text": "Hi", "size": 20, "color": "#000000"})).unwrap()); + let b = id_of(&s.execute("text.create", &json!({"x": 200, "y": 100, "text": "Yo", "size": 40, "color": "#ffffff"})).unwrap()); + let g = make(&mut s, &[a, b], 3); + let st = steps(&s, g); + let NodeKind::Text(t) = &st[2].kind else { panic!("a text step") }; + assert!((t.xf.as_coeffs()[4] - 100.0).abs() < 1e-6, "halfway along"); + let run = &t.runs[0]; + assert!((run.style.size - 30.0).abs() < 1e-6); + assert!((run.style.fill.color().unwrap().to_rgb()[0] - 0.5).abs() < 1e-3, "colour halfway"); + let NodeKind::Text(t1) = &st[1].kind else { panic!() }; + assert_eq!(t1.plain_text(), "Hi", "the words switch only halfway"); +} + +#[test] +fn symbol_instances_interpolate_their_transforms() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let a = id_of(&s.execute("symbol.new", &json!({"name": "Dot"})).unwrap()); + let b = id_of(&s.execute("symbol.place", &json!({"name": "Dot", "x": 210, "y": 10})).unwrap()); + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + s.execute("object.rotate", &json!({"angle": 90})).unwrap(); + let g = make(&mut s, &[a, b], 1); + let mid = &steps(&s, g)[1]; + let NodeKind::SymbolInstance { xf, .. } = &mid.kind else { panic!("{}", mid.kind_label()) }; + let c = xf.as_coeffs(); + assert!((c[1].atan2(c[0]).to_degrees().abs() - 45.0).abs() < 1e-6, "turned halfway: {c:?}"); + assert!((mid.geometric_bounds().unwrap().center().x - 110.0).abs() < 1e-6); +} + +#[test] +fn knockout_blends_draw_as_they_export() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 40.0, 40.0); + let b = rect(&mut s, 20.0, 0.0, 40.0, 40.0); + for id in [a, b] { + s.execute("paint.setFill", &json!({"ids": [id.0], "color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [id.0], "color": null})).ok(); + s.execute("object.setProps", &json!({"ids": [id.0], "opacity": 50})).unwrap(); + } + let g = make(&mut s, &[a, b], 3); + assert_eq!(node(&s, g).knockout, Knockout::On, "blends knock out by default"); + let px = |s: &Session| { + vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 60.0, 40.0), 1.0, true).pixel(30, 20) + }; + let on = px(&s); + assert!((i32::from(on[1]) - 128).abs() <= 3, "one layer of 50 % red over white: {on:?}"); + s.execute("object.setProps", &json!({"ids": [g.0], "knockout": "off"})).unwrap(); + let off = px(&s); + assert!(off[1] < 100, "the steps build up without knockout: {off:?}"); + // Opaque steps don't need a knockout group in the outputs. + let mut t = session(); + let (c, d) = (rect(&mut t, 0.0, 0.0, 10.0, 10.0), rect(&mut t, 100.0, 0.0, 10.0, 10.0)); + let h = make(&mut t, &[c, d], 3); + assert_eq!(vectorcraft_doc::live::expanded_group(&node(&t, h), None).knockout, Knockout::Off); +} + +#[test] +fn expand_and_release_keep_what_the_blend_carries() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0, 10.0)); + let g = make(&mut s, &[a, b], 2); + s.execute("object.setProps", &json!({"ids": [g.0], "name": "Fade", "opacity": 40, "isolate": true})).unwrap(); + s.edit("mask", |d, _| { + let art = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 10.0)), Default::default()); + let mask = vectorcraft_doc::OpacityMask::new(art, false); + d.node_mut(g).unwrap().mask = Some(Box::new(mask)); + Ok(()) + }) + .unwrap(); + let before = node(&s, g); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + s.execute("object.blend.expand", &json!({})).unwrap(); + let e = node(&s, g); + assert!(matches!(e.kind, NodeKind::Group { .. })); + assert_eq!((e.name.as_deref(), e.opacity, e.isolate, e.knockout), (Some("Fade"), before.opacity, true, Knockout::On)); + assert_eq!(e.mask, before.mask); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + let r = s.execute("object.blend.release", &json!({})).unwrap(); + let grp = NodeId(r["groups"][0].as_u64().unwrap()); + let n = node(&s, grp); + assert_eq!((n.name.as_deref(), n.opacity, n.isolate), (Some("Fade"), before.opacity, true)); + assert_eq!(n.mask, before.mask); + assert_eq!(n.children().unwrap().len(), 3, "spine and keys inside"); +} + +#[test] +fn object_expand_expands_blends_inside_the_selection() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0, 10.0)); + let g = make(&mut s, &[a, b], 2); + let c = rect(&mut s, 0.0, 100.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [g.0, c.0]})).unwrap(); + let outer = id_of(&s.execute("object.group", &json!({})).unwrap()); + assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(true)); + s.execute("object.expand", &json!({"object": true, "fill": false, "stroke": false})).unwrap(); + let inner = node(&s, g); + assert!(matches!(inner.kind, NodeKind::Group { .. }), "the blend inside the group expanded"); + assert_eq!(inner.children().unwrap().len(), 4); + assert!(s.doc().unwrap().doc.node(outer).is_some()); + let _ = Paint::None; + let _ = Point::ZERO; +} diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index e1918f5c..a285e476 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -92,7 +92,13 @@ impl Renderer { let stamp = self.stamp; self.live.tick(stamp); let opacity = self.opacity_of(a); - if !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_color::BlendMode::Normal || a.isolate) { + // A knockout blend's steps knock each other out, as in the exports (opaque ones can't show it). + let knockout = !f.opts.outline + && matches!(a.kind, NodeKind::Blend { .. }) + && a.knockout.resolve(self.knockout) + && vectorcraft_doc::live::steps_knockout_shows(&self.live_expanded(a, true)); + let enclosing = std::mem::replace(&mut self.knockout, knockout); + if !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_color::BlendMode::Normal || a.isolate || knockout) { let blends = self.blends_through(a); let bounds = if blends { self.bounds_of(a) } else { None }; let comp = crate::group::Composite { blend: a.blend, opacity, isolated: a.isolate, blends, bounds, ..Default::default() }; @@ -100,6 +106,7 @@ impl Renderer { } else { self.draw_live_body(ctx, f, a, true); } + self.knockout = enclosing; self.stats.drawn += 1; } @@ -109,8 +116,13 @@ impl Renderer { NodeKind::Mesh(m) => self.draw_mesh(ctx, f, a, m, cache), NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => { let items = self.live_expanded(a, cache); - for c in items.iter() { - self.draw_arc(ctx, f, c); + // The flag holds while this blend is drawn (see `draw_live`). + if self.knockout && matches!(a.kind, NodeKind::Blend { .. }) { + self.draw_knockout(ctx, f, &items); + } else { + for c in items.iter() { + self.draw_arc(ctx, f, c); + } } } _ => {} diff --git a/docs/mcp.md b/docs/mcp.md index 4c6f725a..b067805e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1927,3 +1927,14 @@ sits on; the first edit turns the spine into a path. Moving a key moves its end {"name":"run_command","arguments":{"command":"object.blend.spine.moveAnchor","params":{"id":40,"anchor":1,"x":150,"y":20}}} ``` + +How blends interpolate: closed shapes start where they twist least unless `starts` picks the anchors; strokes +interpolate weight, dashes (a solid stroke counts as the dashed one with its gaps closed), miter limit, arrowhead scale +and width profiles, while caps, joins, alignment, arrowheads and brushes switch halfway; gradients of one kind with +different stop counts resample to a common set of stops. Groups pair their members in stacking order (members only +one group has grow out of the other's centre), compound paths stay compound paths, and type, symbol instances and +images interpolate their transforms (type also its colours, stroke weight and size run by run; the words and the +symbol switch halfway). New blends are knockout groups (`object.setProps {knockout}` changes it), drawn on the +canvas as they export. `object.blend.expand` and `object.blend.release` keep the blend's name, transparency, +opacity mask and appearance (Release on a group around the keys and spine when the blend has any), and +`object.expand` with `object: true` expands the blends in the selection.