Merge pull request #1174 from storytold/fix/1111-bend-blend-spine

#1111: a blend's spine bends into a curve
This commit is contained in:
BFlat 2026-10-11 17:49:59 +07:00 committed by GitHub
commit 2adf4465d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 187 additions and 22 deletions

File diff suppressed because one or more lines are too long

View File

@ -825,20 +825,9 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Reshape", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
if seg >= sp.segment_count() {
if !sp.bend_segment(seg, t, dv) {
return Err(EngineError::Other("no such segment".into()));
}
let n = sp.anchors.len();
// Moving both inner control points by v moves B(t) by 3t(1-t)·v. A smooth anchor stays
// smooth: its other handle turns with the moved one.
let v = dv / (3.0 * t * (1.0 - t));
let (i0, i1) = (seg % n, (seg + 1) % n);
if let Some(a) = sp.anchors.get_mut(i0) {
a.set_handle(true, a.h_out + v, false);
}
if let Some(b) = sp.anchors.get_mut(i1) {
b.set_handle(false, b.h_in + v, false);
}
Ok(())
})?;
ok()

View File

@ -14,7 +14,7 @@ use vectorcraft_doc::live::{
PreserveShape, Spine,
};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{Affine, PathData, Point};
use vectorcraft_geom::{Affine, PathData, Point, Vec2};
use super::edit::selected_roots;
use super::expand::gradient_sampler;
@ -95,6 +95,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
spine_move_anchor
),
cmd!(
"object.blend.spine.reshapeSegment",
"Reshape Spine Segment",
[],
None,
"{id? (default: the selected blend), segment: index, t: 0..1, dx, dy} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's: a straight spine turns into a curve (#1111)",
has_doc,
spine_reshape_segment
),
cmd!(
"object.blend.spine.addAnchor",
"Add Spine Point",
@ -640,6 +649,18 @@ fn spine_move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.spine.reshapeSegment";
let seg = index_param(p, "segment", C)?;
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
if !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) {
return Err(bad(C, "t, dx and dy must be finite"));
}
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| if sp.bend_segment(seg, t, dv) { Ok(()) } else { Err(bad(C, "no such spine segment")) })?;
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)?;

View File

@ -187,3 +187,20 @@ fn key_anchors_round_trip() {
let old: BlendSpec = serde_json::from_value(json!({"spine": {"subpaths": []}})).unwrap();
assert!(old.key_anchors.is_empty());
}
/// #1111: a straight spine's segment bends into a curve, its middle moving where it's dragged, and
/// the steps follow it; junk segments and deltas are refused.
#[test]
fn a_straight_spine_segment_bends_into_a_curve() {
let mut s = session();
let (_, _, g) = blend(&mut s);
s.execute("object.blend.spine.reshapeSegment", &json!({"segment": 0, "t": 0.5, "dx": 0, "dy": 40})).unwrap();
let spine = spec(&s, g).spine.unwrap();
let seg = spine.subpaths[0].segment(0);
use vectorcraft_geom::kurbo::ParamCurve;
assert!(seg.eval(0.5).distance(Point::new(55.0, 45.0)) < 1e-9, "{seg:?}");
assert!(center(&steps(&s, g)[1]).y > 20.0, "the step follows the curve");
for bad in [json!({"segment": 1, "dy": 10}), json!({"segment": 0, "dx": f64::MAX, "dy": f64::MAX}), json!({"segment": -1})] {
assert!(s.execute("object.blend.spine.reshapeSegment", &bad).is_err(), "{bad}");
}
}

View File

