Merge pull request #5904 from mockydevs/fix/blend-spine-review

Blend spine: moveAnchor refuses `with` alongside `handle`; spine built once per overlay and drag
This commit is contained in:
BFlat
2026-11-06 16:46:18 +08:00
committed by GitHub
3 changed files with 16 additions and 10 deletions
+2 -1
View File
@@ -205,7 +205,8 @@ pub(super) fn spine_move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
};
let independent = switch_or(p, "independent", false, C)?;
let with = spine_points_param(p, C)?;
if handle.is_some() && !with.is_empty() {
// Any `with`, even an empty list, asks to move points, which a handle drag doesn't.
if handle.is_some() && p.get("with").is_some_and(|v| !v.is_null()) {
return Err(bad(C, "`with` moves points, not a handle"));
}
edit_spine(s, p, C, "Reshape Spine", |sp, anchors, keys| {
+1
View File
@@ -76,6 +76,7 @@ fn spine_points_move_together() {
let before = spec(&s, g);
assert!(s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 1, "x": 55, "y": 45, "with": [9]})).is_err());
assert!(s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 1, "x": 55, "y": 45, "with": [2], "handle": "in"})).is_err());
assert!(s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 1, "x": 55, "y": 45, "with": [], "handle": "in"})).is_err());
assert!(s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 1, "x": 55, "y": 45, "with": "2"})).is_err());
assert_eq!(spec(&s, g), before, "refused requests change nothing");
s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 1, "x": 55, "y": 45, "with": [2, 1]})).unwrap();
+13 -9
View File
@@ -396,8 +396,13 @@ fn hit_spine_handle(cx: &ToolContext, sel: (NodeId, usize), p: Point, tol: f64)
/// Anchor `sel.1` of blend `sel.0`'s spine.
fn spine_anchor(cx: &ToolContext, (id, ai): (NodeId, usize)) -> Option<vectorcraft_geom::Anchor> {
spine_path(cx, id)?.subpaths.first()?.anchors.get(ai).copied()
}
/// Blend `id`'s spine, built once for callers that look up several of its points.
fn spine_path(cx: &ToolContext, id: NodeId) -> Option<PathData> {
match &cx.doc.node(id)?.kind {
NodeKind::Blend { children, spec } => vectorcraft_doc::live::blend_spine(children, spec)?.0.subpaths.first()?.anchors.get(ai).copied(),
NodeKind::Blend { children, spec } => Some(vectorcraft_doc::live::blend_spine(children, spec)?.0),
_ => None,
}
}
@@ -897,7 +902,9 @@ impl Tool for DirectSelectionTool {
}
let q = from + move_delta(start, p, ev.mods.shift, cx.constrain_angle);
let mut params = json!({"id": id.0, "anchor": anchor, "x": q.x, "y": q.y});
let with: Vec<usize> = self.spine_more.iter().copied().filter(|a| spine_anchor(cx, (id, *a)).is_some()).collect();
let spine = spine_path(cx, id);
let count = spine.as_ref().and_then(|sp| sp.subpaths.first()).map_or(0, |sp| sp.anchors.len());
let with: Vec<usize> = self.spine_more.iter().copied().filter(|a| *a < count).collect();
if !with.is_empty() {
params["with"] = json!(with);
}
@@ -1164,7 +1171,9 @@ 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 Some(spine) = spine_path(cx, sel.0) else { return vec![] };
let Some(points) = spine.subpaths.first().map(|sp| &sp.anchors) else { return vec![] };
let Some(a) = points.get(sel.1).copied() 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) {
@@ -1172,12 +1181,7 @@ impl DirectSelectionTool {
out.push(Overlay::Handle { p: h, color });
}
out.push(Overlay::Anchor { p: a.p, color, filled: true, size: 5.0 });
out.extend(self.spine_more.iter().filter_map(|i| spine_anchor(cx, (sel.0, *i))).map(|m| Overlay::Anchor {
p: m.p,
color,
filled: true,
size: 5.0,
}));
out.extend(self.spine_more.iter().filter_map(|i| points.get(*i)).map(|m| Overlay::Anchor { p: m.p, color, filled: true, size: 5.0 }));
out
}
}