Merge pull request #6534 from dotCooCoo/fix/reshape-earlier-steps

Reshape: a step recorded without focal points plays by the earlier rule
This commit is contained in:
BFlat
2026-11-11 14:48:26 +07:00
committed by GitHub
4 changed files with 56 additions and 21 deletions
+18 -5
View File
@@ -131,7 +131,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reshape",
[],
None,
"{id, x, y, dx, dy, tol=3, focal?: [[subpath, anchor]…], radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy). The grabbed anchor and the anchors in focal are focal points and move by the whole drag. Anchors that are not direct-selected stay in place (a path selected whole, or not selected, has every anchor selected), and so do an open path's two ends unless they are focal points. A selected anchor between two focal points moves whole; one between a focal point and an anchor that stays takes a share of the drag that falls smoothly from 1 at the focal point to 0 at the anchor that stays, by its distance along the path's control polygon. A closed subpath with every anchor selected moves whole; a subpath with no focal point does not move. With radius, only the grabbed point's subpath changes: anchors follow with a smooth falloff over radius, measured along the path (0 or more), whatever the selection; focal and radius cannot be used together",
"{id, x, y, dx, dy, tol=3, focal?: [[subpath, anchor]…], radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy). The grabbed anchor and the anchors in focal are focal points and move by the whole drag. Anchors that are not direct-selected stay in place (a path selected whole, or not selected, has every anchor selected), and so do an open path's two ends unless they are focal points. A selected anchor between two focal points moves whole; one between a focal point and an anchor that stays takes a share of the drag that falls smoothly from 1 at the focal point to 0 at the anchor that stays, by its distance along the path's control polygon. A closed subpath with every anchor selected moves whole; a subpath with no focal point does not move. With radius, only the grabbed point's subpath changes: anchors follow with a smooth falloff over radius, measured along the path (0 or more), whatever the selection; focal and radius cannot be used together. Without focal (or with focal null) and without radius, the earlier rule applies: the selection does not matter, an open path stretches from the grabbed point to its two ends, and on a closed path the anchors follow with the falloff over half the diagonal of the subpath's bounding box; the Reshape tool always sends focal",
has_doc,
reshape
),
@@ -1581,6 +1581,11 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
if radius.is_some() && !focal.is_empty() {
return Err(bad("path.reshape", "`focal` and `radius` cannot be used together"));
}
// A step that names neither `focal` nor `radius` (as the Reshape tool recorded one before it
// had focal points) keeps that earlier rule: the selection does not matter, an open path
// stretches from the grabbed point to its two ends, and a closed one follows with a falloff
// over the default radius.
let earlier = radius.is_none() && matches!(p.get("focal"), None | Some(Value::Null));
{
let st = s.doc()?;
let node = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
@@ -1613,11 +1618,12 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
added
}
};
let Some(r) = radius else {
let earlier_closed = earlier && sp.closed;
if radius.is_none() && !earlier_closed {
focal.insert((si, grabbed));
// Anchors that are not direct-selected stay; a path selected whole, or not selected,
// has every anchor selected.
let selected = sel.anchors.get(&id);
// has every anchor selected, and so does every path under the earlier rule.
let selected = sel.anchors.get(&id).filter(|_| !earlier);
for (k, sp) in path.subpaths.iter_mut().enumerate() {
if focal.range((k, 0)..=(k, usize::MAX)).next().is_none() {
continue;
@@ -1637,13 +1643,20 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
}
}
return Ok(());
};
}
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("empty path".into()))?;
let closed = sp.closed;
// Each anchor's distance along the path from the grabbed one, the shorter way round a
// closed path: the lengths are measured once, as a path can have many anchors.
let pos = sp.anchor_arc_positions();
let total = pos.last().copied().unwrap_or(0.0);
// The default reach of the earlier rule: half the diagonal of the subpath's bounding box.
let r = radius.unwrap_or_else(|| {
let mut bp = vectorcraft_geom::BezPath::new();
sp.to_bezpath_into(&mut bp);
let b = vectorcraft_geom::Shape::bounding_box(&bp);
(b.width().hypot(b.height()) / 2.0).max(1.0)
});
let from = pos.get(grabbed).copied().unwrap_or(0.0);
for (i, a) in sp.anchors.iter_mut().enumerate() {
let w = if i == grabbed {
+29 -7
View File
@@ -3219,9 +3219,8 @@ fn flare_rotation_turns_only_the_rays() {
fn reshape_moves_the_grabbed_point_and_its_neighbourhood() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
// Grab the middle of the top edge: a new anchor appears there and moves the full delta. The
// radius, about half the square's diagonal, bounds the reach.
s.execute("path.reshape", &json!({"id": r.0, "x": 50.0, "y": 0.0, "dx": 0.0, "dy": -40.0, "radius": 70.0})).unwrap();
// Grab the middle of the top edge: a new anchor appears there and moves the full delta.
s.execute("path.reshape", &json!({"id": r.0, "x": 50.0, "y": 0.0, "dx": 0.0, "dy": -40.0})).unwrap();
let st = s.doc().unwrap();
let pd = st.doc.node(r).unwrap().path_data().unwrap().clone();
assert_eq!(pd.anchor_count(), 5);
@@ -3480,7 +3479,7 @@ fn reshape_of_a_closed_path_selected_whole_moves_all_of_it() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let before = s.doc().unwrap().doc.node(r).unwrap().path_data().unwrap().clone();
s.execute("path.reshape", &json!({"id": r.0, "x": 0.0, "y": 0.0, "dx": 15.0, "dy": -40.0})).unwrap();
s.execute("path.reshape", &json!({"id": r.0, "x": 0.0, "y": 0.0, "dx": 15.0, "dy": -40.0, "focal": []})).unwrap();
let after = s.doc().unwrap().doc.node(r).unwrap().path_data().unwrap().clone();
assert_eq!(after.anchor_count(), before.anchor_count(), "a grab on an anchor adds none");
let drag = vectorcraft_geom::Vec2::new(15.0, -40.0);
@@ -3494,7 +3493,7 @@ fn reshape_leaves_the_anchors_that_are_not_selected_in_place() {
let mut s = session();
let id = reshape_open_path(&mut s, reshape_line_of_seven());
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2], [0, 3], [0, 4], [0, 5]], "mode": "set"})).unwrap();
s.execute("path.reshape", &json!({"id": id.0, "x": 350.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap();
s.execute("path.reshape", &json!({"id": id.0, "x": 350.0, "y": 0.0, "dx": 0.0, "dy": 100.0, "focal": []})).unwrap();
let y: Vec<f64> = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect();
assert_eq!((y[0], y[1], y[5], y[6]), (0.0, 0.0, 100.0, 0.0), "{y:?}");
// The stretch runs from the last anchor that is not selected, 280 from the focal point.
@@ -3504,6 +3503,29 @@ fn reshape_leaves_the_anchors_that_are_not_selected_in_place() {
}
}
/// A step with neither `focal` nor `radius`, as one recorded before focal points, plays as it did
/// then: the selection does not matter, and a closed path follows with the default falloff.
#[test]
fn reshape_without_focal_or_radius_keeps_the_earlier_rule() {
let mut s = session();
let id = reshape_open_path(&mut s, reshape_line_of_seven());
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2], [0, 3], [0, 4], [0, 5]], "mode": "set"})).unwrap();
s.execute("path.reshape", &json!({"id": id.0, "x": 350.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap();
let y: Vec<f64> = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect();
assert_eq!((y[0], y[5], y[6]), (0.0, 100.0, 0.0), "{y:?}");
// Anchor 1 is not selected and still takes its share, measured from the end 350 away.
for i in 1..5 {
let u = i as f64 / 5.0;
assert!((y[i] - 100.0 * reshape_share(u)).abs() < 1e-9, "anchor {i} takes {u} of the way: {y:?}");
}
// `focal: null` is the same as leaving it out.
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
s.execute("path.reshape", &json!({"id": r.0, "x": 0.0, "y": 0.0, "dx": 0.0, "dy": -40.0, "focal": null})).unwrap();
let pts: Vec<Point> = s.doc().unwrap().doc.node(r).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| a.p).collect();
assert!(pts.contains(&Point::new(0.0, -40.0)), "{pts:?}");
assert!(pts.contains(&Point::new(100.0, 100.0)), "the far corner stays: {pts:?}");
}
#[test]
fn reshape_of_a_partly_selected_closed_path_stretches_between_the_anchors_that_stay() {
let mut s = session();
@@ -3518,7 +3540,7 @@ fn reshape_of_a_partly_selected_closed_path_stretches_between_the_anchors_that_s
);
// Anchors 5, 0, 1 and 2 are selected; 3 and 4 stay. The walk runs 4, 5, 0, 1, 2, 3.
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 5], [0, 0], [0, 1], [0, 2]], "mode": "set"})).unwrap();
s.execute("path.reshape", &json!({"id": id.0, "x": 0.0, "y": 0.0, "dx": 0.0, "dy": -50.0})).unwrap();
s.execute("path.reshape", &json!({"id": id.0, "x": 0.0, "y": 0.0, "dx": 0.0, "dy": -50.0, "focal": []})).unwrap();
let y: Vec<f64> = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect();
assert_eq!((y[0], y[3], y[4]), (-50.0, 100.0, 100.0), "{y:?}");
// From anchor 4 to the focal point is 200: anchor 5 is halfway. From anchor 3 back to it is
@@ -3573,7 +3595,7 @@ fn reshape_of_a_segment_keeps_a_selected_segment_as_its_two_halves() {
fn reshape_moves_only_the_subpaths_with_a_focal_point() {
let mut s = session();
let id = NodeId(s.execute("path.create", &json!({"d": "M0 0 L100 0 L200 0 M0 100 L100 100 L200 100"})).unwrap()["id"].as_u64().unwrap());
s.execute("path.reshape", &json!({"id": id.0, "x": 200.0, "y": 0.0, "dx": 0.0, "dy": 40.0})).unwrap();
s.execute("path.reshape", &json!({"id": id.0, "x": 200.0, "y": 0.0, "dx": 0.0, "dy": 40.0, "focal": []})).unwrap();
let pd = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone();
let y: Vec<Vec<f64>> = pd.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| a.p.y).collect()).collect();
assert_eq!(y[1], vec![100.0, 100.0, 100.0], "{y:?}");
+2 -5
View File
@@ -402,11 +402,8 @@ impl ReshapeTool {
/// `path.reshape`'s params for `press` dragged by `d`.
fn params(cx: &ToolContext, press: &ReshapePress, d: Vec2) -> Value {
let others: Vec<[usize; 2]> = press.others.iter().map(|&((si, ai), _)| [si, ai]).collect();
let mut v = json!({ "id": press.id.0, "x": press.at.x, "y": press.at.y, "dx": d.x, "dy": d.y, "tol": cx.tol(4.0) });
if !others.is_empty() {
v["focal"] = json!(others);
}
v
// `focal` is sent when empty too: without it, `path.reshape` follows the earlier rule.
json!({ "id": press.id.0, "x": press.at.x, "y": press.at.y, "dx": d.x, "dy": d.y, "tol": cx.tol(4.0), "focal": others })
}
}
+7 -4
View File
@@ -3769,8 +3769,8 @@ anchor there unless one lies within `tol` (3 by default), and moves that point b
[Adjust path segments](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/adjust-path-segments.html)
has the Reshape tool stretch part of a path from the dragged point without losing the path's general form, with every
highlighted focal point pulling the path. Which anchors stay, how far the stretch reaches and the share of the drag each
point takes are VectorCraft's own rule. The grabbed point and the anchors that `focal` lists as `[subpath, anchor]` pairs are focal
points and move by the whole drag; a pair that names no anchor of the path is refused ("`focal` names [0, 7], which is
point takes are VectorCraft's own rule. When `focal` is given (an empty list included), the grabbed point and the
anchors that `focal` lists as `[subpath, anchor]` pairs are focal points and move by the whole drag; a pair that names no anchor of the path is refused ("`focal` names [0, 7], which is
not an anchor of the path"). Anchors that are not direct-selected stay in place (on a path selected whole, or not
selected, every anchor is selected), and so do the two ends of an open path unless they are focal points. A selected
anchor between two focal points moves by the whole drag. A selected anchor between a focal point and an anchor that
@@ -3783,8 +3783,11 @@ point does not move. An anchor added on a segment of a partly selected path is s
anchors stay selected. With `radius`, only the grabbed point's subpath changes, whatever the selection: the anchors
within `radius` of the grabbed point along the path follow with a smooth falloff and the others stay; a negative
`radius` is refused ("`radius` must be 0 or more"), and so is `radius` given with `focal` ("`focal` and `radius`
cannot be used together"). A drag of no length on an anchor (`dx` and `dy` 0 or left out) leaves the path as it is
and records no undo step.
cannot be used together"). Without `focal` (or with `focal` null) and without `radius`, the earlier rule applies, so
steps recorded before focal points play as they did: the selection does not matter, an open path stretches from the
grabbed point to its two ends as above, and on a closed path the anchors within half the diagonal of the subpath's
bounding box follow with the smooth falloff. The Reshape tool always sends `focal`. A drag of no length on an anchor
(`dx` and `dy` 0 or left out) leaves the path as it is and records no undo step.
With the Reshape tool, a click on an anchor of a selected path makes it the focal point, and a click on a segment adds
an anchor there (one undo step) that becomes the focal point. Shift-click adds an anchor or a point on a segment to the