mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Merge pull request #191 from storytold/m8-blend-spine
M8.19: blend spine and keys editable on the canvas
This commit is contained in:
15 files changed
+964
-85
No files matched your search
+1
-1
@@ -144,7 +144,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects, M8.9 perspective grid presets and manager, M8.16 test fix (EPS dates), M8.12 Perspective Selection scales, copies, moves perpendicular, Transform Again, M8.18 Blend Options dialog, anchor-targeted Blend tool, add keys) | 8–12 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects, M8.9 perspective grid presets and manager, M8.16 test fix (EPS dates), M8.12 Perspective Selection scales, copies, moves perpendicular, Transform Again, M8.18 Blend Options dialog, anchor-targeted Blend tool, add keys, M8.19 blend spine and keys editable on canvas, Release keeps spine) | 8–12 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
|
||||
+248
-28
@@ -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<Point>,
|
||||
cum: Vec<f64>,
|
||||
anchors: Vec<f64>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl Spine {
|
||||
pub fn new(path: &PathData) -> Option<Self> {
|
||||
let sp = path.subpaths.first()?;
|
||||
let pts = crate::live::flatten_subpath(sp, 0.1);
|
||||
let mut pts: Vec<Point> = Vec::new();
|
||||
let mut cum: Vec<f64> = 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<f64> {
|
||||
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<Vec<usize>> {
|
||||
(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<Node>], spec: &BlendSpec) -> Option<(PathData, Option<Vec<usize>>)> {
|
||||
if keys.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let Some(stored) = &spec.spine else {
|
||||
let centers: Vec<Point> = 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<Node>], spec: &BlendSpec) -> Option<(PathData, Vec<usize>)> {
|
||||
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<usize> {
|
||||
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<f64>,
|
||||
centers: Vec<Point>,
|
||||
rotate: bool,
|
||||
}
|
||||
|
||||
impl Rail {
|
||||
/// `None` when nothing moves: no stored spine and the steps keep the page's orientation.
|
||||
fn new(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<Self> {
|
||||
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<f64> = 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<Node>], spec: &BlendSpec) -> Vec<Node> {
|
||||
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<Node> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,11 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
|
||||
chain.push(c.id);
|
||||
let hit = match &c.kind {
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => 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() {
|
||||
|
||||
@@ -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<f64> {
|
||||
fn cumulative_lengths(pts: &[Point]) -> Vec<f64> {
|
||||
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<Point> {
|
||||
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();
|
||||
|
||||
+230
-29
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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<Arc<Node>>, starts: &[(NodeId, u32)]) -> (Vec<Arc<Node>
|
||||
(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<Node>], old_spec: &BlendSpec, keys: &[Arc<Node>]) {
|
||||
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<Node>| 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<usize> = (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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
|
||||
fn blend_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
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<Node> = 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::<Vec<_>>());
|
||||
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<T>(
|
||||
s: &mut Session,
|
||||
p: &Value,
|
||||
cmd: &str,
|
||||
label: &str,
|
||||
f: impl FnOnce(&mut vectorcraft_geom::SubPath, &mut Vec<usize>, &mut [Arc<Node>]) -> Result<T>,
|
||||
) -> Result<T> {
|
||||
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<Point> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Arc<Node>>, &mut BlendSpec)) -> Result<Value> {
|
||||
@@ -550,6 +710,14 @@ fn blend_info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
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<_>>());
|
||||
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<Value> = 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<Value> {
|
||||
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<Node>], 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<Node>], 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<Value> {
|
||||
let blends = selected_of(s, is_blend);
|
||||
let st = s.doc()?;
|
||||
@@ -614,6 +811,7 @@ fn blend_replace_spine(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
|
||||
fn blend_reverse_spine(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
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<Point> = 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<Value> {
|
||||
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<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl FnOn
|
||||
})
|
||||
}
|
||||
|
||||
fn index_param(p: &Value, cmd: &str) -> Result<usize> {
|
||||
let i = f64_req(p, "index", cmd)?;
|
||||
fn index_param(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
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<usize> {
|
||||
|
||||
fn mesh_set_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
|
||||
fn mesh_move_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
|
||||
fn mesh_delete_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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")) }
|
||||
})?;
|
||||
|
||||
@@ -197,20 +197,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Node> {
|
||||
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<Point> = 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::<Vec<_>>();
|
||||
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<Point> = 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());
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+173
-9
@@ -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<bool> {
|
||||
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<vectorcraft_geom::Anchor> {
|
||||
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<Overlay> {
|
||||
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();
|
||||
|
||||
+58
-1
@@ -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<Vec<Action>> {
|
||||
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![]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
+12
@@ -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}}}
|
||||
```
|
||||
Reference in new issue
Block a user