From e8e88c14f27f4b5d64fc4c89427cca57dc7c49f8 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sun, 18 Oct 2026 13:17:17 +0300 Subject: [PATCH] Fix #2723: Reshape falloff uses arc length along the path Co-authored-by: Cursor --- crates/engine/src/cmd/path.rs | 31 ++++++++++++++++--------------- crates/engine/src/tests.rs | 23 +++++++++++++++++++++++ crates/geom/src/path.rs | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 9a82de1e2..4d3b5d870 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -100,7 +100,7 @@ pub fn specs() -> Vec { "Reshape", [], None, - "{id, x, y, dx, dy, tol=3, radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy); nearby anchors follow with a smooth falloff over radius (default: half the subpath's size)", + "{id, x, y, dx, dy, tol=3, radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy); nearby anchors follow with a smooth falloff over radius along the path (default: half the subpath's length)", has_doc, reshape ), @@ -680,21 +680,22 @@ fn reshape(s: &mut Session, p: &Value) -> Result { Some((i, dist)) if dist <= tol => i, _ => sp.insert_anchor(seg, t), }; - let origin = sp.anchors[grabbed].p; - 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 r = radius.unwrap_or_else(|| (sp.arc_length() / 2.0).max(1.0)); + let weights: Vec = sp + .anchors + .iter() + .enumerate() + .map(|(i, _)| { + if i == grabbed { + 1.0 + } else { + let q = (sp.anchor_arc_distance(grabbed, i) / r).min(1.0); + (1.0 - q * q).powi(2) + } + }) + .collect(); for (i, a) in sp.anchors.iter_mut().enumerate() { - let w = if i == grabbed { - 1.0 - } else { - let q = (a.p.distance(origin) / r).min(1.0); - (1.0 - q * q).powi(2) - }; - let v = delta * w; + let v = delta * weights[i]; a.p += v; a.h_in += v; a.h_out += v; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5213d8aba..53278f3b3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2343,6 +2343,29 @@ fn reshape_moves_the_grabbed_point_and_its_neighbourhood() { assert!(pts.iter().filter(|p| (p.y - 100.0).abs() < 1e-9).count() == 2); } +#[test] +fn reshape_falloff_follows_arc_length_on_open_paths() { + let mut s = session(); + let id = NodeId( + s.execute( + "path.create", + &json!({ + "anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 200, "y": 0}, {"x": 300, "y": 0}], + "closed": false + }), + ) + .unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap(); + let pts: Vec<_> = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect(); + assert!((pts[3].y - 100.0).abs() < 1e-6, "grabbed end moves full delta: {pts:?}"); + assert!(pts[0].y.abs() < 1e-6, "far end stays put: {pts:?}"); + assert!(pts[2].y > 1.0 && pts[2].y < 100.0, "mid-anchor moves partially: {pts:?}"); + assert!(pts[1].y < pts[2].y, "nearer anchors move more: {pts:?}"); +} + #[test] fn graph_tool_drag_creates_a_graph_and_asks_for_data() { let mut s = session(); diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index 31866ed97..1a7bfbde8 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -221,6 +221,38 @@ impl SubPath { let b = &self.anchors[(i + 1) % n]; !a.has_out() && !b.has_in() } + /// Arc-length at each anchor from the start of this subpath (one entry per anchor). + pub fn anchor_arc_positions(&self) -> Vec { + let n = self.anchors.len(); + if n == 0 { + return vec![]; + } + let mut pos = vec![0.0]; + for i in 0..self.segment_count() { + let len = kurbo::ParamCurveArclen::arclen(&self.segment(i), 1e-6); + pos.push(pos[i] + len); + } + pos + } + /// Total length of all segments. + pub fn arc_length(&self) -> f64 { + self.anchor_arc_positions().last().copied().unwrap_or(0.0) + } + /// Shortest distance along this subpath between two anchors. + pub fn anchor_arc_distance(&self, a: usize, b: usize) -> f64 { + let pos = self.anchor_arc_positions(); + let n = self.anchors.len(); + if a >= n || b >= n { + return f64::INFINITY; + } + let d = (pos[a] - pos[b]).abs(); + if self.closed { + let total = pos.last().copied().unwrap_or(0.0); + if total > 0.0 { d.min(total - d) } else { d } + } else { + d + } + } pub fn to_bezpath_into(&self, out: &mut BezPath) { let Some(first) = self.anchors.first() else { return }; out.move_to(first.p);