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 <noreply@anthropic.com>
This commit is contained in:
parent
5157dc87b8
commit
c5985b3f47
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
pub mod appearance;
|
||||
pub mod assets;
|
||||
pub mod blend;
|
||||
pub mod clipnest;
|
||||
pub mod graph;
|
||||
pub mod hit;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
docs/mcp.md
14
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":{}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue