From 98a3e15ec30dc57091f889f03670bbc6b62d0cea Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 11:06:33 +0700 Subject: [PATCH] M8.20: blends interpolate strokes, gradients, groups, type and symbols faithfully Closed shapes start where they twist least (unless the Blend tool picked the anchors); dashes, miter limit, arrowhead scale and width profiles interpolate (caps, joins and brushes switch halfway); gradients with different stop counts resample to common stops; groups with different member counts grow the extra members out of a point; compound steps stay compound paths; type, symbol instances and images interpolate their transforms, type its colours and sizes run by run. New blends are knockout groups and the canvas draws them knocked out as the exports do. Expand and Release keep the blend's name, transparency, opacity mask and appearance, and Object > Expand expands blends. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/blend.rs | 489 +++++++++++++++++++++-- crates/doc/src/live.rs | 5 + crates/engine/src/cmd/expand.rs | 12 +- crates/engine/src/cmd/live.rs | 85 +++- crates/engine/src/cmd/rasterfx.rs | 4 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_blendfidelity.rs | 206 ++++++++++ crates/render/src/live.rs | 18 +- docs/mcp.md | 11 + 9 files changed, 766 insertions(+), 66 deletions(-) create mode 100644 crates/engine/src/tests_blendfidelity.rs 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.