Blend: moving a key of a blend with a replaced spine moves it (#6809)

After Replace Spine the keys are not pinned to spine anchors, so the
steps spread them evenly along the spine by arc length and put a moved
key straight back where it was. Once a key is moved off its place, the
spine is laid out pinned at the anchors where the keys were spread, and
the moved key takes its point of the spine along, as on a pinned spine.
This commit is contained in:
HassanKhan
2026-11-12 12:51:06 +05:00
parent 48e066f475
commit aec586b840
2 changed files with 78 additions and 8 deletions
+47 -8
View File
@@ -1051,7 +1051,8 @@ fn pinned(spec: &BlendSpec, keys: usize, n: usize) -> Option<Vec<usize>> {
/// - 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).
/// evenly along it by arc length) while the keys sit there; once one is moved off, pinned as
/// above at the anchors where they were spread.
///
/// `None` for a blend made without a spine ([`BlendSpec::no_spine`]).
pub fn blend_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Option<Vec<usize>>)> {
@@ -1077,7 +1078,23 @@ fn layout_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Optio
return Some((PathData::single(SubPath::polyline(&centers, 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)) };
let Some(ka) = pinned(spec, keys.len(), first.anchors.len()) else {
let mut path = PathData::single(first);
// A key moved off where Replace Spine spread it takes its point of the spine along, as
// on a pinned spine; otherwise the steps would put it back on the spine (#6809).
if Spine::new(&path).is_none_or(|sp| keys_spread_along(keys, &sp)) {
return Some((path, None));
}
let Some(ka) = spread_anchors(&mut path, keys.len()) else { return Some((path, None)) };
let mut first = path.subpaths.into_iter().next()?;
for (k, &ai) in keys.iter().zip(&ka) {
if let Some(a) = first.anchors.get_mut(ai) {
let d = center_of(k) - a.p;
a.translate(d);
}
}
return Some((PathData::single(first), Some(ka)));
};
for (k, &ai) in keys.iter().zip(&ka) {
let c = center_of(k);
if let Some(a) = first.anchors.get_mut(ai) {
@@ -1091,21 +1108,43 @@ fn layout_spine(keys: &[Arc<Node>], spec: &BlendSpec) -> Option<(PathData, Optio
/// [`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));
// A spine from Replace Spine is pinned as stored, even with a key moved off it (where
// [`blend_spine`] moves that key's point along): the layout is the same.
if keys.len() >= 2
&& let Some(first) = stored_spine(spec).and_then(|s| s.subpaths.first())
&& pinned(spec, keys.len(), first.anchors.len()).is_none()
{
let mut path = PathData::single(first.clone());
let out = spread_anchors(&mut path, keys.len())?;
return Some((path, out));
}
let (path, anchors) = blend_spine(keys, spec)?;
Some((path, anchors?))
}
/// Do `keys` sit where a spine from Replace Spine spreads them: evenly along `spine` by arc length
/// (within a hundredth of a point, as [`anchor_at_length`] matches anchors)?
fn keys_spread_along(keys: &[Arc<Node>], spine: &Spine) -> bool {
let k = keys.len();
let total = Spine::new(&path)?.length();
keys.iter().enumerate().all(|(i, key)| {
let f = if k > 1 { i as f64 / (k - 1) as f64 } else { 0.0 };
center_of(key).distance(spine.at(f).0) < 0.01
})
}
/// The anchors of `path`'s first subpath that `k` keys spread evenly along it by arc length sit
/// on, splitting segments where none is.
fn spread_anchors(path: &mut PathData, k: usize) -> Option<Vec<usize>> {
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)?);
out.push(anchor_at_length(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))
Some(out)
}
/// The anchor of the first subpath at arc length `s`: an existing one within a hundredth of a
+31
View File
@@ -424,6 +424,37 @@ fn blend_replace_spine_follows_path() {
assert!(first.distance(Point::new(100.0, 300.0)) < 1.0, "{first:?}");
}
/// #6809: after Replace Spine, moving a key object moves the drawn key and its end of the spine,
/// not only the key's selection outline.
#[test]
fn moving_a_key_of_a_replaced_spine_blend_moves_what_is_drawn() {
let mut s = session();
let (a, _, g) = blend(&mut s, json!({"steps": 5}));
let arc = id_of(&s.execute("path.create", &json!({"d": "M100 300 C100 245 145 200 200 200 C255 200 300 245 300 300"})).unwrap());
sel(&mut s, &[g, arc]);
s.execute("object.blend.replaceSpine", &json!({})).unwrap();
let center = |n: &Node| n.geometric_bounds().unwrap().center();
let before = steps_of(&s, g);
assert!(center(&before[0]).distance(Point::new(100.0, 300.0)) < 1e-6);
// Direct Selection picks the key inside the blend; the drag moves it.
sel(&mut s, &[a]);
s.execute("object.move", &json!({"dx": -50, "dy": 40})).unwrap();
let key = center(&node(&s, a));
assert!(key.distance(Point::new(50.0, 340.0)) < 1e-6, "{key:?}");
let after = steps_of(&s, g);
assert_eq!(after.len(), before.len());
let drawn = center(&after[0]);
assert!(drawn.distance(key) < 1e-6, "the key is drawn where it was moved to: {drawn:?}, outline at {key:?}");
assert!(center(&after[1]).distance(center(&before[1])) > 1.0, "the first step follows the moved key");
let last = center(&after[after.len() - 1]);
assert!(last.distance(Point::new(300.0, 300.0)) < 1e-6, "the other key stays put: {last:?}");
// Undo puts the key back on the spine as Replace Spine left it.
s.execute("edit.undo", &json!({})).unwrap();
let back = steps_of(&s, g);
assert!(center(&back[0]).distance(Point::new(100.0, 300.0)) < 1e-6);
assert!(center(&back[1]).distance(center(&before[1])) < 1e-6);
}
#[test]
fn blend_align_to_path_rotates_steps() {
let mut s = session();