Merge pull request #190 from storytold/m8-blend-options

M8.18: Blend Options dialog and the Blend tool's anchor targeting
This commit is contained in:
BFlat
2026-10-06 11:01:33 +07:00
committed by GitHub
20 changed files with 1291 additions and 408 deletions
+1 -1
View File
@@ -144,7 +144,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects, M8.9 perspective grid presets and manager, M8.16 test fix (EPS dates), M8.12 Perspective Selection scales, copies, moves perpendicular, Transform Again) | 8–12 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects, M8.9 perspective grid presets and manager, M8.16 test fix (EPS dates), M8.12 Perspective Selection scales, copies, moves perpendicular, Transform Again, M8.18 Blend Options dialog, anchor-targeted Blend tool, add keys) | 8–12 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |
+528
View File
@@ -0,0 +1,528 @@
//! 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.
use std::sync::Arc;
use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint};
use vectorcraft_geom::{Affine, Anchor, FillRule, PathData, Point, SubPath};
use crate::appearance::{Appearance, AppearanceItem};
use crate::live::{BlendOrientation, BlendSpacing, BlendSpec};
use crate::node::{Node, NodeId, NodeKind};
// =====================================================================================
// Colour / paint interpolation
// =====================================================================================
/// Interpolate two colours in their shared model (display RGB when the models differ).
pub fn lerp_color(a: &Color, b: &Color, t: f32) -> Color {
let l = |x: f32, y: f32| x + (y - x) * t;
match (*a, *b) {
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)),
(Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)),
(Color::Lab { l: l1, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => Color::lab(l(l1, l2), l(a, a2), l(b, b2)),
_ => a.lerp(b, t),
}
}
fn lerp_f32(x: f32, y: f32, t: f32) -> f32 {
x + (y - x) * t
}
fn lerp_f64(x: f64, y: f64, t: f64) -> f64 {
x + (y - x) * t
}
fn solid_gradient(c: Color, like: &Gradient) -> Gradient {
let mut g = like.clone();
for s in &mut g.stops {
s.color = c;
s.opacity = 1.0;
}
g
}
fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option<GradientPaint> {
if a.gradient.kind != b.gradient.kind || a.gradient.stops.len() != b.gradient.stops.len() {
return None;
}
let (ga, gb) = (&a.gradient, &b.gradient);
let mut out = if t < 0.5 { a.clone() } else { b.clone() };
out.gradient.stops = ga
.stops
.iter()
.zip(&gb.stops)
.map(|(sa, sb)| GradientStop {
opacity: lerp_f32(sa.opacity, sb.opacity, t),
midpoint: lerp_f32(sa.midpoint, sb.midpoint, t),
..GradientStop::new(lerp_f32(sa.offset, sb.offset, t), lerp_color(&sa.color, &sb.color, t))
})
.collect();
out.angle = a.angle + (b.angle - a.angle) * t as f64;
out.swatch = None;
if let (Some(ga), Some(gb)) = (a.geom, b.geom) {
let mut g = ga;
g.start = ga.start.lerp(gb.start, t as f64);
g.end = ga.end.lerp(gb.end, t as f64);
g.aspect = ga.aspect + (gb.aspect - ga.aspect) * t as f64;
g.focal = (ga.focal.is_some() || gb.focal.is_some()).then(|| ga.focal_point().lerp(gb.focal_point(), t as f64));
out.geom = Some(g);
} else {
out.geom = None;
}
Some(out)
}
/// Interpolate paints: solid↔solid, compatible gradients, solid↔gradient; otherwise switch halfway.
pub fn lerp_paint(a: &Paint, b: &Paint, t: f32) -> Paint {
match (a, b) {
(Paint::Solid { color: x, .. }, Paint::Solid { color: y, .. }) => Paint::solid(lerp_color(x, y, t)),
(Paint::Gradient(ga), Paint::Gradient(gb)) => match lerp_gradient(ga, gb, t) {
Some(g) => Paint::Gradient(Box::new(g)),
None => (if t < 0.5 { a } else { b }).clone(),
},
(Paint::Solid { color, .. }, Paint::Gradient(g)) => {
let ga = GradientPaint { gradient: solid_gradient(*color, &g.gradient), ..(**g).clone() };
Paint::Gradient(Box::new(lerp_gradient(&ga, g, t).unwrap_or_else(|| (**g).clone())))
}
(Paint::Gradient(g), Paint::Solid { color, .. }) => {
let gb = GradientPaint { gradient: solid_gradient(*color, &g.gradient), ..(**g).clone() };
Paint::Gradient(Box::new(lerp_gradient(g, &gb, t).unwrap_or_else(|| (**g).clone())))
}
_ => (if t < 0.5 { a } else { b }).clone(),
}
}
/// Interpolate appearance stacks: item by item when their structure matches, else the top fill
/// and stroke only (on the structure of the nearer key).
pub fn lerp_appearance(a: &Appearance, b: &Appearance, t: f64) -> Appearance {
let tf = t as f32;
let mut out = if t < 0.5 { a.clone() } else { b.clone() };
let same = a.items.len() == b.items.len()
&& a.items.iter().zip(&b.items).all(|(x, y)| {
matches!((x, y), (AppearanceItem::Fill(_), AppearanceItem::Fill(_)) | (AppearanceItem::Stroke(_), AppearanceItem::Stroke(_)))
});
if same {
for ((it, x), y) in out.items.iter_mut().zip(&a.items).zip(&b.items) {
match (it, x, y) {
(AppearanceItem::Fill(o), AppearanceItem::Fill(x), AppearanceItem::Fill(y)) => {
o.paint = lerp_paint(&x.paint, &y.paint, tf);
o.opacity = lerp_f32(x.opacity, y.opacity, tf);
}
(AppearanceItem::Stroke(o), AppearanceItem::Stroke(x), AppearanceItem::Stroke(y)) => {
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);
}
_ => {}
}
}
return out;
}
let (fa, fb) = (a.fill_paint(), b.fill_paint());
if !(fa.is_none() && fb.is_none()) {
out.set_fill(lerp_paint(&fa, &fb, tf));
}
let (sa, sb) = (a.stroke_paint(), b.stroke_paint());
if !(sa.is_none() && sb.is_none()) {
out.set_stroke(lerp_paint(&sa, &sb, tf));
let w = a.stroke_width() + (b.stroke_width() - a.stroke_width()) * t;
if let Some(s) = out.stroke_mut() {
s.width = w;
}
}
out
}
// =====================================================================================
// Path interpolation
// =====================================================================================
/// Grow a subpath to `n` anchors by splitting its longest segments.
fn grow(sp: &mut SubPath, n: usize) {
let mut guard = 0;
while sp.anchors.len() < n && guard < 20_000 {
guard += 1;
if sp.segment_count() == 0 {
match sp.anchors.last().copied() {
Some(a) => sp.anchors.push(Anchor::corner(a.p)),
None => return,
}
continue;
}
let seg = (0..sp.segment_count())
.max_by(|i, j| {
let (ci, cj) = (sp.segment(*i), sp.segment(*j));
(ci.p3 - ci.p0).hypot().total_cmp(&(cj.p3 - cj.p0).hypot())
})
.unwrap_or(0);
sp.insert_anchor(seg, 0.5);
}
}
fn lerp_anchor(x: &Anchor, y: &Anchor, t: f64) -> Anchor {
Anchor { p: x.p.lerp(y.p, t), h_in: x.h_in.lerp(y.h_in, t), h_out: x.h_out.lerp(y.h_out, t), kind: if t < 0.5 { x.kind } else { y.kind } }
}
/// Start `sp` at anchor `i`: a closed subpath turns round to it, an open one clicked at its last
/// anchor runs the other way (a click on any other anchor of an open path is ignored).
fn start_at(sp: &mut SubPath, i: usize) {
let n = sp.anchors.len();
if i >= n {
return;
}
if sp.closed {
sp.anchors.rotate_left(i);
} else if i + 1 == n && n > 1 {
sp.reverse();
}
}
/// Reverse a closed subpath keeping its start anchor first.
fn reverse_closed(sp: &mut SubPath) {
sp.reverse();
sp.anchors.rotate_right(1);
}
/// Two paths prepared for interpolation: subpaths paired in order, with equal anchor counts, the
/// same winding and the clicked start points.
#[derive(Clone, Debug)]
pub struct PathPair {
a: Vec<SubPath>,
b: Vec<SubPath>,
}
impl PathPair {
/// `starts`: the anchor of each path's first subpath its blend starts from (the Blend tool's
/// clicks).
pub fn new(a: &PathData, b: &PathData, starts: (Option<usize>, Option<usize>)) -> Self {
let n = a.subpaths.len().max(b.subpaths.len());
let ca = a.bounds().map(|r| r.center()).unwrap_or_default();
let cb = b.bounds().map(|r| r.center()).unwrap_or_default();
let degenerate = |like: &SubPath, c: Point| SubPath::new(vec![Anchor::corner(c); like.anchors.len().max(1)], like.closed);
let (mut va, mut vb) = (Vec::with_capacity(n), Vec::with_capacity(n));
for i in 0..n {
let (mut sa, mut sb) = match (a.subpaths.get(i), b.subpaths.get(i)) {
(Some(x), Some(y)) => (x.clone(), y.clone()),
(Some(x), None) => (x.clone(), degenerate(x, cb)),
(None, Some(y)) => (degenerate(y, ca), y.clone()),
(None, None) => continue,
};
if i == 0 {
if let Some(s) = starts.0 {
start_at(&mut sa, s);
}
if let Some(s) = starts.1 {
start_at(&mut sb, s);
}
}
let closed = sa.closed && sb.closed && sa.anchors.len() > 2 && sb.anchors.len() > 2;
if closed && (sa.area() * sb.area()) < 0.0 {
reverse_closed(&mut sb);
}
let m = sa.anchors.len().max(sb.anchors.len());
grow(&mut sa, m);
grow(&mut sb, m);
va.push(sa);
vb.push(sb);
}
Self { a: va, b: vb }
}
/// The path at `t` (0 = `a`, 1 = `b`).
pub fn at(&self, t: f64) -> PathData {
let subs = self
.a
.iter()
.zip(&self.b)
.map(|(sa, sb)| {
let anchors = sa.anchors.iter().zip(&sb.anchors).map(|(x, y)| lerp_anchor(x, y, t)).collect();
SubPath::new(anchors, if t < 0.5 { sa.closed } else { sb.closed })
})
.collect();
PathData::new(subs)
}
}
/// Interpolate two paths anchor-by-anchor (see [`PathPair`]).
pub fn lerp_path(a: &PathData, b: &PathData, t: f64) -> PathData {
PathPair::new(a, b, (None, None)).at(t)
}
/// Path data of a path or compound path node (compound children concatenated).
fn node_path(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)),
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))
}
_ => None,
}
}
// =====================================================================================
// Object interpolation
// =====================================================================================
/// How two key objects interpolate.
#[derive(Clone, Debug)]
enum Shape {
/// Groups with as many children: children paired in stacking order.
Group { children: Vec<Lerp>, clip: bool },
/// Gradient meshes with the same grid.
Mesh,
/// Paths and compound paths.
Path { pair: PathPair, rules: (FillRule, FillRule) },
/// Anything else: a copy of the nearer key moved and scaled into the interpolated box.
Other,
}
/// Two key objects prepared for interpolation, so each step of a blend only interpolates.
#[derive(Clone, Debug)]
pub struct Lerp {
a: Node,
b: Node,
shape: Shape,
}
fn center_of(n: &Node) -> Point {
n.geometric_bounds().map(|b| b.center()).unwrap_or_default()
}
impl Lerp {
/// Prepare `a` → `b`; `starts` as in [`PathPair::new`].
pub fn new(a: &Node, b: &Node, starts: (Option<usize>, Option<usize>)) -> Self {
let shape = match (&a.kind, &b.kind) {
(NodeKind::Group { children: ca, clip: k1 }, NodeKind::Group { children: cb, clip: k2 }) if 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::Mesh(ma), NodeKind::Mesh(mb)) if ma.rows == mb.rows && ma.cols == mb.cols && ma.points.len() == mb.points.len() => Shape::Mesh,
_ => match (node_path(a), node_path(b)) {
(Some((pa, ra)), Some((pb, rb))) => Shape::Path { pair: PathPair::new(&pa, &pb, starts), rules: (ra, rb) },
_ => Shape::Other,
},
};
Self { a: a.clone(), b: b.clone(), shape }
}
/// The object at `t` (0 = `a`, 1 = `b`), with id 0.
pub fn at(&self, t: f64) -> Node {
let (a, b) = (&self.a, &self.b);
let base = if t < 0.5 { a } else { b };
let mut n = match &self.shape {
Shape::Group { children, clip } => {
Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at(t))).collect(), clip: *clip })
}
Shape::Mesh => {
let mut n = base.clone();
if let (NodeKind::Mesh(m), NodeKind::Mesh(ma), NodeKind::Mesh(mb)) = (&mut n.kind, &a.kind, &b.kind) {
for ((p, x), y) in m.points.iter_mut().zip(&ma.points).zip(&mb.points) {
p.p = x.p.lerp(y.p, t);
p.color = lerp_color(&x.color, &y.color, t as f32);
p.opacity = lerp_f32(x.opacity, y.opacity, t as f32);
for h in 0..4 {
p.handles[h] = x.handles[h].lerp(y.handles[h], t);
}
}
}
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 };
}
n
}
Shape::Other => {
// Different structure: move/scale a copy of the nearer key into the interpolated box.
let mut n = base.clone();
if let (Some(ba), Some(bb), Some(bn)) = (a.geometric_bounds(), b.geometric_bounds(), base.geometric_bounds()) {
let w = ba.width() + (bb.width() - ba.width()) * t;
let h = ba.height() + (bb.height() - ba.height()) * t;
let c = ba.center().lerp(bb.center(), t);
let sx = if bn.width() > 1e-9 { w / bn.width() } else { 1.0 };
let sy = if bn.height() > 1e-9 { h / bn.height() } else { 1.0 };
n.transform(
Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-bn.center().to_vec2()),
false,
);
}
if let NodeKind::Path { live, .. } = &mut n.kind {
*live = None;
}
n
}
};
n.id = NodeId(0);
n.appearance = lerp_appearance(&a.appearance, &b.appearance, t);
n.opacity = lerp_f32(a.opacity, b.opacity, t as f32);
n.blend = base.blend;
n.visible = true;
n
}
}
/// Interpolate two objects at `t` (0 = `a`, 1 = `b`). A blend prepares each pair once with
/// [`Lerp`] instead.
pub fn lerp_node(a: &Node, b: &Node, t: f64) -> Node {
Lerp::new(a, b, (None, None)).at(t)
}
// =====================================================================================
// Step counts
// =====================================================================================
/// Largest channel difference (0..1) between the solid colours of two objects' top fill and stroke.
fn color_distance(a: &Node, b: &Node) -> f32 {
let mut d = 0.0f32;
let mut cmp = |x: &Paint, y: &Paint| {
let cols = |p: &Paint| -> Vec<Color> {
match p {
Paint::Solid { color, .. } => vec![*color],
Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color).collect(),
_ => vec![],
}
};
let (cx, cy) = (cols(x), cols(y));
for (i, c) in cx.iter().enumerate() {
if let Some(o) = cy.get(i).or(cy.first()) {
let (p, q) = (c.to_rgb(), o.to_rgb());
for k in 0..3 {
d = d.max((p[k] - q[k]).abs());
}
}
}
};
cmp(&a.appearance.fill_paint(), &b.appearance.fill_paint());
cmp(&a.appearance.stroke_paint(), &b.appearance.stroke_paint());
d
}
/// Number of intermediate steps between two keys `len` apart (along the spine).
pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) -> usize {
match spacing {
BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize,
BlendSpacing::Distance(d) => {
let d = d.max(0.01);
((len / d).round() as i64 - 1).clamp(0, 1000) as usize
}
BlendSpacing::SmoothColor => {
let cd = color_distance(a, b);
if cd > 1.0 / 255.0 {
((cd * 255.0 / 2.0).ceil() as usize).clamp(1, 256)
} else {
// Same colours: base the count on the distance between the objects.
let (ba, bb) = (a.geometric_bounds(), b.geometric_bounds());
let dist = match (ba, bb) {
(Some(x), Some(y)) => (x.x0 - y.x0).abs().max((x.x1 - y.x1).abs()).max((x.y0 - y.y0).abs()).max((x.y1 - y.y1).abs()),
_ => len,
};
((dist / 2.0).ceil() as usize).clamp(1, 256)
}
}
}
}
// =====================================================================================
// Spine
// =====================================================================================
/// A spine flattened to a polyline with cumulative lengths.
pub struct Spine {
pts: Vec<Point>,
cum: Vec<f64>,
}
impl Spine {
pub fn new(path: &PathData) -> Option<Self> {
let sp = path.subpaths.first()?;
let pts = crate::live::flatten_subpath(sp, 0.1);
if pts.len() < 2 {
return None;
}
Some(Self { cum: crate::live::cumulative_lengths(&pts), pts })
}
pub fn length(&self) -> f64 {
*self.cum.last().unwrap_or(&0.0)
}
/// Point and tangent angle (radians) at arc-length fraction `f` (0..1).
pub fn at(&self, f: f64) -> (Point, f64) {
let total = self.length();
let s = f.clamp(0.0, 1.0) * total;
let i = match self.cum.binary_search_by(|c| c.total_cmp(&s)) {
Ok(i) => i.min(self.pts.len() - 2),
Err(i) => i.saturating_sub(1).min(self.pts.len() - 2),
};
let seg = (self.cum[i + 1] - self.cum[i]).max(1e-12);
let u = ((s - self.cum[i]) / seg).clamp(0.0, 1.0);
let d = self.pts[i + 1] - self.pts[i];
(self.pts[i].lerp(self.pts[i + 1], u), d.y.atan2(d.x))
}
}
// =====================================================================================
// Blend evaluation
// =====================================================================================
/// Evaluate a blend: the keys and generated steps in paint order.
pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
let k = keys.len();
if k < 2 {
return keys.iter().map(|n| (**n).clone()).collect();
}
let spine = spec.spine.as_ref().and_then(Spine::new);
let place = |mut n: Node, f: f64| -> Node {
if let Some(sp) = &spine {
let (p, ang) = sp.at(f);
let c = center_of(&n);
let rot = if spec.orientation == BlendOrientation::AlignToPath { Affine::rotate(ang) } else { Affine::IDENTITY };
n.transform(Affine::translate(p.to_vec2()) * rot * Affine::translate(-c.to_vec2()), false);
}
n
};
let mut out = Vec::new();
for (i, a) in keys.iter().enumerate() {
out.push(place((**a).clone(), i as f64 / (k - 1) as f64));
let Some(b) = keys.get(i + 1) else { break };
let len = match &spine {
Some(sp) => sp.length() / (k - 1) as f64,
None => center_of(a).distance(center_of(b)),
};
let n = blend_step_count(a, b, spec.spacing, len);
let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1)));
for j in 1..=n {
let t = j as f64 / (n + 1) as f64;
out.push(place(lerp.at(t), (i as f64 + t) / (k - 1) as f64));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_geom::{Rect, shapes};
#[test]
fn clicked_start_points_pair_up() {
let a = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
let mut b = shapes::rectangle(Rect::new(100.0, 0.0, 110.0, 10.0));
b.subpaths[0].anchors.rotate_left(2);
// 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 `b` at its anchor 2 (its first corner again) pairs like corners.
let r = PathPair::new(&a, &b, (Some(0), Some(2))).at(0.5).bounds().unwrap();
assert!((r.width() - 10.0).abs() < 1e-6 && (r.height() - 10.0).abs() < 1e-6, "{r:?}");
// An open path clicked at its end runs the other way.
let line = |x: f64| PathData::single(SubPath::polyline(&[Point::new(x, 0.0), Point::new(x + 10.0, 0.0)], false));
let m = PathPair::new(&line(0.0), &line(100.0), (None, Some(1))).at(0.5);
assert_eq!(m.subpaths[0].anchors[0].p, Point::new(55.0, 0.0));
}
}
+1
View File
@@ -7,6 +7,7 @@
pub mod appearance;
pub mod assets;
pub mod blend;
pub mod clipnest;
pub mod graph;
pub mod hit;
+31 -370
View File
@@ -18,8 +18,8 @@ use std::f64::consts::PI;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use vectorcraft_color::{Color, Gradient, GradientPaint, Paint};
use vectorcraft_geom::{Affine, Anchor, BezPath, CubicBez, FillRule, ParamCurve, PathData, Point, Rect, Shape, SubPath, Vec2};
use vectorcraft_color::{Color, Paint};
use vectorcraft_geom::{Affine, Anchor, BezPath, CubicBez, ParamCurve, PathData, Point, Rect, Shape, SubPath, Vec2};
use crate::appearance::{Appearance, AppearanceItem, FillLayer};
use crate::node::{Node, NodeId, NodeKind};
@@ -69,6 +69,31 @@ pub struct BlendSpec {
/// Replaced spine. `None` = the straight lines between the key objects' centres.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spine: Option<PathData>,
/// The anchor of the spine's first subpath each key object sits on (a spine edited on the
/// canvas: moving a key moves its anchor and the other way round). Empty: the keys spread
/// evenly along the spine by arc length (Replace Spine).
#[serde(default, rename = "keyAnchors", skip_serializing_if = "Vec::is_empty")]
pub key_anchors: Vec<u32>,
/// Per key object, the anchor of its first subpath the blend starts from (Blend tool clicks
/// on anchor points); `None`: chosen so closed shapes don't twist.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub starts: Vec<Option<u32>>,
}
impl BlendSpec {
/// The start anchor of key `i` (see [`Self::starts`]).
pub fn start(&self, i: usize) -> Option<usize> {
self.starts.get(i).copied().flatten().map(|a| a as usize)
}
}
/// Blend Options set with no blend selected: what new blends start with.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct BlendDefaults {
#[serde(default)]
pub spacing: BlendSpacing,
#[serde(default)]
pub orientation: BlendOrientation,
}
/// How an envelope distorts its content.
@@ -228,374 +253,10 @@ pub fn default_fidelity() -> f64 {
}
// =====================================================================================
// Colour / paint interpolation
// Blends (evaluation in `crate::blend`)
// =====================================================================================
/// Interpolate two colours in their shared model (display RGB when the models differ).
pub fn lerp_color(a: &Color, b: &Color, t: f32) -> Color {
let l = |x: f32, y: f32| x + (y - x) * t;
match (*a, *b) {
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)),
(Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)),
(Color::Lab { l: l1, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => Color::lab(l(l1, l2), l(a, a2), l(b, b2)),
_ => a.lerp(b, t),
}
}
fn solid_gradient(c: Color, like: &Gradient) -> Gradient {
let mut g = like.clone();
for s in &mut g.stops {
s.color = c;
s.opacity = 1.0;
}
g
}
fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option<GradientPaint> {
if a.gradient.kind != b.gradient.kind || a.gradient.stops.len() != b.gradient.stops.len() {
return None;
}
let mut out = if t < 0.5 { a.clone() } else { b.clone() };
let l = |x: f32, y: f32| x + (y - x) * t;
for (i, s) in out.gradient.stops.iter_mut().enumerate() {
let (sa, sb) = (&a.gradient.stops[i], &b.gradient.stops[i]);
s.offset = l(sa.offset, sb.offset);
s.color = lerp_color(&sa.color, &sb.color, t);
s.opacity = l(sa.opacity, sb.opacity);
s.midpoint = l(sa.midpoint, sb.midpoint);
}
out.angle = a.angle + (b.angle - a.angle) * t as f64;
out.swatch = None;
if let (Some(ga), Some(gb)) = (a.geom, b.geom) {
let mut g = ga;
g.start = ga.start.lerp(gb.start, t as f64);
g.end = ga.end.lerp(gb.end, t as f64);
g.aspect = ga.aspect + (gb.aspect - ga.aspect) * t as f64;
g.focal = (ga.focal.is_some() || gb.focal.is_some()).then(|| ga.focal_point().lerp(gb.focal_point(), t as f64));
out.geom = Some(g);
} else {
out.geom = None;
}
Some(out)
}
/// Interpolate paints: solid↔solid, compatible gradients, solid↔gradient; otherwise switch halfway.
pub fn lerp_paint(a: &Paint, b: &Paint, t: f32) -> Paint {
match (a, b) {
(Paint::Solid { color: x, .. }, Paint::Solid { color: y, .. }) => Paint::solid(lerp_color(x, y, t)),
(Paint::Gradient(ga), Paint::Gradient(gb)) => match lerp_gradient(ga, gb, t) {
Some(g) => Paint::Gradient(Box::new(g)),
None => (if t < 0.5 { a } else { b }).clone(),
},
(Paint::Solid { color, .. }, Paint::Gradient(g)) => {
let ga = GradientPaint { gradient: solid_gradient(*color, &g.gradient), ..(**g).clone() };
Paint::Gradient(Box::new(lerp_gradient(&ga, g, t).unwrap_or_else(|| (**g).clone())))
}
(Paint::Gradient(g), Paint::Solid { color, .. }) => {
let gb = GradientPaint { gradient: solid_gradient(*color, &g.gradient), ..(**g).clone() };
Paint::Gradient(Box::new(lerp_gradient(g, &gb, t).unwrap_or_else(|| (**g).clone())))
}
_ => (if t < 0.5 { a } else { b }).clone(),
}
}
/// Interpolate appearance stacks: item by item when their structure matches, else the top fill
/// and stroke only (on the structure of the nearer key).
pub fn lerp_appearance(a: &Appearance, b: &Appearance, t: f64) -> Appearance {
let tf = t as f32;
let mut out = if t < 0.5 { a.clone() } else { b.clone() };
let same = a.items.len() == b.items.len()
&& a.items.iter().zip(&b.items).all(|(x, y)| {
matches!((x, y), (AppearanceItem::Fill(_), AppearanceItem::Fill(_)) | (AppearanceItem::Stroke(_), AppearanceItem::Stroke(_)))
});
if same {
for (i, it) in out.items.iter_mut().enumerate() {
match (it, &a.items[i], &b.items[i]) {
(AppearanceItem::Fill(o), AppearanceItem::Fill(x), AppearanceItem::Fill(y)) => {
o.paint = lerp_paint(&x.paint, &y.paint, tf);
o.opacity = x.opacity + (y.opacity - x.opacity) * tf;
}
(AppearanceItem::Stroke(o), AppearanceItem::Stroke(x), AppearanceItem::Stroke(y)) => {
o.paint = lerp_paint(&x.paint, &y.paint, tf);
o.width = x.width + (y.width - x.width) * t;
o.opacity = x.opacity + (y.opacity - x.opacity) * tf;
}
_ => {}
}
}
return out;
}
let (fa, fb) = (a.fill_paint(), b.fill_paint());
if !(fa.is_none() && fb.is_none()) {
out.set_fill(lerp_paint(&fa, &fb, tf));
}
let (sa, sb) = (a.stroke_paint(), b.stroke_paint());
if !(sa.is_none() && sb.is_none()) {
out.set_stroke(lerp_paint(&sa, &sb, tf));
let w = a.stroke_width() + (b.stroke_width() - a.stroke_width()) * t;
if let Some(s) = out.stroke_mut() {
s.width = w;
}
}
out
}
// =====================================================================================
// Path interpolation
// =====================================================================================
/// Grow a subpath to `n` anchors by splitting its longest segments.
fn grow(sp: &mut SubPath, n: usize) {
let mut guard = 0;
while sp.anchors.len() < n && guard < 20_000 {
guard += 1;
if sp.segment_count() == 0 {
match sp.anchors.last().copied() {
Some(a) => sp.anchors.push(Anchor::corner(a.p)),
None => return,
}
continue;
}
let seg = (0..sp.segment_count())
.max_by(|i, j| {
let (ci, cj) = (sp.segment(*i), sp.segment(*j));
(ci.p3 - ci.p0).hypot().total_cmp(&(cj.p3 - cj.p0).hypot())
})
.unwrap_or(0);
sp.insert_anchor(seg, 0.5);
}
}
fn lerp_anchor(x: &Anchor, y: &Anchor, t: f64) -> Anchor {
Anchor { p: x.p.lerp(y.p, t), h_in: x.h_in.lerp(y.h_in, t), h_out: x.h_out.lerp(y.h_out, t), kind: if t < 0.5 { x.kind } else { y.kind } }
}
/// Interpolate two paths anchor-by-anchor. Subpath and anchor counts are equalised first (missing
/// subpaths grow out of a point; shorter subpaths are resampled); closed subpaths of opposite
/// winding are reversed so they don't flip through themselves.
pub fn lerp_path(a: &PathData, b: &PathData, t: f64) -> PathData {
let n = a.subpaths.len().max(b.subpaths.len());
let ca = a.bounds().map(|r| r.center()).unwrap_or_default();
let cb = b.bounds().map(|r| r.center()).unwrap_or_default();
let degenerate = |like: &SubPath, c: Point| SubPath::new(vec![Anchor::corner(c); like.anchors.len().max(1)], like.closed);
let mut out = Vec::with_capacity(n);
for i in 0..n {
let (mut sa, mut sb) = match (a.subpaths.get(i), b.subpaths.get(i)) {
(Some(x), Some(y)) => (x.clone(), y.clone()),
(Some(x), None) => (x.clone(), degenerate(x, cb)),
(None, Some(y)) => (degenerate(y, ca), y.clone()),
(None, None) => continue,
};
if sa.closed && sb.closed && sa.anchors.len() > 2 && sb.anchors.len() > 2 && (sa.area() * sb.area()) < 0.0 {
sb.reverse();
}
let m = sa.anchors.len().max(sb.anchors.len());
grow(&mut sa, m);
grow(&mut sb, m);
let anchors = sa.anchors.iter().zip(&sb.anchors).map(|(x, y)| lerp_anchor(x, y, t)).collect();
out.push(SubPath::new(anchors, if t < 0.5 { sa.closed } else { sb.closed }));
}
PathData::new(out)
}
/// Path data of a path or compound path node (compound children concatenated).
fn node_path(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)),
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))
}
_ => None,
}
}
/// Interpolate two objects at `t` (0 = `a`, 1 = `b`).
pub fn lerp_node(a: &Node, b: &Node, t: f64) -> Node {
let base = if t < 0.5 { a } else { b };
let mut n = match (&a.kind, &b.kind) {
(NodeKind::Group { children: ca, clip: k1 }, NodeKind::Group { children: cb, clip: k2 }) if ca.len() == cb.len() && k1 == k2 => {
let children = ca.iter().zip(cb).map(|(x, y)| Arc::new(lerp_node(x, y, t))).collect();
Node::new(NodeId(0), NodeKind::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() => {
let mut m = ma.clone();
for (i, p) in m.points.iter_mut().enumerate() {
let q = &mb.points[i];
p.p = p.p.lerp(q.p, t);
p.color = lerp_color(&p.color, &q.color, t as f32);
p.opacity += (q.opacity - p.opacity) * t as f32;
for h in 0..4 {
p.handles[h] = p.handles[h].lerp(q.handles[h], t);
}
}
Node::new(NodeId(0), NodeKind::Mesh(m))
}
_ => match (node_path(a), node_path(b)) {
(Some((pa, ra)), Some((pb, rb))) => {
let path = lerp_path(&pa, &pb, t);
let mut n = Node::path(NodeId(0), path, Appearance::default());
if let NodeKind::Path { rule, .. } = &mut n.kind {
*rule = if t < 0.5 { ra } else { rb };
}
n
}
_ => {
// Different structure: move/scale a copy of the nearer key into the interpolated box.
let mut n = base.clone();
if let (Some(ba), Some(bb), Some(bn)) = (a.geometric_bounds(), b.geometric_bounds(), base.geometric_bounds()) {
let w = ba.width() + (bb.width() - ba.width()) * t;
let h = ba.height() + (bb.height() - ba.height()) * t;
let c = ba.center().lerp(bb.center(), t);
let sx = if bn.width() > 1e-9 { w / bn.width() } else { 1.0 };
let sy = if bn.height() > 1e-9 { h / bn.height() } else { 1.0 };
n.transform(
Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-bn.center().to_vec2()),
false,
);
}
if let NodeKind::Path { live, .. } = &mut n.kind {
*live = None;
}
n.id = NodeId(0);
n
}
},
};
n.appearance = lerp_appearance(&a.appearance, &b.appearance, t);
n.opacity = a.opacity + (b.opacity - a.opacity) * t as f32;
n.blend = base.blend;
n.visible = true;
n
}
// =====================================================================================
// Blend evaluation
// =====================================================================================
/// Largest channel difference (0..1) between the solid colours of two objects' top fill and stroke.
fn color_distance(a: &Node, b: &Node) -> f32 {
let mut d = 0.0f32;
let mut cmp = |x: &Paint, y: &Paint| {
let cols = |p: &Paint| -> Vec<Color> {
match p {
Paint::Solid { color, .. } => vec![*color],
Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color).collect(),
_ => vec![],
}
};
let (cx, cy) = (cols(x), cols(y));
for (i, c) in cx.iter().enumerate() {
if let Some(o) = cy.get(i).or(cy.first()) {
let (p, q) = (c.to_rgb(), o.to_rgb());
for k in 0..3 {
d = d.max((p[k] - q[k]).abs());
}
}
}
};
cmp(&a.appearance.fill_paint(), &b.appearance.fill_paint());
cmp(&a.appearance.stroke_paint(), &b.appearance.stroke_paint());
d
}
fn center_of(n: &Node) -> Point {
n.geometric_bounds().map(|b| b.center()).unwrap_or_default()
}
/// Number of intermediate steps between two keys `len` apart (along the spine).
pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) -> usize {
match spacing {
BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize,
BlendSpacing::Distance(d) => {
let d = d.max(0.01);
((len / d).round() as i64 - 1).clamp(0, 1000) as usize
}
BlendSpacing::SmoothColor => {
let cd = color_distance(a, b);
if cd > 1.0 / 255.0 {
((cd * 255.0 / 2.0).ceil() as usize).clamp(1, 256)
} else {
// Same colours: base the count on the distance between the objects.
let (ba, bb) = (a.geometric_bounds(), b.geometric_bounds());
let dist = match (ba, bb) {
(Some(x), Some(y)) => (x.x0 - y.x0).abs().max((x.x1 - y.x1).abs()).max((x.y0 - y.y0).abs()).max((x.y1 - y.y1).abs()),
_ => len,
};
((dist / 2.0).ceil() as usize).clamp(1, 256)
}
}
}
}
/// A spine flattened to a polyline with cumulative lengths.
pub struct Spine {
pts: Vec<Point>,
cum: Vec<f64>,
}
impl Spine {
pub fn new(path: &PathData) -> Option<Self> {
let sp = path.subpaths.first()?;
let pts = flatten_subpath(sp, 0.1);
if pts.len() < 2 {
return None;
}
Some(Self { cum: cumulative_lengths(&pts), pts })
}
pub fn length(&self) -> f64 {
*self.cum.last().unwrap_or(&0.0)
}
/// Point and tangent angle (radians) at arc-length fraction `f` (0..1).
pub fn at(&self, f: f64) -> (Point, f64) {
let total = self.length();
let s = f.clamp(0.0, 1.0) * total;
let i = match self.cum.binary_search_by(|c| c.total_cmp(&s)) {
Ok(i) => i.min(self.pts.len() - 2),
Err(i) => i.saturating_sub(1).min(self.pts.len() - 2),
};
let seg = (self.cum[i + 1] - self.cum[i]).max(1e-12);
let u = ((s - self.cum[i]) / seg).clamp(0.0, 1.0);
let d = self.pts[i + 1] - self.pts[i];
(self.pts[i].lerp(self.pts[i + 1], u), d.y.atan2(d.x))
}
}
/// Evaluate a blend: the keys and generated steps in paint order.
pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
let k = keys.len();
if k < 2 {
return keys.iter().map(|n| (**n).clone()).collect();
}
let spine = spec.spine.as_ref().and_then(Spine::new);
let place = |mut n: Node, f: f64| -> Node {
if let Some(sp) = &spine {
let (p, ang) = sp.at(f);
let c = center_of(&n);
let rot = if spec.orientation == BlendOrientation::AlignToPath { Affine::rotate(ang) } else { Affine::IDENTITY };
n.transform(Affine::translate(p.to_vec2()) * rot * Affine::translate(-c.to_vec2()), false);
}
n
};
let mut out = Vec::new();
for i in 0..k {
let a = &keys[i];
out.push(place((**a).clone(), i as f64 / (k - 1) as f64));
let Some(b) = keys.get(i + 1) else { break };
let len = match &spine {
Some(sp) => sp.length() / (k - 1) as f64,
None => center_of(a).distance(center_of(b)),
};
let n = blend_step_count(a, b, spec.spacing, len);
for j in 1..=n {
let t = j as f64 / (n + 1) as f64;
let mut s = lerp_node(a, b, t);
s.id = NodeId(0);
out.push(place(s, (i as f64 + t) / (k - 1) as f64));
}
}
out
}
pub use crate::blend::{Spine, blend_expand, blend_step_count, lerp_appearance, lerp_color, lerp_node, lerp_paint, lerp_path};
// =====================================================================================
// Warp maps (shared with vectorcraft-effects' Warp effects)
@@ -711,7 +372,7 @@ fn poly_len(c: &CubicBez) -> f64 {
}
/// Running length along a polyline: `0, |p0p1|, |p0p1| + |p1p2|, …` (one entry per point).
fn cumulative_lengths(pts: &[Point]) -> Vec<f64> {
pub(crate) fn cumulative_lengths(pts: &[Point]) -> Vec<f64> {
let mut total = 0.0;
let mut cum = Vec::with_capacity(pts.len());
cum.push(0.0);
@@ -772,7 +433,7 @@ pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point
// Coons patches from outlines
// =====================================================================================
fn flatten_subpath(sp: &SubPath, tol: f64) -> Vec<Point> {
pub(crate) fn flatten_subpath(sp: &SubPath, tol: f64) -> Vec<Point> {
let mut bp = BezPath::new();
sp.to_bezpath_into(&mut bp);
let mut pts: Vec<Point> = Vec::new();
+137 -16
View File
@@ -10,7 +10,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::live::{
self, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeOptions, GradientMesh, MeshAppearance, PreserveShape, Spine,
self, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeOptions, GradientMesh, MeshAppearance, PreserveShape, Spine,
};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{Affine, PathData, Point};
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Blend"],
Some("Cmd+Alt+B"),
"{ids?, steps?: n | distance?: pt | smooth?: bool (default smooth colour), orientation?: page|path} blend the selected objects (paint order) into a live blend → {id}",
"{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}",
has_doc,
blend_make
),
@@ -45,8 +45,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Blend Options…",
["Object", "Blend"],
None,
"{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path} set the options of the selected blends",
has_blend,
"{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path} set the options of the selected blends; with no blend selected, the options new blends start with (a tool setting, not an undo step) → {defaults?: true}",
always,
blend_options
),
cmd!(
@@ -76,6 +76,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_blend,
blend_reverse_spine
),
cmd!(
query "object.blend.info",
"Blend Info",
[],
None,
"{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, keys?: [ids], starts?: [anchor|null]}",
always,
blend_info
),
cmd!(
"object.blend.reverseFrontToBack",
"Reverse Front to Back",
@@ -352,14 +361,18 @@ fn fix_ids(d: &mut Document, n: &mut Node) {
// ---------- Blend ----------
/// The Specified Steps and Specified Distance a spacing mode starts with.
const DEFAULT_STEPS: u32 = 5;
const DEFAULT_DISTANCE: f64 = 10.0;
fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result<BlendSpacing> {
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let mode = str_param(p, "spacing");
let value = num("value");
let sp = match mode {
Some("smooth") | Some("smoothColor") => BlendSpacing::SmoothColor,
Some("steps") => BlendSpacing::Steps(value.or(num("steps")).unwrap_or(5.0) as u32),
Some("distance") => BlendSpacing::Distance(value.or(num("distance")).unwrap_or(10.0)),
Some("steps") => BlendSpacing::Steps(value.or(num("steps")).unwrap_or(DEFAULT_STEPS as f64) as u32),
Some("distance") => BlendSpacing::Distance(value.or(num("distance")).unwrap_or(DEFAULT_DISTANCE)),
Some(o) => return Err(bad(cmd, format!("unknown spacing `{o}` (smooth|steps|distance)"))),
None => {
if let Some(n) = num("steps") {
@@ -388,16 +401,77 @@ fn orientation_param(p: &Value, current: BlendOrientation) -> BlendOrientation {
}
}
/// `starts`: per object of `ids` (else of `roots`), the anchor its blend starts from.
fn starts_param(p: &Value, roots: &[NodeId]) -> Result<Vec<(NodeId, u32)>> {
const C: &str = "object.blend.make";
let Some(v) = p.get("starts").filter(|v| !v.is_null()) else { return Ok(vec![]) };
let list = v.as_array().ok_or_else(|| bad(C, "`starts` must be an array of anchor indices (or nulls)"))?;
let owners = ids_param(p, "ids").unwrap_or_else(|| roots.to_vec());
let mut out = vec![];
for (id, a) in owners.iter().zip(list) {
if a.is_null() {
continue;
}
let i = a.as_u64().and_then(|i| u32::try_from(i).ok()).ok_or_else(|| bad(C, "a start must be an anchor index ≥ 0 or null"))?;
out.push((*id, i));
}
Ok(out)
}
/// The first blend among `roots`.
fn first_blend(d: &Document, roots: &[NodeId]) -> Option<Node> {
roots.iter().filter_map(|r| d.node(*r)).find(|n| is_blend(n)).cloned()
}
/// Key objects of a blend made of `nodes` (paint order) and their start points: a blend among
/// them gives its keys (with theirs), the other objects are keys themselves.
fn merge_keys(nodes: Vec<Arc<Node>>, starts: &[(NodeId, u32)]) -> (Vec<Arc<Node>>, Vec<Option<u32>>) {
let (mut keys, mut st) = (vec![], vec![]);
for n in nodes {
match &n.kind {
NodeKind::Blend { children, spec } => {
st.extend((0..children.len()).map(|i| spec.starts.get(i).copied().flatten()));
keys.extend(children.iter().cloned());
}
_ => {
st.push(starts.iter().find(|(id, _)| *id == n.id).map(|(_, a)| *a));
keys.push(n);
}
}
}
if st.iter().all(Option::is_none) {
st.clear();
}
(keys, st)
}
fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.make";
let spacing = spacing_param(p, C, BlendSpacing::SmoothColor)?;
let orientation = orientation_param(p, BlendOrientation::AlignToPage);
let roots = roots_param(s, p)?;
if roots.len() < 2 {
return Err(bad(C, "select at least two objects"));
}
let starts = starts_param(p, &roots)?;
let base = first_blend(&s.doc()?.doc, &roots);
let cur = match base.as_ref().map(|n| &n.kind) {
Some(NodeKind::Blend { spec, .. }) => BlendDefaults { spacing: spec.spacing, orientation: spec.orientation },
_ => s.prefs.blend_options.unwrap_or_default(),
};
let spacing = spacing_param(p, C, cur.spacing)?;
let orientation = orientation_param(p, cur.orientation);
let id = s.edit("Make Blend", |d, sel| {
wrap(d, sel, &roots, |id, children| Node::new(id, NodeKind::Blend { children, spec: BlendSpec { spacing, orientation, spine: None } }))
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 }));
let spine = match &n.kind {
NodeKind::Blend { spec, .. } => spec.spine.clone(),
_ => None,
};
n.id = id;
n.kind = NodeKind::Blend { children, spec: BlendSpec { spacing, orientation, spine, starts, ..Default::default() } };
n
})
})?;
Ok(json!({ "id": id.0 }))
}
@@ -430,12 +504,23 @@ fn edit_blends(s: &mut Session, label: &str, f: impl Fn(&mut Vec<Arc<Node>>, &mu
ok()
}
/// The first selected blend: its id, spec and key ids.
fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec<NodeId>)> {
let b = *selected_of(s, is_blend).first()?;
match &s.doc().ok()?.doc.node(b)?.kind {
NodeKind::Blend { spec, children } => Some((b, spec.clone(), children.iter().map(|c| c.id).collect())),
_ => None,
}
}
fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.options";
let blends = selected_of(s, is_blend);
let cur = match blends.first().and_then(|b| s.doc().ok()?.doc.node(*b)).map(|n| &n.kind) {
Some(NodeKind::Blend { spec, .. }) => spec.clone(),
_ => BlendSpec::default(),
let Some((_, cur, _)) = selected_spec(s) else {
// Nothing to set them on: the options new blends start with.
let d = s.prefs.blend_options.unwrap_or_default();
let spacing = spacing_param(p, C, d.spacing)?;
s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) });
return Ok(json!({ "defaults": true }));
};
let spacing = spacing_param(p, C, cur.spacing)?;
let orientation = orientation_param(p, cur.orientation);
@@ -445,6 +530,37 @@ fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Spacing and orientation as `object.blend.options` takes them; the step count and distance
/// not in use show their defaults.
fn blend_options_json(spacing: BlendSpacing, orientation: BlendOrientation) -> Value {
let (mode, steps, distance) = match spacing {
BlendSpacing::SmoothColor => ("smooth", DEFAULT_STEPS, DEFAULT_DISTANCE),
BlendSpacing::Steps(n) => ("steps", n, DEFAULT_DISTANCE),
BlendSpacing::Distance(d) => ("distance", DEFAULT_STEPS, d),
};
let orientation = if orientation == BlendOrientation::AlignToPath { "path" } else { "page" };
json!({ "spacing": mode, "steps": steps, "distance": distance, "orientation": orientation })
}
fn blend_info(s: &mut Session, _: &Value) -> Result<Value> {
Ok(match selected_spec(s) {
Some((id, spec, keys)) => {
let mut v = blend_options_json(spec.spacing, spec.orientation);
v["target"] = json!("blend");
v["id"] = json!(id.0);
v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::<Vec<_>>());
v["keys"] = json!(keys.iter().map(|k| k.0).collect::<Vec<_>>());
v
}
None => {
let d = s.prefs.blend_options.unwrap_or_default();
let mut v = blend_options_json(d.spacing, d.orientation);
v["target"] = json!("defaults");
v
}
})
}
fn blend_expand(s: &mut Session, _: &Value) -> Result<Value> {
let blends = selected_of(s, is_blend);
let ids = s.edit("Expand Blend", |d, sel| {
@@ -524,6 +640,11 @@ fn blend_reverse_spine(s: &mut Session, _: &Value) -> Result<Value> {
fn blend_reverse_stack(s: &mut Session, _: &Value) -> Result<Value> {
edit_blends(s, "Reverse Front to Back", |children, spec| {
children.reverse();
// Each key keeps its start point.
if !spec.starts.is_empty() {
spec.starts.resize(children.len(), None);
spec.starts.reverse();
}
if let Some(sp) = &mut spec.spine {
sp.reverse();
}
@@ -682,7 +803,7 @@ fn env_reset_mesh(s: &mut Session, p: &Value) -> Result<Value> {
}
/// `v` with an envelope's options and fidelity added.
fn options_json(mut v: Value, o: &EnvelopeOptions, fidelity: f64) -> Value {
fn envelope_options_json(mut v: Value, o: &EnvelopeOptions, fidelity: f64) -> Value {
v["fidelity"] = json!(fidelity);
v["antiAlias"] = json!(o.anti_alias);
v["preserveShape"] = json!(o.preserve_shape.id());
@@ -712,7 +833,7 @@ fn env_info(s: &mut Session, _: &Value) -> Result<Value> {
let env = selected_of(s, is_envelope).into_iter().find_map(|e| Some((e, &doc.node(e)?.kind)));
let Some((id, NodeKind::Envelope { kind, fidelity, editing, options, .. })) = env else {
let (options, fidelity) = envelope_defaults(s);
return Ok(options_json(json!({ "id": null }), &options, fidelity));
return Ok(envelope_options_json(json!({ "id": null }), &options, fidelity));
};
let v = match kind {
EnvelopeKind::Warp { style, bend, h, v, horizontal } => {
@@ -721,7 +842,7 @@ fn env_info(s: &mut Session, _: &Value) -> Result<Value> {
EnvelopeKind::Mesh { rows, cols, .. } => json!({"type": "mesh", "rows": rows, "cols": cols}),
EnvelopeKind::TopObject { .. } => json!({"type": "topObject"}),
};
let mut v = options_json(v, options, *fidelity);
let mut v = envelope_options_json(v, options, *fidelity);
v["id"] = json!(id.0);
v["editing"] = json!(editing);
Ok(v)
+9
View File
@@ -494,6 +494,12 @@ pub struct Prefs {
/// `perspective.presets.*` edit it.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub perspective_presets: Vec<vectorcraft_tools::distort::perspective::GridDefinition>,
/// Blend Options set with no blend selected: what new blends start with
/// (`object.blend.options`; none: Smooth Color, Align to Page). A tool setting, not a
/// Preferences dialog field: it has no [`cmd::prefscmds::PREF_SPECS`] row and resetting the
/// preferences keeps it.
#[serde(skip_serializing_if = "Option::is_none")]
pub blend_options: Option<vectorcraft_doc::live::BlendDefaults>,
}
impl Default for Prefs {
@@ -629,6 +635,7 @@ impl Default for Prefs {
constrain_proportions: false,
tool_settings: Default::default(),
perspective_presets: vec![],
blend_options: None,
}
}
}
@@ -1141,6 +1148,8 @@ mod tests_attributes;
#[cfg(test)]
mod tests_bboxrotate;
#[cfg(test)]
mod tests_blendopts;
#[cfg(test)]
mod tests_brushsym;
#[cfg(test)]
mod tests_build;
+155
View File
@@ -0,0 +1,155 @@
//! Blend Options (defaults with nothing selected, `object.blend.info`) and the Blend tool:
//! start points from clicked anchors, adding keys to a blend instead of nesting it (M8.18).
use serde_json::{Value, json};
use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
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) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": w})).unwrap())
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn spec(s: &Session, id: NodeId) -> (Vec<NodeId>, BlendSpec) {
match node(s, id).kind {
NodeKind::Blend { children, spec } => (children.iter().map(|c| c.id).collect(), spec),
_ => panic!("not a blend"),
}
}
fn sel(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
#[test]
fn options_with_nothing_selected_set_what_new_blends_start_with() {
let mut s = session();
let info = s.execute("object.blend.info", &json!({})).unwrap();
assert_eq!((info["target"].as_str(), info["spacing"].as_str()), (Some("defaults"), Some("smooth")));
let depth = s.doc().unwrap().history.undo.len();
let r = s.execute("object.blend.options", &json!({"spacing": "distance", "value": 20, "orientation": "path"})).unwrap();
assert_eq!(r["defaults"], json!(true));
assert_eq!(s.doc().unwrap().history.undo.len(), depth, "a tool setting, not an undo step");
let info = s.execute("object.blend.info", &json!({})).unwrap();
assert_eq!((info["spacing"].as_str(), info["distance"].as_f64(), info["orientation"].as_str()), (Some("distance"), Some(20.0), Some("path")));
// New blends start with them; explicit params still win.
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0));
sel(&mut s, &[a, b]);
let g = id_of(&s.execute("object.blend.make", &json!({})).unwrap());
let (_, sp) = spec(&s, g);
assert_eq!((sp.spacing, sp.orientation), (BlendSpacing::Distance(20.0), BlendOrientation::AlignToPath));
s.execute("edit.undo", &json!({})).unwrap();
sel(&mut s, &[a, b]);
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 2})).unwrap());
assert_eq!(spec(&s, g).1.spacing, BlendSpacing::Steps(2));
// The preference survives a round trip through the preferences file.
let back: Prefs = serde_json::from_value(s.prefs.to_json()).unwrap();
assert_eq!(back.blend_options, s.prefs.blend_options);
assert!(Prefs::default().to_json().get("blendOptions").is_none());
}
#[test]
fn info_reads_the_selected_blend() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0));
sel(&mut s, &[a, b]);
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 4})).unwrap());
let info = s.execute("object.blend.info", &json!({})).unwrap();
assert_eq!(info["target"], "blend");
assert_eq!(info["id"], json!(g.0));
assert_eq!((info["spacing"].as_str(), info["steps"].as_u64()), (Some("steps"), Some(4)));
assert_eq!(info["keys"], json!([a.0, b.0]));
}
#[test]
fn start_points_pair_the_clicked_anchors() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0));
// a starts at its top left, b at its bottom right: corners meet their opposites.
let g = id_of(&s.execute("object.blend.make", &json!({"ids": [b.0, a.0], "starts": [2, 0], "steps": 1})).unwrap());
let (keys, sp) = spec(&s, g);
assert_eq!(keys, vec![a, b], "keys in paint order");
assert_eq!(sp.starts, vec![Some(0), Some(2)], "each start stays with its object");
let steps = vectorcraft_doc::live::expand_live(&node(&s, g));
let mid = steps[1].geometric_bounds().unwrap();
assert!(mid.height() < 1e-6, "the middle step collapses where opposite corners meet: {mid:?}");
assert!(s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "starts": "x"})).is_err());
assert!(s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "starts": [-1, 0]})).is_err());
// Reverse Front to Back keeps each key's start.
sel(&mut s, &[g]);
s.execute("object.blend.reverseFrontToBack", &json!({})).unwrap();
assert_eq!(spec(&s, g).1.starts, vec![Some(2), Some(0)]);
}
#[test]
fn a_blend_and_an_object_make_one_blend_with_more_keys() {
let mut s = session();
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0), rect(&mut s, 200.0, 0.0, 10.0));
sel(&mut s, &[a, b]);
let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3})).unwrap());
s.edit("name", |d, _| {
d.node_mut(g).unwrap().name = Some("Ribbon".into());
Ok(())
})
.unwrap();
let g2 = id_of(&s.execute("object.blend.make", &json!({"ids": [g.0, c.0]})).unwrap());
let n = node(&s, g2);
let (keys, sp) = spec(&s, g2);
assert_eq!(keys, vec![a, b, c], "no blend inside the blend");
assert_eq!(sp.spacing, BlendSpacing::Steps(3), "the blend's options are kept");
assert_eq!(n.name.as_deref(), Some("Ribbon"));
assert!(s.doc().unwrap().doc.node(g).is_none());
assert_eq!(vectorcraft_doc::live::expand_live(&n).len(), 3 + 2 * 3);
}
#[test]
fn the_blend_tool_blends_from_clicked_anchors_and_keeps_adding() {
let mut s = session();
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0));
s.select_tool("blend", ViewInfo::default()).unwrap();
let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap();
// a's top right anchor, then b's interior.
click(&mut s, 40.0, 0.0);
click(&mut s, 220.0, 20.0);
let g = s.doc().unwrap().selection.objects[0];
let (keys, sp) = spec(&s, g);
assert_eq!(keys, vec![a, b]);
assert_eq!(sp.starts, vec![Some(1), None]);
// The next object joins the same blend.
click(&mut s, 420.0, 20.0);
let g2 = s.doc().unwrap().selection.objects[0];
assert_eq!(spec(&s, g2).0, vec![a, b, c]);
// Alt-click asks for Blend Options.
let r = s.pointer(&PointerEvent::new(PointerKind::Down, 600.0, 500.0).with_mods(Mods { alt: true, ..Default::default() }), ViewInfo::default());
assert!(matches!(r.unwrap().as_slice(), [UiRequest::Dialog(k, _)] if k == "blendOptions"));
}
#[test]
fn start_points_round_trip_and_old_blends_load() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0));
s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "starts": [null, 3]})).unwrap();
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
assert_eq!(&back.layers, &d.layers);
// A blend saved before start points has none.
let old: BlendSpec = serde_json::from_value(json!({"spacing": {"steps": 4}, "orientation": "alignToPage"})).unwrap();
assert!(old.starts.is_empty() && old.start(0).is_none());
assert!(serde_json::to_value(&old).unwrap().get("starts").is_none());
}
+3
View File
@@ -273,6 +273,9 @@ fn structured_junk() {
("object.liquify", json!({"tool": "pucker", "points": [[0, 0, 1e308], [1, 1, -5], [2, 2, "x"]], "usePressure": true})),
("object.liquify", json!({"tool": "twirl", "points": vec![json!([50, 50, 0.5]); 400], "usePressure": "yes", "simplifyOn": 3})),
("object.liquify", json!({"tool": "wrinkle", "points": [[0, 0], [1e4, 1e4]], "width": 1e308, "complexity": -1, "affectIn": null})),
("object.blend.make", json!({"ids": [1, 2, 3], "starts": [u64::MAX, -1, "x", null, 1e308]})),
("object.blend.make", json!({"ids": [2, 2], "starts": {"a": 1}, "steps": 1e308})),
("object.blend.options", json!({"spacing": "distance", "value": -1e308, "orientation": 5})),
];
let mut failures = vec![];
for fx in Fixture::ALL {
+6
View File
@@ -305,6 +305,12 @@ pub enum Cursor {
Slice,
/// The Slice Selection tool: the arrow with a slice badge.
SliceSelect,
/// The Blend tool away from art: a crosshair with a hollow square.
Blend,
/// The Blend tool over an object it can blend: a crosshair with a filled square.
BlendObject,
/// The Blend tool over an anchor point (the blend starts there): a crosshair with a target.
BlendAnchor,
}
/// A tool state machine.
+131 -19
View File
@@ -1,7 +1,9 @@
//! Blend tool (W) and Mesh tool (U).
//!
//! - Blend: click object A, then object B → `object.blend.make {ids: [A, B]}`. Double-click opens
//! Blend Options. Clicking empty canvas resets.
//! - Blend: click object A, then object B → `object.blend.make {ids: [A, B]}`; clicking an anchor
//! point of a path blends from that point (`starts`). Each further click adds the object as one
//! more key of the new blend (as does clicking a blend first). Double-click or Alt-click opens
//! Blend Options (dialog `blendOptions`). Clicking empty canvas starts over.
//! - Mesh: click inside a filled path → `object.mesh.create {ids, at}` (1×1 mesh plus lines through
//! the click); click inside a mesh → `object.mesh.addLine` (new point takes the current fill
//! colour unless Shift); drag a mesh point → `object.mesh.movePoint` previews; Alt-click a mesh
@@ -29,9 +31,48 @@ fn hit_top(cx: &ToolContext, p: Point) -> Option<(NodeId, NodeId)> {
Some((h.top_object(cx.isolation), h.leaf))
}
/// The dialog the Blend tool's double-click, Alt-click and toolbar button open.
const BLEND_OPTIONS: &str = "blendOptions";
/// What the Blend tool picks: an object and the anchor of its first subpath clicked, if any.
type Pick = (NodeId, Option<usize>);
#[derive(Default)]
pub struct BlendTool {
first: Option<NodeId>,
first: Option<Pick>,
/// A blend was just made: the next click adds to it (the selected blend).
chained: bool,
}
/// The anchor of the first subpath of path or compound path `id` (its first member's) nearest
/// `p`, within the click tolerance.
fn anchor_at(cx: &ToolContext, id: NodeId, p: Point) -> Option<usize> {
let n = cx.doc.node(id)?;
let path = match &n.kind {
NodeKind::Path { path, .. } => path,
NodeKind::Compound { children, .. } => children.first()?.path_data()?,
_ => return None,
};
let tol = cx.tol(5.0);
let d = |a: &vectorcraft_geom::Anchor| a.p.distance(p);
let (i, a) = path.subpaths.first()?.anchors.iter().enumerate().min_by(|x, y| d(x.1).total_cmp(&d(y.1)))?;
(d(a) <= tol).then_some(i)
}
/// The object the Blend tool picks at `p` (the top object hit) and the anchor clicked.
fn pick(cx: &ToolContext, p: Point) -> Option<Pick> {
let (top, _) = hit_top(cx, p)?;
Some((top, anchor_at(cx, top, p)))
}
impl BlendTool {
/// The object picked first: the clicked one, or the blend just made.
fn first(&self, cx: &ToolContext) -> Option<Pick> {
self.first.or_else(|| match cx.selection.objects.as_slice() {
[b] if self.chained && cx.doc.node(*b).is_some_and(|n| matches!(n.kind, NodeKind::Blend { .. })) => Some((*b, None)),
_ => None,
})
}
}
impl Tool for BlendTool {
@@ -39,23 +80,29 @@ impl Tool for BlendTool {
"blend"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let options = |t: &mut Self| {
(t.first, t.chained) = (None, false);
vec![Action::Dialog(BLEND_OPTIONS.into(), json!({}))]
};
match ev.kind {
PointerKind::DoubleClick => {
self.first = None;
vec![Action::Dialog("object.blend.options".into(), json!({}))]
}
PointerKind::Down => match (self.first, hit_top(cx, ev.pos)) {
PointerKind::DoubleClick => options(self),
PointerKind::Down if ev.mods.alt => options(self),
PointerKind::Down => match (self.first(cx), pick(cx, ev.pos)) {
(_, None) => {
self.first = None;
(self.first, self.chained) = (None, false);
vec![]
}
(None, Some((top, _))) => {
self.first = Some(top);
(None, Some((top, start))) => {
self.first = Some((top, start));
vec![Action::Exec("select.set".into(), json!({"ids": [top.0]}))]
}
(Some(a), Some((b, _))) if a != b && cx.doc.node(a).is_some() => {
self.first = None;
vec![Action::Exec("object.blend.make".into(), json!({"ids": [a.0, b.0]}))]
(Some((a, sa)), Some((b, sb))) if a != b && cx.doc.node(a).is_some() => {
(self.first, self.chained) = (None, true);
let mut p = json!({"ids": [a.0, b.0]});
if sa.is_some() || sb.is_some() {
p["starts"] = json!([sa, sb]);
}
vec![Action::Exec("object.blend.make".into(), p)]
}
(Some(_), Some(_)) => vec![],
},
@@ -63,17 +110,30 @@ impl Tool for BlendTool {
}
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let Some(b) = self.first.and_then(|a| cx.doc.node(a)).and_then(|n| n.geometric_bounds()) else { return vec![] };
vec![Overlay::Anchor { p: b.center(), color: FEEDBACK, filled: true, size: 6.0 }]
let Some((id, start)) = self.first(cx) else { return vec![] };
let Some(n) = cx.doc.node(id) else { return vec![] };
let at = match (start, &n.kind) {
(Some(i), NodeKind::Path { path, .. }) => path.subpaths.first().and_then(|s| s.anchors.get(i)).map(|a| a.p),
(Some(i), NodeKind::Compound { children, .. }) => {
children.first().and_then(|c| c.path_data()).and_then(|p| p.subpaths.first()).and_then(|s| s.anchors.get(i)).map(|a| a.p)
}
_ => None,
};
let Some(p) = at.or_else(|| n.geometric_bounds().map(|b| b.center())) else { return vec![] };
vec![Overlay::Anchor { p, color: FEEDBACK, filled: true, size: 6.0 }]
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Crosshair
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
match pick(cx, p) {
Some((_, Some(_))) => Cursor::BlendAnchor,
Some(_) => Cursor::BlendObject,
None => Cursor::Blend,
}
}
fn busy(&self) -> bool {
self.first.is_some()
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.first = None;
(self.first, self.chained) = (None, false);
vec![]
}
}
@@ -208,6 +268,58 @@ mod tests {
assert_eq!(acts, vec![Action::Exec("object.blend.make".into(), json!({"ids": [a.0, b.0]}))]);
}
#[test]
fn blend_tool_anchor_clicks_and_options() {
let (mut d, a) = doc_with_rect();
let l = d.layers[0].id;
let b = d.alloc_id();
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 100.0, 350.0, 150.0));
d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, r, vectorcraft_doc::Appearance::default_art())).unwrap();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = create("blend").unwrap();
// Over an anchor, over an object, away from art.
assert_eq!(t.cursor(&c, Point::new(100.0, 100.0), Mods::default()), Cursor::BlendAnchor);
assert_eq!(t.cursor(&c, Point::new(150.0, 150.0), Mods::default()), Cursor::BlendObject);
assert_eq!(t.cursor(&c, Point::new(250.0, 400.0), Mods::default()), Cursor::Blend);
// The rectangle's first anchor is its top left, b's third its bottom right.
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 101.0, 99.0));
assert!(t.busy());
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 350.0, 150.0));
assert_eq!(acts, vec![Action::Exec("object.blend.make".into(), json!({"ids": [a.0, b.0], "starts": [0, 2]}))]);
// Alt-click and double-click open Blend Options.
let alt = Mods { alt: true, ..Default::default() };
assert_eq!(
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 10.0, 10.0).with_mods(alt)),
vec![Action::Dialog("blendOptions".into(), json!({}))]
);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::DoubleClick, 10.0, 10.0)), vec![Action::Dialog("blendOptions".into(), json!({}))]);
}
#[test]
fn blend_tool_next_click_adds_to_the_blend_just_made() {
let (mut d, a) = doc_with_rect();
let l = d.layers[0].id;
let g = d.alloc_id();
let key = |id: NodeId, x: f64| {
let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0));
std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art()))
};
let (k1, k2) = (d.alloc_id(), d.alloc_id());
let blend = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 0.0), key(k2, 100.0)], spec: Default::default() });
d.insert(Some(l), 1, blend).unwrap();
let mut s = Selection::default();
let p = paint();
let mut t = BlendTool { first: None, chained: true };
s.set([g]);
let c = cx(&d, &s, &p);
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
assert_eq!(acts, vec![Action::Exec("object.blend.make".into(), json!({"ids": [g.0, a.0]}))]);
// Away from art: start over.
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 450.0, 450.0));
assert!(t.first(&c).is_none());
}
#[test]
fn mesh_tool_click_path_creates_mesh() {
let (d, a) = doc_with_rect();
+1
View File
@@ -394,6 +394,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
Cursor::RemoveStop => C::NotAllowed,
Cursor::Slice => C::Crosshair,
Cursor::SliceSelect => C::Default,
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => C::Crosshair,
}
}
+8
View File
@@ -536,6 +536,14 @@ fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> {
("Drag a handle", true),
(" to scale in perspective", false),
],
"blend" => &[
("Click", true),
(" an object, then another to blend them | ", false),
("Click an anchor point", true),
(" to blend from it | ", false),
("Alt+Click", true),
(" to set spacing and orientation", false),
],
_ => return None,
})
}
+16
View File
@@ -140,6 +140,21 @@ fn ibeam(p: &Painter, o: Pos2) {
line(p, o + vec2(-2.0, 3.0), o + vec2(2.0, 3.0));
}
/// The Blend tool: a crosshair with a square below right of the hotspot, hollow away from art,
/// filled over an object; over an anchor point a ringed dot (the blend starts there).
fn blend(p: &Painter, o: Pos2, badge: Cursor) {
crosshair(p, o);
let b = o + vec2(9.0, 9.0);
if badge == Cursor::BlendAnchor {
p.circle_stroke(b + vec2(3.5, 3.5), 3.5, Stroke::new(3.0, HALO));
p.circle_stroke(b + vec2(3.5, 3.5), 3.5, Stroke::new(1.2, INK));
p.circle_filled(b + vec2(3.5, 3.5), 1.4, INK);
return;
}
let fill = if badge == Cursor::BlendObject { INK } else { HALO };
poly(p, vec![b, b + vec2(7.0, 0.0), b + vec2(7.0, 7.0), b + vec2(0.0, 7.0)], fill, INK);
}
/// Paint cursor `c` at `p` on the given (foreground) painter. Returns false for cursors that should
/// stay system cursors (hand, zoom, busy states).
pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
@@ -170,6 +185,7 @@ pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
arrow(painter, p, false);
slice_badge(painter, p + vec2(11.0, 14.0));
}
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => blend(painter, p, c),
_ => return false,
}
let _ = pos2;
+213
View File
@@ -0,0 +1,213 @@
//! Object → Blend → Blend Options (also the Blend tool's double-click, Alt-click and toolbar
//! button): Spacing (Smooth Color, Specified Steps or Specified Distance) with its value and
//! Orientation (Align to Page or Align to Path), opened on the selected blend's options and
//! previewed live on it; OK keeps the preview as one undo step (`object.blend.options`). With no
//! blend selected it sets the options new blends start with.
//!
//! Fields: `spacing` (`smooth` | `steps` | `distance`), `steps` (1..1000), `distance` (pt),
//! `orientation` (`page` | `path`), `preview`, and `__target` (`blend` or `defaults`, from
//! `object.blend.info`).
use egui::{CornerRadius, Sense, Stroke, Vec2, pos2, vec2};
use serde_json::{Value, json};
use super::{DialogSpec, form, run_and_close};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Blend Options.
pub const KIND: &str = "blendOptions";
const CMD: &str = "object.blend.options";
/// Width of the label column.
const LABEL_W: f32 = 82.0;
/// Spacing modes: (value, label).
const SPACING: [(&str, &str); 3] = [("smooth", "Smooth Color"), ("steps", "Specified Steps"), ("distance", "Specified Distance")];
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| "Blend Options".into(), body, confirm, preview: true, min_width: 300.0, ..DialogSpec::FORM };
/// Open Blend Options on the selected blend's options (else those new blends start with).
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let mut fields = app.session.execute("object.blend.info", &json!({})).map_err(|e| e.to_string())?;
let target = fields["target"].clone();
let Some(o) = fields.as_object_mut() else { return Err("no blend options".into()) };
o.retain(|k, _| matches!(k.as_str(), "spacing" | "steps" | "distance" | "orientation"));
o.insert("__target".into(), target);
o.insert("preview".into(), json!(true));
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(Value::Null)
}
/// Does the dialog edit a blend (rather than the options new blends start with)?
fn on_blend(d: &Dialog) -> bool {
d.str("__target") == "blend"
}
/// `object.blend.options` parameters from the fields.
fn params(d: &Dialog) -> Value {
let mut p = json!({"spacing": d.str("spacing"), "orientation": d.str("orientation")});
match d.str("spacing").as_str() {
"steps" => p["steps"] = json!(d.f64("steps", 5.0).round().clamp(1.0, 1000.0)),
"distance" => p["distance"] = json!(d.f64("distance", 10.0)),
_ => {}
}
p
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let unit = app.session.general_unit();
let mode = d.str("spacing");
let shown = SPACING.iter().find(|(v, _)| *v == mode).map_or(SPACING[0].1, |(_, l)| l);
let labels = SPACING.map(|(_, l)| l);
widgets::label_row(ui, "Spacing:", LABEL_W, |ui| {
if let Some((value, _)) = widgets::dropdown(ui, "blend-spacing", shown, &labels, 150.0).and_then(|i| SPACING.get(i)) {
d.fields.insert("spacing".into(), json!(value));
}
ui.add_space(6.0);
match mode.as_str() {
"distance" => {
form::length(ui, d, "distance", unit, 72.0);
}
// Smooth Color picks the count itself: the field shows the steps, disabled.
m => {
let steps = d.f64("steps", 5.0);
ui.add_enabled_ui(m == "steps", |ui| {
if let Some(n) = widgets::plain_field(ui, "blend-steps", steps, "", 0, 72.0) {
d.fields.insert("steps".into(), json!(n.round().clamp(1.0, 1000.0)));
}
});
}
}
});
ui.add_space(6.0);
widgets::label_row(ui, "Orientation:", LABEL_W, |ui| {
let path = d.str("orientation") == "path";
for (value, along, tip) in [("page", false, "Align to Page"), ("path", true, "Align to Path")] {
if orientation_button(ui, along, path == along, tip) {
d.fields.insert("orientation".into(), json!(value));
}
}
});
if on_blend(d) {
let p = params(d);
form::preview(app, ui, d, "Blend Options", CMD, p);
}
false
}
/// An orientation toggle drawn in code: three bars on a curve, upright (Align to Page) or square
/// to the curve (Align to Path), in a pressed well when `selected`. Returns clicked.
fn orientation_button(ui: &mut egui::Ui, along: bool, selected: bool, tip: &str) -> bool {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(28.0), Sense::click());
let bg = if selected {
t.tool_active
} else if resp.hovered() {
t.hover
} else {
egui::Color32::TRANSPARENT
};
ui.painter().rect_filled(rect, CornerRadius::same(3), bg);
let color = if selected { t.text } else { t.icon };
// An arch through the button: y = c + a·x² with x in -1..1.
let c = rect.center() + vec2(0.0, -2.0);
let at = |x: f32| pos2(c.x + 9.0 * x, c.y + 6.0 * x * x);
let curve: Vec<egui::Pos2> = (0..=12).map(|i| at(-1.0 + i as f32 / 6.0)).collect();
ui.painter().add(egui::Shape::line(curve, Stroke::new(1.0, color)));
for x in [-0.8f32, 0.0, 0.8] {
let p = at(x);
// Normal to the arch at x (pointing up), or straight up.
let n = if along { vec2(-12.0 * x / 9.0, -1.0).normalized() } else { vec2(0.0, -1.0) };
ui.painter().line_segment([p, p + n * 7.0], Stroke::new(2.0, color));
}
resp.on_hover_text(tip).clicked()
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
if on_blend(d) { form::commit_preview(app, CMD, params(d)) } else { run_and_close(app, CMD, params(d)) }
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_doc::NodeKind;
use vectorcraft_doc::live::{BlendOrientation, BlendSpacing};
use vectorcraft_engine::Session;
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx()));
out.textures_delta.clear();
}
fn spec(app: &VectorcraftApp, id: u64) -> vectorcraft_doc::BlendSpec {
match &app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
NodeKind::Blend { spec, .. } => spec.clone(),
_ => panic!("not a blend"),
}
}
#[test]
fn opens_on_the_blend_previews_and_keeps_smooth_color() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
app.run("shape.rectangle", json!({"x": 200, "y": 0, "width": 20, "height": 20})).unwrap();
app.run("select.all", json!({})).unwrap();
let id = app.run("object.blend.make", json!({"smooth": true})).unwrap()["id"].as_u64().unwrap();
// The menu item opens the dialog on the blend's own options.
crate::menus::invoke(&mut app, CMD, json!({}));
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND));
frame(&mut app);
assert_eq!(app.ui.dialog.as_ref().unwrap().str("spacing"), "smooth");
// OK without changes keeps Smooth Color (it used to become 5 steps).
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(spec(&app, id).spacing, BlendSpacing::SmoothColor);
// Specified Steps and Align to Path preview on the blend; Cancel rolls them back.
app.run("ui.blendOptions", json!({})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("spacing".into(), json!("steps"));
d.fields.insert("steps".into(), json!(3));
d.fields.insert("orientation".into(), json!("path"));
frame(&mut app);
assert_eq!(spec(&app, id).spacing, BlendSpacing::Steps(3), "previewed");
crate::dialogs::cancel(&mut app);
assert_eq!(spec(&app, id).spacing, BlendSpacing::SmoothColor);
// OK keeps them as one undo step.
app.run("ui.blendOptions", json!({})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("spacing".into(), json!("distance"));
d.fields.insert("distance".into(), json!(25));
frame(&mut app);
let depth = app.session.doc().unwrap().history.undo.len();
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(spec(&app, id).spacing, BlendSpacing::Distance(25.0));
assert_eq!(app.session.doc().unwrap().history.undo.len(), depth + 1);
}
#[test]
fn with_nothing_selected_it_sets_what_new_blends_start_with() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app.run("ui.blendOptions", json!({})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("__target"), "defaults");
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("spacing".into(), json!("steps"));
d.fields.insert("steps".into(), json!(7));
d.fields.insert("orientation".into(), json!("path"));
frame(&mut app);
crate::dialogs::confirm(&mut app).unwrap();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
app.run("shape.rectangle", json!({"x": 200, "y": 0, "width": 20, "height": 20})).unwrap();
app.run("select.all", json!({})).unwrap();
let id = app.run("object.blend.make", json!({})).unwrap()["id"].as_u64().unwrap();
let s = spec(&app, id);
assert_eq!((s.spacing, s.orientation), (BlendSpacing::Steps(7), BlendOrientation::AlignToPath));
}
}
+2
View File
@@ -9,6 +9,7 @@
mod about;
mod all_tools;
mod artboard_options;
pub mod blend_options;
pub mod color_balance;
pub mod color_guide_options;
mod color_picker;
@@ -257,6 +258,7 @@ registry! {
Envelope: [envelope::WARP, envelope::MESH, envelope::OPTIONS] => envelope::SPEC,
LiquifyOptions: [liquify::KIND] => liquify::SPEC,
PerspectiveGridPresets: [perspective_presets::KIND] => perspective_presets::SPEC,
BlendOptions: [blend_options::KIND] => blend_options::SPEC,
}
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
+7
View File
@@ -56,6 +56,13 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
}
return;
}
// The Blend tool's double-click and Alt-click: Blend Options.
super::blend_options::KIND => {
if let Err(e) = super::blend_options::open(app) {
app.status(e);
}
return;
}
// Double-clicking a slice with the Slice Selection tool: Slice Options.
super::slices::OPTIONS => {
if let Err(e) = super::slices::open_options(app) {
+15 -1
View File
@@ -655,6 +655,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{} apply the 5. saved 3-point perspective grid preset (perspective.grid.preset)",
),
(
"ui.blendOptions",
"Blend Options…",
"",
"{} open Blend Options (Object › Blend › Blend Options…, the Blend tool's double-click, Alt-click and toolbar button; dialog `blendOptions`: spacing smooth|steps|distance, steps, distance (pt), orientation page|path, preview) on the selected blend's options, previewed live: OK runs object.blend.options as one undo step; with no blend selected it sets what new blends start with",
),
];
/// Handle a UI command. `None` = not a UI command (the engine handles it).
@@ -1081,6 +1087,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
Some(name) => app.run("perspective.grid.preset", json!({ "name": name })),
None => Err("no such perspective grid preset".into()),
},
"ui.blendOptions" => crate::dialogs::blend_options::open(app),
_ => return None,
};
Some(r)
@@ -1649,7 +1656,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
c("Make", "object.blend.make"),
c("Release", "object.blend.release"),
Sep,
cp("Blend Options…", "object.blend.options", json!({"steps": 5})),
c("Blend Options…", "object.blend.options"),
Sep,
c("Expand", "object.blend.expand"),
Sep,
@@ -2252,6 +2259,13 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
}
return;
}
// Blend Options…: its dialog, on the selected blend's options.
if id == "object.blend.options" && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::blend_options::open(app) {
app.status(e);
}
return;
}
// Expand…: its dialog.
if id == "object.expand" && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::expand::open(app) {
+6 -1
View File
@@ -201,7 +201,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
/// Open a tool's options (`tool.options`, a double-click on its button): the Gradient tool's are
/// the Gradient panel, the Eyedropper's the Eyedropper Options dialog, a Liquify tool's its Tool
/// Options dialog; the Print Tiling tool's resets the print tiling.
/// Options dialog, the Blend tool's Blend
/// Options; the Print Tiling tool's resets the print tiling.
pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::Value, String> {
match tool {
"gradient" if app.ui.open_panel.as_deref() == Some("gradient") => Ok(json!({ "open": "gradient" })),
@@ -210,6 +211,10 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
crate::dialogs::eyedropper::open(app);
Ok(json!({ "dialog": crate::dialogs::eyedropper::KIND }))
}
"blend" => {
crate::dialogs::blend_options::open(app)?;
Ok(json!({ "dialog": crate::dialogs::blend_options::KIND }))
}
// A double click on the Print Tiling tool puts the pages back where the placement puts them.
"printTiling" => app.run("print.tiling.set", json!({ "reset": true })),
// The Liquify tools: their Tool Options (the Global Brush Dimensions and the tool's own).
+7
View File
@@ -491,3 +491,10 @@ edited), whose OK runs `perspective.presets.save` and comes back; Delete, Import
`perspective.presets.delete`, `import` and `export`. View → Perspective Grid → One/Two/Three Point Perspective list
that type's built-in views and the saved presets (`ui.perspectiveUserPreset<type>.<n>`, hidden while empty). Opening a
`.vcperspective` file with `app.open` imports its presets.
Blend Options (Object › Blend › Blend Options…, `ui.blendOptions`, the Blend tool's double-click, Alt-click and
toolbar button) opens the `blendOptions` dialog on the selected blend's options: `spacing` (`smooth` | `steps` |
`distance`), `steps`, `distance` (pt), `orientation` (`page` | `path`) and `preview`; it previews live and
`ui.dialog.confirm` keeps the change as one undo step. With no blend selected (`__target: "defaults"`) OK sets what
new blends start with. The Blend tool (`ui.tool.select {tool: "blend"}`): `ui.pointer` down on an object, then on
another, blends them; down on an anchor point blends from that point; each further object clicked joins the blend.
+14
View File
@@ -1850,3 +1850,17 @@ copies). Object › Transform › Transform Again repeats the last perspective m
{"name":"run_command","arguments":{"command":"perspective.transform","params":{"matrix":[1.5,0,0,1.5,0,0],"copy":true}}}
{"name":"run_command","arguments":{"command":"object.transformAgain","params":{}}}
```
## Blends
`object.blend.make` blends the selected objects (or `ids`) into a live blend. Spacing and orientation default to
what `object.blend.options` set with no blend selected (`object.blend.info` reads them: `target: "defaults"`),
else Smooth Color and Align to Page. `starts` gives, per object, the anchor of its first subpath the blend starts
from (as the Blend tool's clicks on anchor points do; an open path's last anchor runs it the other way). A blend
among the objects takes the others in as more key objects, keeping its options, name and transparency:
```json
{"name":"run_command","arguments":{"command":"object.blend.make","params":{"ids":[12,15],"starts":[0,2],"steps":8}}}
{"name":"run_command","arguments":{"command":"object.blend.make","params":{"ids":[20,31]}}}
{"name":"run_command","arguments":{"command":"object.blend.info","params":{}}}
```