diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 56037596..489b5286 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -5,11 +5,16 @@ //! winding; the Blend tool's clicks on anchor points choose where each path starts. Paints, //! strokes and opacity interpolate. A blend prepares each pair of keys once ([`Lerp`]) and only //! interpolates per step. +//! +//! The spine ([`blend_spine`]) is the straight lines between the key centres until it is edited +//! or replaced; then a path whose anchors the keys sit on (`key_anchors`). Steps follow it by arc +//! length; Align to Path turns them to its direction. use std::sync::Arc; use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint}; -use vectorcraft_geom::{Affine, Anchor, FillRule, PathData, Point, SubPath}; +use vectorcraft_geom::kurbo::ParamCurveArclen; +use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, SubPath}; use crate::appearance::{Appearance, AppearanceItem}; use crate::live::{BlendOrientation, BlendSpacing, BlendSpec}; @@ -434,36 +439,225 @@ pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) -> // Spine // ===================================================================================== -/// A spine flattened to a polyline with cumulative lengths. +/// A spine (the first subpath of a path) flattened to a polyline, with cumulative lengths and the +/// arc length at each of its anchors. pub struct Spine { pts: Vec, cum: Vec, + anchors: Vec, + closed: bool, } impl Spine { pub fn new(path: &PathData) -> Option { let sp = path.subpaths.first()?; - let pts = crate::live::flatten_subpath(sp, 0.1); + let mut pts: Vec = Vec::new(); + let mut cum: Vec = Vec::new(); + let mut anchors = Vec::with_capacity(sp.anchors.len()); + for i in 0..sp.segment_count() { + anchors.push(cum.last().copied().unwrap_or(0.0)); + let c = sp.segment(i); + let mut bp = BezPath::new(); + bp.move_to(c.p0); + if sp.segment_is_line(i) { + bp.line_to(c.p3); + } else { + bp.curve_to(c.p1, c.p2, c.p3); + } + vectorcraft_geom::kurbo::flatten(bp.iter(), 0.1, |el| { + if let vectorcraft_geom::PathEl::MoveTo(p) | vectorcraft_geom::PathEl::LineTo(p) = el + && p.x.is_finite() + && p.y.is_finite() + && pts.last().is_none_or(|q| q.distance(p) > 1e-9) + { + let s = match (pts.last(), cum.last()) { + (Some(q), Some(l)) => l + q.distance(p), + _ => 0.0, + }; + pts.push(p); + cum.push(s); + } + }); + } + if !sp.closed { + anchors.push(cum.last().copied().unwrap_or(0.0)); + } if pts.len() < 2 { return None; } - Some(Self { cum: crate::live::cumulative_lengths(&pts), pts }) + Some(Self { pts, cum, anchors, closed: sp.closed }) } pub fn length(&self) -> f64 { *self.cum.last().unwrap_or(&0.0) } + /// Arc length at anchor `i`. + pub fn anchor_length(&self, i: usize) -> Option { + self.anchors.get(i).copied() + } /// 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; + self.at_length(f.clamp(0.0, 1.0) * self.length()) + } + /// Point and tangent angle (radians) at arc length `s` (clamped to the spine). + pub fn at_length(&self, s: f64) -> (Point, f64) { + let last = self.pts.len().saturating_sub(2); + let s = if s.is_finite() { s.clamp(0.0, self.length()) } else { 0.0 }; 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), + Ok(i) => i.min(last), + Err(i) => i.saturating_sub(1).min(last), }; - 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)) + let (Some(p0), Some(p1), Some(c0), Some(c1)) = (self.pts.get(i), self.pts.get(i + 1), self.cum.get(i), self.cum.get(i + 1)) else { + return (self.pts.first().copied().unwrap_or_default(), 0.0); + }; + let u = ((s - c0) / (c1 - c0).max(1e-12)).clamp(0.0, 1.0); + let d = *p1 - *p0; + (p0.lerp(*p1, u), d.y.atan2(d.x)) + } +} + +/// The anchor of the first subpath each key sits on, when they are all valid for `n` anchors. +fn pinned(spec: &BlendSpec, keys: usize, n: usize) -> Option> { + (spec.key_anchors.len() == keys && keys >= 2 && spec.key_anchors.iter().all(|a| (*a as usize) < n)) + .then(|| spec.key_anchors.iter().map(|a| *a as usize).collect()) +} + +/// The spine of a blend of `keys` and the anchor of it each key sits on: +/// - without a stored spine: the straight lines between the key centres; +/// - a pinned spine (`key_anchors`): the stored spine's first subpath with each key's anchor (and +/// its handles) moved onto the key's centre, so moving a key moves its end of the spine; +/// - a spine from Replace Spine: its first subpath, `None` for the anchors (the keys spread +/// evenly along it by arc length). +pub fn blend_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Option>)> { + if keys.len() < 2 { + return None; + } + let Some(stored) = &spec.spine else { + let centers: Vec = keys.iter().map(|k| center_of(k)).collect(); + return Some((PathData::single(SubPath::polyline(¢ers, false)), Some((0..keys.len()).collect()))); + }; + let mut first = stored.subpaths.first()?.clone(); + let Some(ka) = pinned(spec, keys.len(), first.anchors.len()) else { return Some((PathData::single(first), None)) }; + for (k, &ai) in keys.iter().zip(&ka) { + let c = center_of(k); + if let Some(a) = first.anchors.get_mut(ai) { + let d = c - a.p; + a.translate(d); + } + } + Some((PathData::single(first), Some(ka))) +} + +/// [`blend_spine`] with every key on an anchor: a spine from Replace Spine gets anchors where its +/// keys sit (by arc length), so editing it keeps them in place. +pub fn pin_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Vec)> { + let (mut path, anchors) = blend_spine(keys, spec)?; + if let Some(a) = anchors { + return Some((path, a)); + } + let k = keys.len(); + let total = Spine::new(&path)?.length(); + let closed = path.subpaths.first().is_some_and(|s| s.closed); + let mut out = vec![0usize]; + for i in 1..k.saturating_sub(1) { + let s = total * i as f64 / (k - 1) as f64; + out.push(anchor_at_length(&mut path, s)?); + } + let last = path.subpaths.first().map_or(0, |sp| if closed { 0 } else { sp.anchors.len().saturating_sub(1) }); + out.push(last); + Some((path, out)) +} + +/// The anchor of the first subpath at arc length `s`: an existing one within a hundredth of a +/// point, else a new one splitting the segment there. +fn anchor_at_length(path: &mut PathData, s: f64) -> Option { + let spine = Spine::new(path)?; + let sp = path.subpaths.first_mut()?; + let n = sp.segment_count(); + let seg = (0..n).rev().find(|i| spine.anchor_length(*i).is_some_and(|l| l <= s)).unwrap_or(0); + let from = spine.anchor_length(seg).unwrap_or(0.0); + let to = spine.anchor_length(seg + 1).unwrap_or(spine.length()); + if s - from < 0.01 { + return Some(seg); + } + if to - s < 0.01 { + return Some((seg + 1) % sp.anchors.len().max(1)); + } + let c = sp.segment(seg); + // The flattened length and the curve's own differ slightly: scale into the curve's. + let arc = c.arclen(1e-4).max(1e-12); + let t = c.inv_arclen((s - from) / (to - from).max(1e-12) * arc, 1e-4).clamp(1e-4, 1.0 - 1e-4); + Some(sp.insert_anchor(seg, t)) +} + +/// Where a blend's keys and steps go along its spine (see [`blend_spine`]). +struct Rail { + spine: Spine, + /// Arc length of each key. + stops: Vec, + centers: Vec, + rotate: bool, +} + +impl Rail { + /// `None` when nothing moves: no stored spine and the steps keep the page's orientation. + fn new(keys: &[Arc], spec: &BlendSpec) -> Option { + let rotate = spec.orientation == BlendOrientation::AlignToPath; + if spec.spine.is_none() && !rotate { + return None; + } + let (path, anchors) = blend_spine(keys, spec)?; + let spine = Spine::new(&path)?; + let total = spine.length(); + let k = keys.len(); + let stops = match anchors { + Some(ka) => { + let mut v: Vec = Vec::with_capacity(k); + for a in ka { + let mut s = spine.anchor_length(a).unwrap_or(0.0); + // On a closed spine the key back at the start sits at its far end. + if spine.closed + && let Some(prev) = v.last() + && s <= *prev + 1e-9 + { + s += total; + } + v.push(s); + } + v + } + None => (0..k).map(|i| total * i as f64 / (k - 1).max(1) as f64).collect(), + }; + Some(Self { spine, stops, centers: keys.iter().map(|k| center_of(k)).collect(), rotate }) + } + + /// Arc length between keys `i` and `i + 1`. + fn len(&self, i: usize) -> f64 { + match (self.stops.get(i), self.stops.get(i + 1)) { + (Some(a), Some(b)) => (b - a).abs(), + _ => 0.0, + } + } + + /// Move `n`, the object at `t` between keys `i` and `i + 1` (a key at `t` = 0), onto the + /// spine: by how far the spine there is from the straight line between the key centres (so + /// steps keep their own interpolated position on a straight spine), turned to the spine's + /// direction with Align to Path. + fn place(&self, n: &mut Node, i: usize, t: f64) { + let (Some(&s0), Some(&c0)) = (self.stops.get(i), self.centers.get(i)) else { return }; + let (s, base) = match (self.stops.get(i + 1), self.centers.get(i + 1)) { + (Some(&s1), Some(&c1)) if t > 0.0 => (lerp_f64(s0, s1, t), c0.lerp(c1, t)), + _ => (s0, c0), + }; + let (p, ang) = self.spine.at_length(if self.spine.closed { s.rem_euclid(self.spine.length().max(1e-12)) } else { s }); + let off = p - base; + let mut m = if off.hypot() > 1e-9 { Affine::translate(off) } else { Affine::IDENTITY }; + if self.rotate && ang.abs() > 1e-12 { + let c = (center_of(n) + off).to_vec2(); + m = Affine::translate(c) * Affine::rotate(ang) * Affine::translate(-c) * m; + } + if m != Affine::IDENTITY { + n.transform(m, false); + } } } @@ -477,29 +671,25 @@ pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { 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 rail = Rail::new(keys, spec); 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 mut key = (**a).clone(); + if let Some(r) = &rail { + r.place(&mut key, i, 0.0); + } + out.push(key); 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 len = rail.as_ref().map_or_else(|| center_of(a).distance(center_of(b)), |r| r.len(i)); 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)); + let mut s = lerp.at(t); + if let Some(r) = &rail { + r.place(&mut s, i, t); + } + out.push(s); } } out @@ -525,4 +715,34 @@ mod tests { 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)); } + + fn key(x: f64) -> Arc { + Arc::new(Node::path(NodeId(1), vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default())) + } + + #[test] + fn a_replaced_spine_gets_anchors_where_its_keys_sit() { + let keys = vec![key(0.0), key(100.0), key(200.0)]; + let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 50.0), Point::new(300.0, 50.0)], false)); + let spec = BlendSpec { spine: Some(line), ..Default::default() }; + let (path, anchors) = pin_spine(&keys, &spec).unwrap(); + assert_eq!(anchors, vec![0, 1, 2]); + assert!((path.subpaths[0].anchors[1].p.x - 150.0).abs() < 1e-6, "the middle key sits halfway"); + // Pinned, each key's anchor follows it. + let pinned = BlendSpec { spine: Some(path), key_anchors: vec![0, 1, 2], ..Default::default() }; + let (moved, _) = blend_spine(&[key(0.0), key(100.0), key(300.0)], &pinned).unwrap(); + assert_eq!(moved.subpaths[0].anchors[2].p, Point::new(305.0, 5.0)); + } + + #[test] + fn steps_follow_a_bent_spine() { + let keys = vec![key(0.0), key(100.0)]; + // A spine through the key centres bent up through (55, -95). + let spine = PathData::single(SubPath::polyline(&[Point::new(5.0, 5.0), Point::new(55.0, -95.0), Point::new(105.0, 5.0)], false)); + let spec = BlendSpec { spacing: BlendSpacing::Steps(1), spine: Some(spine), key_anchors: vec![0, 2], ..Default::default() }; + let out = blend_expand(&keys, &spec); + let mid = out[1].geometric_bounds().unwrap().center(); + assert!(mid.distance(Point::new(55.0, -95.0)) < 1e-6, "{mid:?}"); + assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put"); + } } diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index bacce0bb..dddc9d41 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -107,6 +107,11 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec hit_children(c, p, opt, chain), + // A blend's key objects first (Direct and Group Selection pick them); its steps hit + // as the blend. + NodeKind::Blend { .. } => { + hit_children(c, p, opt, chain).or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind })) + } _ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind }), }; if hit.is_some() { diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 878526bb..fe9f7b5b 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -256,7 +256,9 @@ pub fn default_fidelity() -> f64 { // Blends (evaluation in `crate::blend`) // ===================================================================================== -pub use crate::blend::{Spine, blend_expand, blend_step_count, lerp_appearance, lerp_color, lerp_node, lerp_paint, lerp_path}; +pub use crate::blend::{ + Spine, blend_expand, blend_spine, blend_step_count, lerp_appearance, lerp_color, lerp_node, lerp_paint, lerp_path, pin_spine, +}; // ===================================================================================== // Warp maps (shared with vectorcraft-effects' Warp effects) @@ -372,7 +374,7 @@ fn poly_len(c: &CubicBez) -> f64 { } /// Running length along a polyline: `0, |p0p1|, |p0p1| + |p1p2|, …` (one entry per point). -pub(crate) fn cumulative_lengths(pts: &[Point]) -> Vec { +fn cumulative_lengths(pts: &[Point]) -> Vec { let mut total = 0.0; let mut cum = Vec::with_capacity(pts.len()); cum.push(0.0); @@ -433,7 +435,7 @@ pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point // Coons patches from outlines // ===================================================================================== -pub(crate) fn flatten_subpath(sp: &SubPath, tol: f64) -> Vec { +fn flatten_subpath(sp: &SubPath, tol: f64) -> Vec { let mut bp = BezPath::new(); sp.to_bezpath_into(&mut bp); let mut pts: Vec = Vec::new(); diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 295350ee..d10c602b 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Release", ["Object", "Blend"], Some("Cmd+Alt+Shift+B"), - "{} release the selected blends, keeping the key objects → {ids}", + "{} release the selected blends: the key objects come back and the spine stays as a path with no fill or stroke, below them → {ids (the keys), spines: [ids]}", has_blend, blend_release ), @@ -81,10 +81,37 @@ pub fn specs() -> Vec { "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]}", + "{} 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], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", always, blend_info ), + cmd!( + "object.blend.spine.moveAnchor", + "Move Spine Point", + [], + None, + "{id? (default: the selected blend), anchor: index, x, y, handle?: in|out, independent?: bool} move a point of the blend's spine (its handles along), or with `handle` place that handle's end at (x, y) (a smooth point keeps the other handle in line unless `independent`). A key object sitting on the point moves with it. The first edit turns the straight spine into a path", + has_doc, + spine_move_anchor + ), + cmd!( + "object.blend.spine.addAnchor", + "Add Spine Point", + [], + None, + "{id? (default: the selected blend), x, y} add a point to the blend's spine where it passes nearest (x, y) → {anchor}", + has_doc, + spine_add_anchor + ), + cmd!( + "object.blend.spine.removeAnchor", + "Delete Spine Point", + [], + None, + "{id? (default: the selected blend), anchor: index} delete a point of the blend's spine that no key object sits on", + has_doc, + spine_remove_anchor + ), cmd!( "object.blend.reverseFrontToBack", "Reverse Front to Back", @@ -445,6 +472,30 @@ fn merge_keys(nodes: Vec>, starts: &[(NodeId, u32)]) -> (Vec (keys, st) } +/// The spine of a blend of `keys` made by giving blend `old` (spec `old_spec`) more keys: its +/// spine pinned, with a straight segment to each new key before or after its own (a closed spine +/// gives way to the straight lines between the keys). +fn extend_spine(spec: &mut BlendSpec, old: &[Arc], old_spec: &BlendSpec, keys: &[Arc]) { + if old_spec.spine.is_none() { + return; + } + let Some((mut path, anchors)) = live::pin_spine(old, old_spec) else { return }; + let Some(first) = old.first().and_then(|f| keys.iter().position(|k| k.id == f.id)) else { return }; + let Some(sp) = path.subpaths.first_mut().filter(|sp| !sp.closed) else { return }; + let center = |k: &Arc| vectorcraft_geom::Anchor::corner(k.geometric_bounds().map(|b| b.center()).unwrap_or_default()); + let before: Vec<_> = keys.iter().take(first).map(center).collect(); + let after: Vec<_> = keys.iter().skip(first + old.len()).map(center).collect(); + let shift = before.len(); + sp.anchors.splice(0..0, before); + let n = sp.anchors.len(); + sp.anchors.extend(after); + let mut ka: Vec = (0..shift).collect(); + ka.extend(anchors.iter().map(|a| a + shift)); + ka.extend(n..sp.anchors.len()); + spec.key_anchors = ka.iter().map(|a| u32::try_from(*a).unwrap_or(u32::MAX)).collect(); + spec.spine = Some(path); +} + fn blend_make(s: &mut Session, p: &Value) -> Result { const C: &str = "object.blend.make"; let roots = roots_param(s, p)?; @@ -464,12 +515,12 @@ fn blend_make(s: &mut Session, p: &Value) -> Result { 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, - }; + let mut spec = BlendSpec { spacing, orientation, starts, ..Default::default() }; + if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind { + extend_spine(&mut spec, old, old_spec, &children); + } n.id = id; - n.kind = NodeKind::Blend { children, spec: BlendSpec { spacing, orientation, spine, starts, ..Default::default() } }; + n.kind = NodeKind::Blend { children, spec }; n }) })?; @@ -478,17 +529,126 @@ fn blend_make(s: &mut Session, p: &Value) -> Result { fn blend_release(s: &mut Session, _: &Value) -> Result { let blends = selected_of(s, is_blend); - let ids = s.edit("Release Blend", |d, sel| { - let mut out = vec![]; + let (ids, spines) = s.edit("Release Blend", |d, sel| { + let (mut keys, mut spines) = (vec![], vec![]); for b in &blends { let Some(n) = d.node(*b).cloned() else { continue }; - let keys: Vec = n.children().into_iter().flatten().map(|c| (**c).clone()).collect(); - out.extend(replace_with(d, *b, keys)?); + let NodeKind::Blend { children, spec } = &n.kind else { continue }; + // The spine stays behind as a path that paints nothing, below the keys. + let mut nodes = vec![]; + if let Some((path, _)) = live::blend_spine(children, spec) { + let id = d.alloc_id(); + spines.push(id); + nodes.push(Node::path(id, path, Appearance::basic(Paint::None, Paint::None, 0.0))); + } + keys.extend(children.iter().map(|c| c.id)); + nodes.extend(children.iter().map(|c| (**c).clone())); + replace_with(d, *b, nodes)?; } - sel.set(out.iter().copied()); - Ok(out) + sel.set(spines.iter().chain(&keys).copied()); + Ok((keys, spines)) })?; - Ok(ids_json(&ids)) + let mut r = ids_json(&ids); + r["spines"] = json!(spines.iter().map(|i| i.0).collect::>()); + Ok(r) +} + +/// The blend `id` (default: the first selected), its spine pinned to its keys (see +/// [`live::pin_spine`]) and given to `f` with the key anchors and the keys; the result is stored +/// as the blend's spine, one undo step. +fn edit_spine( + s: &mut Session, + p: &Value, + cmd: &str, + label: &str, + f: impl FnOnce(&mut vectorcraft_geom::SubPath, &mut Vec, &mut [Arc]) -> Result, +) -> Result { + let id = match id_param(p, "id") { + Some(id) => id, + None => *selected_of(s, is_blend).first().ok_or_else(|| bad(cmd, "select a blend or give `id`"))?, + }; + let c = cmd.to_string(); + s.edit(label, move |d, _| { + let Some(NodeKind::Blend { children, spec }) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(bad(&c, "not a blend")) }; + let (mut path, mut anchors) = live::pin_spine(children, spec).ok_or_else(|| bad(&c, "the blend has no spine"))?; + let sp = path.subpaths.first_mut().ok_or_else(|| bad(&c, "the blend has no spine"))?; + let out = f(sp, &mut anchors, children)?; + spec.key_anchors = anchors.iter().map(|a| u32::try_from(*a).unwrap_or(u32::MAX)).collect(); + spec.spine = Some(path); + Ok(out) + }) +} + +/// A point parameter that must be finite. +fn finite_point(p: &Value, cmd: &str) -> Result { + let q = Point::new(f64_req(p, "x", cmd)?, f64_req(p, "y", cmd)?); + if q.x.is_finite() && q.y.is_finite() && q.x.abs() < 1e9 && q.y.abs() < 1e9 { Ok(q) } else { Err(bad(cmd, "x and y must be finite")) } +} + +fn spine_move_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.spine.moveAnchor"; + let index = index_param(p, "anchor", C)?; + let q = finite_point(p, C)?; + let handle = match str_param(p, "handle") { + None => None, + Some("out") => Some(true), + Some("in") => Some(false), + Some(_) => return Err(bad(C, "handle must be in or out")), + }; + let independent = bool_or(p, "independent", false); + edit_spine(s, p, C, "Reshape Spine", |sp, anchors, keys| { + let a = sp.anchors.get_mut(index).ok_or_else(|| bad(C, "no such spine point"))?; + if let Some(out) = handle { + a.set_handle(out, q, independent); + return Ok(()); + } + let delta = q - a.p; + a.translate(delta); + // The key objects on the point move with it. + for (k, _) in anchors.iter().enumerate().filter(|(_, a)| **a == index) { + if let Some(key) = keys.get_mut(k) { + Arc::make_mut(key).transform(Affine::translate(delta), false); + } + } + Ok(()) + })?; + ok() +} + +fn spine_add_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.spine.addAnchor"; + let q = finite_point(p, C)?; + let index = edit_spine(s, p, C, "Add Spine Point", |sp, anchors, _| { + let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q).ok_or_else(|| bad(C, "the spine has no segments"))?; + let i = sp.insert_anchor(seg, t.clamp(1e-3, 1.0 - 1e-3)); + for a in anchors.iter_mut().filter(|a| **a >= i) { + *a += 1; + } + Ok(i) + })?; + Ok(json!({ "anchor": index })) +} + +fn spine_remove_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.spine.removeAnchor"; + let index = index_param(p, "anchor", C)?; + edit_spine(s, p, C, "Delete Spine Point", |sp, anchors, _| { + if index >= sp.anchors.len() { + return Err(bad(C, "no such spine point")); + } + if anchors.contains(&index) { + return Err(bad(C, "a key object sits on that point")); + } + if sp.anchors.len() <= 2 { + return Err(bad(C, "a spine keeps at least two points")); + } + sp.anchors.remove(index); + for a in anchors.iter_mut().filter(|a| **a > index) { + *a -= 1; + } + Ok(()) + })?; + ok() } fn edit_blends(s: &mut Session, label: &str, f: impl Fn(&mut Vec>, &mut BlendSpec)) -> Result { @@ -550,6 +710,14 @@ fn blend_info(s: &mut Session, _: &Value) -> Result { v["id"] = json!(id.0); v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::>()); v["keys"] = json!(keys.iter().map(|k| k.0).collect::>()); + if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind) + && let Some((path, anchors)) = live::blend_spine(children, &spec) + && let Some(sp) = path.subpaths.first() + { + let pt = |p: Point| json!([p.x, p.y]); + let points: Vec = sp.anchors.iter().map(|a| json!({"x": a.p.x, "y": a.p.y, "in": pt(a.h_in), "out": pt(a.h_out)})).collect(); + v["spine"] = json!({"anchors": points, "closed": sp.closed, "keyAnchors": anchors, "explicit": spec.spine.is_some()}); + } v } None => { @@ -581,19 +749,48 @@ fn blend_expand(s: &mut Session, _: &Value) -> Result { Ok(ids_json(&ids)) } -/// Move the keys onto their spine positions (so what's stored matches what's drawn). +/// Move the keys onto their spine positions (so what's stored matches what's drawn): onto their +/// anchors, or spread evenly by arc length along a spine from Replace Spine. fn keys_to_spine(children: &mut [Arc], spec: &BlendSpec) { - let Some(sp) = spec.spine.as_ref().and_then(Spine::new) else { return }; + let Some(path) = &spec.spine else { return }; let k = children.len(); + let pinned = spec.key_anchors.len() == k; + let sp = Spine::new(path); for (i, c) in children.iter_mut().enumerate() { - let f = if k > 1 { i as f64 / (k - 1) as f64 } else { 0.0 }; - let (pt, _) = sp.at(f); - if let Some(b) = c.geometric_bounds() { + let pt = if pinned { + spec.key_anchors.get(i).and_then(|a| path.subpaths.first()?.anchors.get(*a as usize)).map(|a| a.p) + } else { + sp.as_ref().map(|sp| sp.at(if k > 1 { i as f64 / (k - 1) as f64 } else { 0.0 }).0) + }; + if let (Some(pt), Some(b)) = (pt, c.geometric_bounds()) { Arc::make_mut(c).transform(Affine::translate(pt - b.center()), false); } } } +/// Store the blend's spine pinned to its keys (see [`live::pin_spine`]); false when it has none. +fn pin(children: &[Arc], spec: &mut BlendSpec) -> bool { + match live::pin_spine(children, spec) { + Some((path, anchors)) => { + spec.spine = Some(path); + spec.key_anchors = anchors.iter().map(|a| u32::try_from(*a).unwrap_or(u32::MAX)).collect(); + true + } + None => false, + } +} + +/// `spec`'s spine reversed: the key anchors count from the other end. +fn reverse_spine(spec: &mut BlendSpec) { + if let Some(sp) = &mut spec.spine { + sp.reverse(); + let n = sp.subpaths.first().map_or(0, |s| s.anchors.len() as u32); + for a in &mut spec.key_anchors { + *a = n.saturating_sub(1).saturating_sub(*a); + } + } +} + fn blend_replace_spine(s: &mut Session, _: &Value) -> Result { let blends = selected_of(s, is_blend); let st = s.doc()?; @@ -614,6 +811,7 @@ fn blend_replace_spine(s: &mut Session, _: &Value) -> Result { d.remove(path)?; if let Some(NodeKind::Blend { children, spec }) = d.node_mut(b).map(|n| &mut n.kind) { spec.spine = Some(spine); + spec.key_anchors.clear(); keys_to_spine(children, spec); } sel.set([b]); @@ -624,8 +822,11 @@ fn blend_replace_spine(s: &mut Session, _: &Value) -> Result { fn blend_reverse_spine(s: &mut Session, _: &Value) -> Result { edit_blends(s, "Reverse Spine", |children, spec| { - if let Some(sp) = &mut spec.spine { - sp.reverse(); + if spec.spine.is_some() && pin(children, spec) { + // The keys keep their anchors, which now count from the other end. + if let Some(sp) = &mut spec.spine { + sp.reverse(); + } keys_to_spine(children, spec); } else { let centers: Vec = children.iter().map(|k| k.geometric_bounds().map(|b| b.center()).unwrap_or_default()).collect(); @@ -645,9 +846,9 @@ fn blend_reverse_stack(s: &mut Session, _: &Value) -> Result { spec.starts.resize(children.len(), None); spec.starts.reverse(); } - if let Some(sp) = &mut spec.spine { - sp.reverse(); - } + // Each key keeps its place: on its anchor, or (spread evenly) with the spine reversed. + spec.key_anchors.reverse(); + reverse_spine(spec); }) } @@ -1093,8 +1294,8 @@ fn with_mesh(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl FnOn }) } -fn index_param(p: &Value, cmd: &str) -> Result { - let i = f64_req(p, "index", cmd)?; +fn index_param(p: &Value, key: &str, cmd: &str) -> Result { + let i = f64_req(p, key, cmd)?; if i < 0.0 { return Err(bad(cmd, "index must be ≥ 0")); } @@ -1103,7 +1304,7 @@ fn index_param(p: &Value, cmd: &str) -> Result { fn mesh_set_color(s: &mut Session, p: &Value) -> Result { const C: &str = "object.mesh.setPointColor"; - let index = index_param(p, C)?; + let index = index_param(p, "index", C)?; let color = p.get("color").and_then(color_value); let opacity = p.get("opacity").and_then(Value::as_f64); if color.is_none() && opacity.is_none() { @@ -1124,7 +1325,7 @@ fn mesh_set_color(s: &mut Session, p: &Value) -> Result { fn mesh_move_point(s: &mut Session, p: &Value) -> Result { const C: &str = "object.mesh.movePoint"; - let index = index_param(p, C)?; + let index = index_param(p, "index", C)?; let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); let handle = p.get("handle").and_then(Value::as_u64).map(|h| h as usize); if handle.is_some_and(|h| h > 3) { @@ -1157,7 +1358,7 @@ fn mesh_add_line(s: &mut Session, p: &Value) -> Result { fn mesh_delete_point(s: &mut Session, p: &Value) -> Result { const C: &str = "object.mesh.deletePoint"; - let index = index_param(p, C)?; + let index = index_param(p, "index", C)?; with_mesh(s, p, C, "Delete Mesh Point", |m| { if m.remove_point_lines(index) { Ok(()) } else { Err(bad(C, "only interior mesh lines can be deleted")) } })?; diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index e4faf3b9..9486117b 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -197,20 +197,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result { s.edit("Reshape", |d, _| { let path = path_mut(d, id)?; let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?; - if independent { - a.kind = AnchorKind::Corner; - } - let (moved, other) = if out { (&mut a.h_out, &mut a.h_in) } else { (&mut a.h_in, &mut a.h_out) }; - *moved = pos; - if a.kind == AnchorKind::Smooth { - // Keep the opposite handle collinear, preserving its length. - let len = (*other - a.p).hypot(); - let dir = a.p - pos; - let l = dir.hypot(); - if l > 1e-9 { - *other = a.p + dir * (len / l); - } - } + a.set_handle(out, pos, independent); Ok(()) })?; ok() diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2d308bc3..17795365 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1150,6 +1150,8 @@ mod tests_bboxrotate; #[cfg(test)] mod tests_blendopts; #[cfg(test)] +mod tests_blendspine; +#[cfg(test)] mod tests_brushsym; #[cfg(test)] mod tests_build; diff --git a/crates/engine/src/tests_blendspine.rs b/crates/engine/src/tests_blendspine.rs new file mode 100644 index 00000000..30c4504e --- /dev/null +++ b/crates/engine/src/tests_blendspine.rs @@ -0,0 +1,189 @@ +//! A blend's spine and keys on the canvas (M8.19): spine points moved, added and deleted (the +//! straight spine becomes a path on the first edit), keys picked and edited live, Release keeping +//! the spine as a path. + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::live::BlendSpec; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::{Point, Rect}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn spec(s: &Session, id: NodeId) -> BlendSpec { + match node(s, id).kind { + NodeKind::Blend { spec, .. } => spec, + _ => panic!("not a blend"), + } +} + +fn center(n: &Node) -> Point { + n.geometric_bounds().unwrap().center() +} + +/// Two 10 pt squares centred on (5, 5) and (105, 5), blended with one step. +fn blend(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let a = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let b = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": 10, "height": 10})).unwrap()); + let g = id_of(&s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap()); + (a, b, g) +} + +fn steps(s: &Session, g: NodeId) -> Vec { + vectorcraft_doc::live::expand_live(&node(s, g)) +} + +#[test] +fn a_spine_point_added_and_moved_bends_the_blend() { + let mut s = session(); + let (_, _, g) = blend(&mut s); + assert!(spec(&s, g).spine.is_none(), "straight until edited"); + let r = s.execute("object.blend.spine.addAnchor", &json!({"x": 55, "y": 8})).unwrap(); + assert_eq!(r["anchor"], json!(1)); + let sp = spec(&s, g); + assert_eq!(sp.key_anchors, vec![0, 2], "the keys keep their points"); + assert!(sp.spine.unwrap().subpaths[0].anchors[1].p.distance(Point::new(55.0, 5.0)) < 1e-9, "on the spine, nearest the click"); + s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 1, "x": 55, "y": 105})).unwrap(); + let st = steps(&s, g); + assert!(center(&st[1]).distance(Point::new(55.0, 105.0)) < 1e-6, "the step follows the spine: {:?}", center(&st[1])); + assert_eq!(center(&st[0]), Point::new(5.0, 5.0), "the keys stay put"); + // One undo step each. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(center(&steps(&s, g)[1]).distance(Point::new(55.0, 5.0)) < 1e-6); +} + +#[test] +fn moving_a_key_point_moves_the_key_and_the_other_way_round() { + let mut s = session(); + let (a, b, g) = blend(&mut s); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 0, "x": 5, "y": 105})).unwrap(); + assert_eq!(center(&node(&s, a)), Point::new(5.0, 105.0)); + // A handle drag curves the spine without moving the key. + s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 0, "handle": "out", "x": 5, "y": 300})).unwrap(); + assert_eq!(center(&node(&s, a)), Point::new(5.0, 105.0)); + assert!(center(&steps(&s, g)[1]).y > 100.0, "the step bows out"); + // Editing a key on the canvas (it is selectable inside the blend) drags its point along. + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 0, "dy": 50})).unwrap(); + let n = node(&s, g); + let NodeKind::Blend { children, spec } = &n.kind else { panic!() }; + let (path, _) = vectorcraft_doc::live::blend_spine(children, spec).unwrap(); + assert_eq!(path.subpaths[0].anchors[1].p, Point::new(105.0, 55.0)); + assert!(s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 9, "x": 0, "y": 0})).is_err()); + assert!(s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 0, "x": f64::MAX, "y": 0})).is_err()); +} + +#[test] +fn only_points_without_a_key_can_be_deleted() { + let mut s = session(); + let (_, _, g) = blend(&mut s); + assert!(s.execute("object.blend.spine.removeAnchor", &json!({"id": g.0, "anchor": 0})).is_err()); + s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 30, "y": 5})).unwrap(); + s.execute("object.blend.spine.removeAnchor", &json!({"id": g.0, "anchor": 1})).unwrap(); + let sp = spec(&s, g); + assert_eq!((sp.spine.unwrap().subpaths[0].anchors.len(), sp.key_anchors), (2, vec![0, 1])); +} + +#[test] +fn info_lists_the_spine() { + let mut s = session(); + let (_, _, g) = blend(&mut s); + let info = s.execute("object.blend.info", &json!({})).unwrap(); + assert_eq!(info["spine"]["explicit"], json!(false)); + assert_eq!(info["spine"]["keyAnchors"], json!([0, 1])); + assert_eq!(info["spine"]["anchors"][1]["x"], json!(105.0)); + let _ = g; +} + +#[test] +fn release_keeps_the_spine_as_a_path() { + let mut s = session(); + let (a, b, g) = blend(&mut s); + s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap(); + s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1, "x": 55, "y": 60})).unwrap(); + let r = s.execute("object.blend.release", &json!({})).unwrap(); + assert_eq!(r["ids"], json!([a.0, b.0])); + let spine = NodeId(r["spines"][0].as_u64().unwrap()); + let n = node(&s, spine); + assert!(n.appearance.fill_paint().is_none() && n.appearance.stroke_paint() == Paint::None, "paints nothing"); + let pts: Vec = n.path_data().unwrap().anchors().map(|(_, _, a)| a.p).collect(); + assert_eq!(pts, vec![Point::new(5.0, 5.0), Point::new(55.0, 60.0), Point::new(105.0, 5.0)]); + let d = &s.doc().unwrap().doc; + let layer = d.layers[0].children().unwrap().iter().map(|c| c.id).collect::>(); + assert_eq!(layer, vec![spine, a, b], "the spine below the keys"); +} + +#[test] +fn reverse_spine_and_front_to_back_keep_an_edited_spine() { + let mut s = session(); + let (a, b, g) = blend(&mut s); + s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap(); + s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1, "x": 55, "y": 60})).unwrap(); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + s.execute("object.blend.reverseSpine", &json!({})).unwrap(); + assert_eq!((center(&node(&s, a)), center(&node(&s, b))), (Point::new(105.0, 5.0), Point::new(5.0, 5.0)), "the keys swap ends"); + assert!(center(&steps(&s, g)[1]).distance(Point::new(55.0, 60.0)) < 1e-6, "the spine keeps its shape"); + s.execute("object.blend.reverseFrontToBack", &json!({})).unwrap(); + assert_eq!(center(&node(&s, a)), Point::new(105.0, 5.0), "stacking changes, places don't"); + assert!(center(&steps(&s, g)[1]).distance(Point::new(55.0, 60.0)) < 1e-6); +} + +#[test] +fn a_blend_with_an_edited_spine_takes_more_keys_along_it() { + let mut s = session(); + let (a, b, g) = blend(&mut s); + s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap(); + s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1, "x": 55, "y": 60})).unwrap(); + let c = id_of(&s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 10, "height": 10})).unwrap()); + let g2 = id_of(&s.execute("object.blend.make", &json!({"ids": [g.0, c.0]})).unwrap()); + let sp = spec(&s, g2); + assert_eq!(sp.key_anchors, vec![0, 2, 3]); + let pts: Vec = sp.spine.unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect(); + assert_eq!(pts[1], Point::new(55.0, 60.0), "the bend is kept"); + assert_eq!(pts[3], Point::new(205.0, 5.0)); + let _ = (a, b); +} + +#[test] +fn direct_selection_picks_keys_and_edits_update_the_blend_live() { + let mut s = session(); + let (a, _, g) = blend(&mut s); + s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#000000"})).ok(); + let opt = vectorcraft_doc::hit::HitOptions::default(); + let h = vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, Point::new(3.0, 3.0), opt).unwrap(); + assert_eq!((h.leaf, h.top_object(None)), (a, g), "the key is hit; the Selection tool still takes the blend"); + let h = vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, Point::new(55.0, 5.0), opt).unwrap(); + assert_eq!(h.leaf, g, "a step hits as the blend"); + let mut r = vectorcraft_render::Renderer::new(); + let before = r.render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 120.0, 20.0), 1.0, true).pixel(55, 5); + s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ff0000"})).unwrap(); + let after = r.render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 120.0, 20.0), 1.0, true).pixel(55, 5); + assert_ne!(before, after, "the step redraws with the key's new colour"); +} + +#[test] +fn key_anchors_round_trip() { + let mut s = session(); + let (_, _, g) = blend(&mut s); + s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap(); + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + assert_eq!(&back.layers, &d.layers); + let old: BlendSpec = serde_json::from_value(json!({"spine": {"subpaths": []}})).unwrap(); + assert!(old.key_anchors.is_empty()); +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index b19bdaed..9effcfe4 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -276,6 +276,9 @@ fn structured_junk() { ("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})), + ("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})), + ("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})), + ("object.blend.spine.removeAnchor", json!({"anchor": -1})), ]; let mut failures = vec![]; for fx in Fixture::ALL { diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index 33942790..67c53873 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -140,6 +140,25 @@ impl Anchor { self.h_in += d; self.h_out += d; } + /// Place the end of the outgoing (`out`) or incoming handle at `pos`. `independent` makes the + /// anchor a corner first; a smooth anchor keeps its other handle in line, at its length. + pub fn set_handle(&mut self, out: bool, pos: Point, independent: bool) { + if independent { + self.kind = AnchorKind::Corner; + } + let p = self.p; + let smooth = self.kind == AnchorKind::Smooth; + let (moved, other) = if out { (&mut self.h_out, &mut self.h_in) } else { (&mut self.h_in, &mut self.h_out) }; + *moved = pos; + if smooth { + let len = (*other - p).hypot(); + let dir = p - pos; + let l = dir.hypot(); + if l > 1e-9 { + *other = p + dir * (len / l); + } + } + } /// Swap handles (used when reversing path direction). pub fn reversed(&self) -> Self { Self { p: self.p, h_in: self.h_out, h_out: self.h_in, kind: self.kind } diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index a18b461f..b1bc0c26 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -4,11 +4,13 @@ //! path's anchors on that segment, drag to move selected anchors, drag a direction handle to //! reshape, marquee to select anchors, drag a live rectangle's corner widget to round its corners. //! Group Selection: click selects the leaf; each further click on it adds the next enclosing group. +//! Both pick the key objects of a blend. Direct Selection also edits a blend's spine: drag its +//! points (a key object on a point moves with it) and, once a point is clicked, its handles. use serde_json::{Value, json}; use vectorcraft_doc::hit::hit_test; use vectorcraft_doc::{AnchorRef, NodeId, NodeKind}; -use vectorcraft_geom::{Point, Rect}; +use vectorcraft_geom::{PathData, Point, Rect}; use crate::bbox::move_delta; use crate::corners::{CornerDrag, over_widget}; @@ -18,21 +20,93 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool #[derive(Clone, Debug)] enum State { Idle, - MoveAnchors { start: Point, began: bool }, - MoveObject { start: Point, began: bool }, - Handle { id: NodeId, si: usize, ai: usize, out: bool }, - Marquee { start: Point, cur: Point, add: bool }, + MoveAnchors { + start: Point, + began: bool, + }, + MoveObject { + start: Point, + began: bool, + }, + Handle { + id: NodeId, + si: usize, + ai: usize, + out: bool, + }, + Marquee { + start: Point, + cur: Point, + add: bool, + }, Corner(CornerDrag), + /// Dragging a point of a blend's spine from `from`. + SpinePoint { + id: NodeId, + anchor: usize, + from: Point, + start: Point, + began: bool, + }, + /// Dragging a handle of the clicked spine point. + SpineHandle { + id: NodeId, + anchor: usize, + out: bool, + }, } pub struct DirectSelectionTool { group: bool, state: State, + /// The spine point last clicked (blend, anchor): its handles show and can be dragged. + spine: Option<(NodeId, usize)>, } impl DirectSelectionTool { pub fn new(group: bool) -> Self { - Self { group, state: State::Idle } + Self { group, state: State::Idle, spine: None } + } +} + +/// The spines of the editable blends, topmost first: (blend, spine). +pub(crate) fn spines(cx: &ToolContext) -> Vec<(NodeId, PathData)> { + let mut v = vec![]; + cx.doc.walk(|n| { + if let NodeKind::Blend { children, spec } = &n.kind + && let Some((path, _)) = vectorcraft_doc::live::blend_spine(children, spec) + { + v.push((n.id, path)); + } + }); + v.reverse(); + v.retain(|(id, _)| cx.doc.is_editable(*id)); + v +} + +/// The spine point under `p`: (blend, anchor, where it is). +fn hit_spine_point(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, Point)> { + spines(cx).into_iter().find_map(|(id, path)| { + let (_, ai, a) = path.anchors().find(|(_, _, a)| a.p.distance(p) <= tol)?; + Some((id, ai, a.p)) + }) +} + +/// The spine point `sel` and whether `p` is on its outgoing handle's end (`Some(true)`) or its +/// incoming one's. +fn hit_spine_handle(cx: &ToolContext, sel: (NodeId, usize), p: Point, tol: f64) -> Option { + let a = spine_anchor(cx, sel)?; + if a.has_out() && a.h_out.distance(p) <= tol { + return Some(true); + } + (a.has_in() && a.h_in.distance(p) <= tol).then_some(false) +} + +/// Anchor `sel.1` of blend `sel.0`'s spine. +fn spine_anchor(cx: &ToolContext, (id, ai): (NodeId, usize)) -> Option { + match &cx.doc.node(id)?.kind { + NodeKind::Blend { children, spec } => vectorcraft_doc::live::blend_spine(children, spec)?.0.subpaths.first()?.anchors.get(ai).copied(), + _ => None, } } @@ -123,10 +197,22 @@ impl Tool for DirectSelectionTool { self.state = State::Corner(c); return vec![]; } + if let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id)) + && let Some(out) = hit_spine_handle(cx, sel, p, tol) + { + self.state = State::SpineHandle { id: sel.0, anchor: sel.1, out }; + return vec![Action::Begin("Reshape Spine".into())]; + } + self.spine = None; if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) { self.state = State::Handle { id, si, ai, out }; return vec![Action::Begin("Reshape".into())]; } + if let Some((id, anchor, from)) = hit_spine_point(cx, p, tol) { + self.spine = Some((id, anchor)); + self.state = State::SpinePoint { id, anchor, from, start: p, began: false }; + return if cx.selection.contains(id) { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] }; + } if let Some((id, si, ai)) = hit_anchor(cx, p, tol, false) { let already = cx.selection.partial(id).is_some_and(|s| s.contains(&(si, ai))); self.state = State::MoveAnchors { start: p, began: false }; @@ -187,6 +273,23 @@ impl Tool for DirectSelectionTool { json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out {"out"} else {"in"}, "x": p.x, "y": p.y, "independent": ev.mods.alt}), )] } + (PointerKind::Drag, State::SpinePoint { id, anchor, from, start, began }) => { + let mut out = vec![]; + if !began { + if p.distance(start) < cx.tol(3.0) { + return out; + } + out.push(Action::Begin("Reshape Spine".into())); + self.state = State::SpinePoint { id, anchor, from, start, began: true }; + } + let q = from + move_delta(start, p, ev.mods.shift); + out.push(Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": id.0, "anchor": anchor, "x": q.x, "y": q.y}))); + out + } + (PointerKind::Drag, State::SpineHandle { id, anchor, out }) => vec![Action::Preview( + "object.blend.spine.moveAnchor".into(), + json!({"id": id.0, "anchor": anchor, "handle": if out {"out"} else {"in"}, "x": p.x, "y": p.y, "independent": ev.mods.alt}), + )], (PointerKind::Drag, State::Marquee { start, add, .. }) => { self.state = State::Marquee { start, cur: p, add }; vec![] @@ -200,11 +303,11 @@ impl Tool for DirectSelectionTool { self.state = State::Idle; c.finish() } - (PointerKind::Up, State::MoveAnchors { began, .. } | State::MoveObject { began, .. }) => { + (PointerKind::Up, State::MoveAnchors { began, .. } | State::MoveObject { began, .. } | State::SpinePoint { began, .. }) => { self.state = State::Idle; if began { vec![Action::Commit] } else { vec![] } } - (PointerKind::Up, State::Handle { .. }) => { + (PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => { self.state = State::Idle; vec![Action::Commit] } @@ -235,7 +338,7 @@ impl Tool for DirectSelectionTool { match &self.state { State::Marquee { start, cur, .. } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], State::Corner(c) => c.overlays(cx), - _ => vec![], + _ => self.spine_overlays(cx), } } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { @@ -246,12 +349,73 @@ impl Tool for DirectSelectionTool { } } +impl DirectSelectionTool { + /// The clicked spine point, filled, with its handles. + fn spine_overlays(&self, cx: &ToolContext) -> Vec { + let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id)) else { return vec![] }; + let Some(a) = spine_anchor(cx, sel) else { return vec![] }; + let color = cx.doc.layer_color(sel.0); + let mut out = vec![]; + for h in [a.h_in, a.h_out].into_iter().filter(|h| h.distance(a.p) > 1e-6) { + out.push(Overlay::Line { a: a.p, b: h, color, dashed: false }); + out.push(Overlay::Handle { p: h, color }); + } + out.push(Overlay::Anchor { p: a.p, color, filled: true, size: 5.0 }); + out + } +} + #[cfg(test)] mod tests { use super::*; use crate::testutil::*; use vectorcraft_doc::Selection; + /// A blend of two 20 pt squares centred on (110, 310) and (210, 310), selected. + fn blend_doc() -> (vectorcraft_doc::Document, NodeId, NodeId) { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let key = |id: NodeId, x: f64| { + let r = vectorcraft_geom::shapes::rectangle(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 (g, k1, k2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let b = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 100.0), key(k2, 200.0)], spec: Default::default() }); + d.insert(Some(l), 1, b).unwrap(); + (d, g, k1) + } + + #[test] + fn dragging_a_spine_point_moves_it() { + let (d, g, _) = blend_doc(); + let mut s = Selection::default(); + s.set([g]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 211.0, 310.0)), vec![]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 221.0, 350.0)); + assert_eq!(a[0], Action::Begin("Reshape Spine".into())); + assert_eq!(a[1], Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": g.0, "anchor": 1, "x": 220.0, "y": 350.0}))); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 221.0, 350.0)), vec![Action::Commit]); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Anchor { filled: true, .. })), "the clicked point shows"); + } + + #[test] + fn direct_and_group_selection_pick_blend_keys() { + let (d, g, k1) = blend_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let a = DirectSelectionTool::new(false).pointer(&cx, &PointerEvent::new(PointerKind::Down, 104.0, 304.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [k1.0]}))]); + let a = DirectSelectionTool::new(true).pointer(&cx, &PointerEvent::new(PointerKind::Down, 104.0, 304.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [k1.0]}))]); + // A click on a step (between the keys) still selects the blend. + let a = DirectSelectionTool::new(true).pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 304.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [g.0]}))]); + } + #[test] fn click_anchor_selects_it() { let (d, id) = doc_with_rect(); diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 70411f9c..da837114 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -3,7 +3,8 @@ //! Click adds a corner anchor; click-drag adds a smooth anchor with symmetric handles (Alt-drag //! breaks the handles); Shift constrains to 45°. Clicking the first anchor closes the path. //! Enter/Esc (or switching tools) ends the path. Clicking the end of a selected open path continues -//! it. The rubber-band preview shows the next segment. +//! it. The rubber-band preview shows the next segment. On a selected blend's spine a click adds a +//! point (on a point no key object sits on: deletes it). use serde_json::json; use vectorcraft_doc::{NodeId, NodeKind}; @@ -68,6 +69,9 @@ impl Tool for PenTool { self.drag = Some((p, false)); return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))]; } + if let Some(acts) = spine_click(cx, p, tol) { + return acts; + } // Continue a selected open path when clicking on one of its ends. if let Some((_, first, last, _)) = active_path(cx) && (p.distance(last) <= tol || p.distance(first) <= tol) @@ -149,6 +153,13 @@ impl Tool for PenTool { vec![Overlay::Path { path: bp, color: c, width: 1.0, dashed: false }] } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if !self.drawing { + match spine_click(cx, p, cx.tol(5.0)).as_deref() { + Some([Action::Exec(c, _)]) if c == "object.blend.spine.removeAnchor" => return Cursor::PenDelete, + Some([_]) => return Cursor::PenAdd, + _ => {} + } + } if let Some((_, first, last, _)) = active_path(cx) { if self.drawing && p.distance(first) <= cx.tol(5.0) { return Cursor::PenClose; @@ -161,6 +172,25 @@ impl Tool for PenTool { } } +/// A click on a selected blend's spine: delete the point under `p` when no key object sits on it +/// (a key's point does nothing), else add one where the spine passes within `tol`. +fn spine_click(cx: &ToolContext, p: Point, tol: f64) -> Option> { + for (id, path) in crate::direct::spines(cx).into_iter().filter(|(id, _)| cx.selection.contains(*id)) { + let Some(NodeKind::Blend { children, spec }) = cx.doc.node(id).map(|n| &n.kind) else { continue }; + let keys = vectorcraft_doc::live::blend_spine(children, spec).and_then(|(_, a)| a).unwrap_or_default(); + if let Some((_, ai, _)) = path.anchors().find(|(_, _, a)| a.p.distance(p) <= tol) { + if keys.contains(&ai) { + return Some(vec![]); + } + return Some(vec![Action::Exec("object.blend.spine.removeAnchor".into(), json!({"id": id.0, "anchor": ai}))]); + } + if path.nearest(p).is_some_and(|n| n.4 <= tol) { + return Some(vec![Action::Exec("object.blend.spine.addAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))]); + } + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -179,4 +209,31 @@ mod tests { assert!(matches!(&a[1], Action::Preview(c, _) if c == "path.create")); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)), vec![Action::Commit]); } + + #[test] + fn clicking_a_selected_blend_spine_adds_a_point() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].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 (g, k1, k2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert( + Some(l), + 1, + vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 100.0), key(k2, 200.0)], spec: Default::default() }), + ) + .unwrap(); + let mut s = Selection::default(); + s.set([g]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = PenTool::default(); + assert_eq!(t.cursor(&cx, Point::new(160.0, 311.0), Mods::default()), Cursor::PenAdd); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 311.0)); + assert_eq!(a, vec![Action::Exec("object.blend.spine.addAnchor".into(), json!({"id": g.0, "x": 160.0, "y": 310.0}))]); + // A key's own point is left alone (the click snaps to the spine line). + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 310.0)), vec![]); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 12dad272..6bb4460e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1071,6 +1071,20 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { p.circle_filled(o, 2.5, color); } } + // The spine of each selected blend (or of the blend a selected key object belongs to). + let mut spines = vec![]; + for id in &st.selection.objects { + let is_blend = |b: &vectorcraft_doc::NodeId| st.doc.node(*b).is_some_and(|n| matches!(n.kind, NodeKind::Blend { .. })); + let Some(b) = [Some(*id), st.doc.parent_of(*id)].into_iter().flatten().find(is_blend).filter(|b| !spines.contains(b)) else { continue }; + spines.push(b); + let Some(NodeKind::Blend { children, spec }) = st.doc.node(b).map(|n| &n.kind) else { continue }; + let Some((path, _)) = vectorcraft_doc::live::blend_spine(children, spec) else { continue }; + let color = c32(st.doc.layer_color(b)); + stroke_path(p, &path.to_bezpath(), xf, Stroke::new(1.0, color)); + for (_, _, a) in path.anchors() { + anchor_square(p, xf.to_screen(a.p), color, false, if direct { 5.0 } else { 4.0 }); + } + } // Live Corners widgets (Selection / Direct Selection on a single live rectangle). if matches!(tool, "selection" | "directSelection") && app.ui.view.corner_widgets diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 20d93c56..0591dcb7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -498,3 +498,7 @@ toolbar button) opens the `blendOptions` dialog on the selected blend's options: `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. +A selected blend shows its spine. With Direct Selection (`directSelection`) a `ui.pointer` drag from a spine point +moves it (`object.blend.spine.moveAnchor`, one undo step) and, once a point is clicked, from one of its handles +reshapes the curve; Direct and Group Selection pick a blend's key objects. With the Pen tool a click on a selected +blend's spine adds a point (on a point no key sits on, deletes it). diff --git a/docs/mcp.md b/docs/mcp.md index 2a385e8d..cd6035e2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1864,3 +1864,15 @@ among the objects takes the others in as more key objects, keeping its options, {"name":"run_command","arguments":{"command":"object.blend.make","params":{"ids":[20,31]}}} {"name":"run_command","arguments":{"command":"object.blend.info","params":{}}} ``` + +A blend's spine is the straight lines between its key centres until it is edited. `object.blend.info` lists it +(`spine.anchors`, `keyAnchors`: the point each key sits on). `object.blend.spine.addAnchor {x, y}` adds a point where +the spine passes nearest, `object.blend.spine.moveAnchor {anchor, x, y}` moves a point (a key on it moves along; with +`handle: "in" | "out"` it places that handle) and `object.blend.spine.removeAnchor {anchor}` deletes a point no key +sits on; the first edit turns the spine into a path. Moving a key moves its end of the spine. Release +(`object.blend.release`) leaves the spine as a path that paints nothing (`spines` in the result): + +```json +{"name":"run_command","arguments":{"command":"object.blend.spine.addAnchor","params":{"id":40,"x":150,"y":100}}} +{"name":"run_command","arguments":{"command":"object.blend.spine.moveAnchor","params":{"id":40,"anchor":1,"x":150,"y":20}}} +```