From 4110f8c24e7419c3d437482746638ffc5311c1f0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 22:30:18 -0800 Subject: [PATCH] Reshape: a step recorded without focal points plays by the earlier rule Before the Reshape tool had focal points it sent path.reshape without focal or radius, and the command stretched an open path from the grabbed point to its ends whatever the selection, and moved the anchors of a closed path within half the diagonal of its bounding box with a falloff. Such a step, in a saved action or a script, now moved a closed path whole. A step without focal (or with focal null) and without radius now plays by that earlier rule. The Reshape tool always sends focal, an empty list included, so its drags keep the focal-point rule. --- crates/engine/src/cmd/path.rs | 23 +++++++++++++++++----- crates/engine/src/tests.rs | 36 ++++++++++++++++++++++++++++------- crates/tools/src/extra.rs | 7 ++----- docs/mcp.md | 11 +++++++---- 4 files changed, 56 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index e249a23fc..d6c532224 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -131,7 +131,7 @@ pub fn specs() -> Vec { "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 ), @@ -1573,6 +1573,11 @@ fn reshape(s: &mut Session, p: &Value) -> Result { 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))?; @@ -1605,11 +1610,12 @@ fn reshape(s: &mut Session, p: &Value) -> Result { 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; @@ -1629,13 +1635,20 @@ fn reshape(s: &mut Session, p: &Value) -> Result { } } 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 { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 72b6d3ea8..217d31e01 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3217,9 +3217,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); @@ -3478,7 +3477,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); @@ -3492,7 +3491,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 = 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. @@ -3502,6 +3501,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 = 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 = 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(); @@ -3516,7 +3538,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 = 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 @@ -3571,7 +3593,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> = 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:?}"); diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index bcf7d6e65..4f8e867a5 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -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 }) } } diff --git a/docs/mcp.md b/docs/mcp.md index 19f16205c..d01aa4a74 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3681,8 +3681,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 @@ -3695,8 +3695,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