From c5985b3f47c85b5cfbeacd07fa86165732190383 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 09:42:10 +0700 Subject: [PATCH] M8.18: Blend Options dialog and the Blend tool's anchor targeting Blend Options is a dedicated dialog (Spacing dropdown with its value, Align to Page / Align to Path buttons, live preview) opened on the selected blend's own options, so OK no longer turns Smooth Color into 5 steps; with nothing selected it sets what new blends start with (object.blend.info reads them). The Blend tool blends from clicked anchor points (blend spec `starts`), adds keys to an existing blend instead of nesting it, opens Blend Options on double-click, Alt-click and its toolbar button, and has its own cursors and hint. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/blend.rs | 528 ++++++++++++++++++++ crates/doc/src/lib.rs | 1 + crates/doc/src/live.rs | 401 ++------------- crates/engine/src/cmd/live.rs | 153 +++++- crates/engine/src/lib.rs | 9 + crates/engine/src/tests_blendopts.rs | 155 ++++++ crates/engine/tests/command_sweep.rs | 3 + crates/tools/src/lib.rs | 6 + crates/tools/src/meshblend.rs | 150 +++++- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/chrome.rs | 8 + crates/ui-egui/src/cursors.rs | 16 + crates/ui-egui/src/dialogs/blend_options.rs | 213 ++++++++ crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/dialogs/tools.rs | 7 + crates/ui-egui/src/menus.rs | 16 +- crates/ui-egui/src/toolbar.rs | 7 +- docs/control-protocol.md | 7 + docs/mcp.md | 14 + 19 files changed, 1290 insertions(+), 407 deletions(-) create mode 100644 crates/doc/src/blend.rs create mode 100644 crates/engine/src/tests_blendopts.rs create mode 100644 crates/ui-egui/src/dialogs/blend_options.rs diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs new file mode 100644 index 00000000..56037596 --- /dev/null +++ b/crates/doc/src/blend.rs @@ -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 { + 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, + b: Vec, +} + +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, Option)) -> Self { + let n = a.subpaths.len().max(b.subpaths.len()); + let ca = a.bounds().map(|r| r.center()).unwrap_or_default(); + let cb = b.bounds().map(|r| r.center()).unwrap_or_default(); + let degenerate = |like: &SubPath, c: Point| SubPath::new(vec![Anchor::corner(c); like.anchors.len().max(1)], like.closed); + let (mut va, mut vb) = (Vec::with_capacity(n), Vec::with_capacity(n)); + for i in 0..n { + let (mut sa, mut sb) = match (a.subpaths.get(i), b.subpaths.get(i)) { + (Some(x), Some(y)) => (x.clone(), y.clone()), + (Some(x), None) => (x.clone(), degenerate(x, cb)), + (None, Some(y)) => (degenerate(y, ca), y.clone()), + (None, None) => continue, + }; + 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, 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, 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::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 { + 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, + cum: Vec, +} + +impl Spine { + pub fn new(path: &PathData) -> Option { + 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], spec: &BlendSpec) -> Vec { + 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)); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b7d6b94e..f7e6d979 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -7,6 +7,7 @@ pub mod appearance; pub mod assets; +pub mod blend; pub mod clipnest; pub mod graph; pub mod hit; diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 50763981..878526bb 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -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, + /// 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, + /// 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>, +} + +impl BlendSpec { + /// The start anchor of key `i` (see [`Self::starts`]). + pub fn start(&self, i: usize) -> Option { + 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 { - 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 { - 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, - cum: Vec, -} - -impl Spine { - pub fn new(path: &PathData) -> Option { - 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], spec: &BlendSpec) -> Vec { - 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 { +pub(crate) fn cumulative_lengths(pts: &[Point]) -> Vec { 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 { +pub(crate) fn flatten_subpath(sp: &SubPath, tol: f64) -> Vec { let mut bp = BezPath::new(); sp.to_bezpath_into(&mut bp); let mut pts: Vec = Vec::new(); diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index fa52015b..295350ee 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -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 { "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 { "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 { 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 { 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> { + 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 { + 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>, starts: &[(NodeId, u32)]) -> (Vec>, Vec>) { + 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 { 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>, &mu ok() } +/// The first selected blend: its id, spec and key ids. +fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec)> { + 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 { 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 { }) } +/// 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 { + 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::>()); + v["keys"] = json!(keys.iter().map(|k| k.0).collect::>()); + 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 { 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 { fn blend_reverse_stack(s: &mut Session, _: &Value) -> Result { 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 { } /// `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 { 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 { 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) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 14313d4a..2d308bc3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -494,6 +494,12 @@ pub struct Prefs { /// `perspective.presets.*` edit it. #[serde(skip_serializing_if = "Vec::is_empty")] pub perspective_presets: Vec, + /// 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, } 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; diff --git a/crates/engine/src/tests_blendopts.rs b/crates/engine/src/tests_blendopts.rs new file mode 100644 index 00000000..7e08912c --- /dev/null +++ b/crates/engine/src/tests_blendopts.rs @@ -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, 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::>()})).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()); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 2cf4a5dc..b19bdaed 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -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 { diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 26c61758..9931dcc4 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -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. diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index 7bfd167c..7670f31e 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -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); + #[derive(Default)] pub struct BlendTool { - first: Option, + first: Option, + /// 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 { + 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 { + 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 { + 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 { + 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 { - 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 { - 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(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b855523f..12dad272 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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, } } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 9fffe070..857cca15 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -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, }) } diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index 7027e130..cf8f272a 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -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; diff --git a/crates/ui-egui/src/dialogs/blend_options.rs b/crates/ui-egui/src/dialogs/blend_options.rs new file mode 100644 index 00000000..83af48e2 --- /dev/null +++ b/crates/ui-egui/src/dialogs/blend_options.rs @@ -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 { + 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 = (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 { + 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)); + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 5c714c8b..0d43061f 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -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). diff --git a/crates/ui-egui/src/dialogs/tools.rs b/crates/ui-egui/src/dialogs/tools.rs index a56e35fd..32ebfc19 100644 --- a/crates/ui-egui/src/dialogs/tools.rs +++ b/crates/ui-egui/src/dialogs/tools.rs @@ -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) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 51cb4ec7..573b08ad 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 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)> { 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) { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 44a2ca21..52536942 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -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 { 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 { + 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). diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 49e0626d..20d93c56 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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.`, 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. diff --git a/docs/mcp.md b/docs/mcp.md index 9ac2805d..2a385e8d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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":{}}} +```