@ -367,6 +367,7 @@ fn structured_junk() {
("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.reshapeSegment", json!({"id": 2, "segment": u64::MAX, "t": f64::NAN, "dx": 1e308, "dy": -1e308})),
("object.blend.spine.removeAnchor", json!({"anchor": -1})),
("object.puppetWarp", json!({"rest": true, "pins": [], "moved": []})),
("object.puppetWarp", json!({"rest": true, "pins": [[1e308, 0]], "moved": [[0, 0]], "expand": -5})),

View File

@ -252,6 +252,24 @@ impl SubPath {
_ => a,
}
}
/// Bend segment `seg` so its point at `t` moves by `dv`: both inner control points move by
/// dv / 3t(1-t), since B(t) moves by 3t(1-t) times that. A smooth anchor stays smooth: its other
/// handle turns with the moved one. False when there is no such segment.
pub fn bend_segment(&mut self, seg: usize, t: f64, dv: kurbo::Vec2) -> bool {
let n = self.anchors.len();
if seg >= self.segment_count() || n == 0 {
return false;
}
let v = dv / (3.0 * t * (1.0 - t));
if let Some(a) = self.anchors.get_mut(seg % n) {
a.set_handle(true, a.h_out + v, false);
}
if let Some(b) = self.anchors.get_mut((seg + 1) % n) {
b.set_handle(false, b.h_in + v, false);
}
true
}
/// Split segment `seg` at parameter `t`, inserting a new anchor. Returns the new anchor index.
pub fn insert_anchor(&mut self, seg: usize, t: f64) -> usize {
let n = self.anchors.len();

View File

@ -90,6 +90,15 @@ enum State {
start: Point,
began: bool,
},
/// Dragging a curved segment of a selected blend's spine: it follows the pointer
/// (`object.blend.spine.reshapeSegment`, #1111).
SpineSegment {
id: NodeId,
seg: usize,
t: f64,
start: Point,
began: bool,
},
/// Dragging a handle of the clicked spine point.
SpineHandle {
id: NodeId,
@ -170,6 +179,15 @@ fn hit_spine_point(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usiz
})
}
/// The segment of a selected blend's spine passing within `tol` of `p`: (blend, segment, t, whether
/// it is straight).
pub(crate) fn hit_spine_segment(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, f64, bool)> {
spines(cx).into_iter().filter(|(id, _)| cx.selection.contains(*id)).find_map(|(id, path)| {
let (si, seg, t, _, dist) = path.nearest(p)?;
(dist <= tol).then(|| (id, seg, t, path.subpaths.get(si).is_some_and(|sp| sp.segment_is_line(seg))))
})
}
/// 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> {
@ -402,6 +420,11 @@ impl Tool for DirectSelectionTool {
if let Some((id, anchors)) = hit_frame_edge(cx, p, tol) {
return self.press_type_area(cx, id, anchors, p, ev.mods.shift);
}
// A curved segment of a selected blend's spine reshapes, as a path's does.
if let Some((id, seg, t, false)) = hit_spine_segment(cx, p, tol) {
self.state = State::SpineSegment { id, seg, t, start: p, began: false };
return vec![];
}
if let Some(out) = self.guide.press(cx, ev) {
self.state = State::Idle;
return out;
@ -546,6 +569,22 @@ impl Tool for DirectSelectionTool {
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::SpineSegment { id, seg, t, 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::SpineSegment { id, seg, t, start, began: true };
}
let d = p - start;
out.push(Action::Preview(
"object.blend.spine.reshapeSegment".into(),
json!({"id": id.0, "segment": seg, "t": t, "dx": d.x, "dy": d.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}),
@ -583,6 +622,7 @@ impl Tool for DirectSelectionTool {
| State::MoveObject { began, .. }
| State::Segment { began, .. }
| State::SpinePoint { began, .. }
| State::SpineSegment { began, .. }
| State::TypeArea { began, .. },
) => {
self.state = State::Idle;
@ -814,6 +854,45 @@ mod tests {
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Anchor { filled: true, .. })), "the clicked point shows");
}
/// #1111: the Anchor Point tool bends a selected blend's straight spine; Direct Selection
/// reshapes a curved spine segment and leaves a straight one alone, as it does a path's.
#[test]
fn spine_segments_bend_with_the_anchor_point_tool_and_direct_selection() {
let (mut d, g, _) = blend_doc();
let mut s = Selection::default();
s.set([g]);
let p = paint();
let drag = |t: &mut dyn crate::Tool, cx: &ToolContext| {
t.pointer(cx, &PointerEvent::new(PointerKind::Down, 160.0, 311.0));
let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, 160.0, 351.0));
t.pointer(cx, &PointerEvent::new(PointerKind::Up, 160.0, 351.0));
a
};
let straight = cx(&d, &s, &p);
let a = drag(crate::create("anchorPoint").as_mut(), &straight);
assert!(
matches!(a.last(), Some(Action::Preview(c, v)) if c == "object.blend.spine.reshapeSegment" && v["id"] == g.0 && v["segment"] == 0 && v["dy"] == 40.0),
"{a:?}"
);
let a = drag(&mut DirectSelectionTool::new(false), &straight);
assert!(!a.iter().any(|x| matches!(x, Action::Preview(c, _) if c == "object.blend.spine.reshapeSegment")), "straight: {a:?}");
// The spine bent upward: Direct Selection takes it from where it passes.
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(210.0, 310.0)], false);
sp.bend_segment(0, 0.5, vectorcraft_geom::Vec2::new(0.0, 20.0));
let Some(NodeKind::Blend { spec, .. }) = d.node_mut(g).map(|n| &mut n.kind) else { panic!("blend") };
(spec.spine, spec.key_anchors) = (Some(PathData::single(sp)), vec![0, 1]);
let mut t = DirectSelectionTool::new(false);
let curved = cx(&d, &s, &p);
t.pointer(&curved, &PointerEvent::new(PointerKind::Down, 160.0, 330.0));
let a = t.pointer(&curved, &PointerEvent::new(PointerKind::Drag, 160.0, 345.0));
assert_eq!(a[0], Action::Begin("Reshape Spine".into()));
assert!(
matches!(&a[1], Action::Preview(c, v) if c == "object.blend.spine.reshapeSegment" && v["segment"] == 0 && v["dx"] == 0.0 && v["dy"] == 15.0),
"{a:?}"
);
assert_eq!(t.pointer(&curved, &PointerEvent::new(PointerKind::Up, 160.0, 345.0)), vec![Action::Commit]);
}
#[test]
fn direct_and_group_selection_pick_blend_keys() {
let (d, g, k1) = blend_doc();

View File

@ -4,7 +4,7 @@
//! Delete: click an anchor to remove it, re-fitting the neighbouring curve (Alt = add).
//! Anchor Point: click a smooth anchor → corner; drag an anchor → pull out smooth handles; drag a
//! handle → move it independently (Shift: at 45° steps round its anchor); drag a segment → reshape
//! the curve.
//! the curve, a selected blend's spine's too, straight or curved (#1111).
//! Scissors: click a path to split it there.
use serde_json::json;
@ -19,9 +19,30 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool
#[derive(Clone, Copy, Debug)]
enum State {
Idle,
Convert { id: NodeId, si: usize, ai: usize, start: Point, began: bool },
Handle { id: NodeId, si: usize, ai: usize, out: bool, began: bool },
Reshape { id: NodeId, si: usize, seg: usize, t: f64, start: Point, began: bool },
Convert {
id: NodeId,
si: usize,
ai: usize,
start: Point,
began: bool,
},
Handle {
id: NodeId,
si: usize,
ai: usize,
out: bool,
began: bool,
},
/// Bending a segment: a path's, or (`spine`) a selected blend's spine's.
Reshape {
id: NodeId,
si: usize,
seg: usize,
t: f64,
start: Point,
began: bool,
spine: bool,
},
}
pub struct AnchorTool {
@ -94,7 +115,9 @@ impl Tool for AnchorTool {
} else if let Some((id, si, ai)) = hit_anchor(cx, p, point) {
self.state = State::Convert { id, si, ai, start: p, began: false };
} else if let Some((id, si, seg, t)) = hit_segment(cx, p, tol) {
self.state = State::Reshape { id, si, seg, t, start: p, began: false };
self.state = State::Reshape { id, si, seg, t, start: p, began: false, spine: false };
} else if let Some((id, seg, t, _)) = crate::direct::hit_spine_segment(cx, p, tol) {
self.state = State::Reshape { id, si: 0, seg, t, start: p, began: false, spine: true };
}
vec![]
}
@ -128,6 +151,10 @@ impl Tool for AnchorTool {
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out { "out" } else { "in" }, "x": q.x, "y": q.y, "independent": true}),
)
}
State::Reshape { id, seg, t, start, spine: true, .. } => Action::Preview(
"object.blend.spine.reshapeSegment".into(),
json!({"id": id.0, "segment": seg, "t": t, "dx": p.x - start.x, "dy": p.y - start.y}),
),
State::Reshape { id, si, seg, t, start, .. } => Action::Preview(
"path.reshapeSegment".into(),
json!({"id": id.0, "subpath": si, "segment": seg, "t": t, "dx": p.x - start.x, "dy": p.y - start.y}),

View File

@ -1545,6 +1545,7 @@
Move Rows Zeilen verschieben
Move Slices Slices verschieben
Move Spine Point Achsenpunkt verschieben
Reshape Spine Segment Achsensegment umformen
Move Swatch Farbfeld verschieben
Move Up Nach oben verschieben
Move… Verschieben…

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1563,6 +1563,7 @@
Move Rows Mover filas
Move Slices Mover sectores
Move Spine Point Mover punto del eje
Reshape Spine Segment Remodelar segmento del eje
Move Swatch Mover muestra
Move Up Subir
Move… Mover…

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1562,6 +1562,7 @@
Move Rows Déplacer les lignes
Move Slices Déplacer les tranches
Move Spine Point Déplacer le point de la dorsale
Reshape Spine Segment Modeler le segment de la dorsale
Move Swatch Déplacer la nuance
Move Up Monter
Move… Déplacer…

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1563,6 +1563,7 @@
Move Rows Sposta righe
Move Slices Sposta sezioni
Move Spine Point Sposta punto dorsale
Reshape Spine Segment Rimodella segmento della dorsale
Move Swatch Sposta campione
Move Up Sposta su
Move… Sposta…

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1697,6 +1697,7 @@
Move Rows 行を移動
Move Slices スライスを移動
Move Spine Point スパインポイントを移動
Reshape Spine Segment スパインセグメントをシェイプ変形
Move Swatch スウォッチを移動
Move Up 上へ移動
Move in Perspective 遠近で移動

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1334,6 +1334,7 @@
Move Plane to Match Object Mover Plano para Corresponder ao Objeto
Move Slices Mover Fatias
Move Spine Point Mover Ponto de Coluna
Reshape Spine Segment Remodelar Segmento da Coluna
Move Swatch Mover Amostra
Move Up Mover para Cima
Move in Perspective Mover em Perspectiva

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1561,6 +1561,7 @@
Move Rows Переместить строки
Move Slices Переместить фрагменты
Move Spine Point Переместить точку остова
Reshape Spine Segment Изменить форму сегмента остова
Move Swatch Переместить образец
Move Up Переместить вверх
Move… Переместить…

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1557,6 +1557,7 @@
Move Rows Перемістити рядки
Move Slices Перемістити фрагменти
Move Spine Point Перемістити точку осі
Reshape Spine Segment Змінити форму сегмента осі
Move Swatch Перемістити зразок
Move Up Перемістити вгору
Move… Перемістити…

Can't render this file because it has a wrong number of fields in line 13.

View File

@ -1494,6 +1494,7 @@
Move Plane to Match Object 移动平面以符合对象
Move Slices 移动切片
Move Spine Point 移动脊线点
Reshape Spine Segment 整形脊线线段
Move Swatch 移动色板
Move Up 上移
Move… 移动…

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1538,6 +1538,7 @@
Move Rows 移動列
Move Slices 移動切片
Move Spine Point 移動脊線點
Reshape Spine Segment 改變脊線線段外框
Move Swatch 移動色票
Move Up 上移
Move… 移動…

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -692,7 +692,9 @@ new blends start with. The Blend tool (`ui.tool.select {tool: "blend"}`): `ui.po
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
reshapes the curve, and a drag on a curved spine segment bends it (`object.blend.spine.reshapeSegment`); the Anchor
Point tool (`anchorPoint`) bends any segment of a selected blend's spine, straight ones too. 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).
Perspective grid view options: View → Perspective Grid lists Show/Hide Grid, Show/Hide Rulers, Snap to Grid

View File

@ -3077,8 +3077,9 @@ among the objects takes the others in as more key objects, keeping its options,
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
`handle: "in" | "out"` it places that handle), `object.blend.spine.reshapeSegment {segment, t, dx, dy}` bends a segment
so its point at `t` moves by (dx, dy), turning a straight spine into a curve, 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