From e8e88c14f27f4b5d64fc4c89427cca57dc7c49f8 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sun, 18 Oct 2026 13:17:17 +0300 Subject: [PATCH 1/2] 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); From 4f526b4e3f383487109dea013b5154eae1fa5746 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 19:52:07 +0700 Subject: [PATCH 2/2] Reshape: the lengths along the path are measured once per drag, and the default reach stays the path's size (#2900) anchor_arc_distance measured every segment again for each anchor, so a reshape cost the square of the anchor count; path.reshape now measures the positions once, and the two helpers only it used are gone. The default radius stays half the subpath's bounding-box diagonal, now measured along the path: half its length reached the far corners of a square, moving art the grab is nowhere near (reshape_moves_the_grabbed_point_and_its_neighbourhood). --- crates/engine/src/cmd/path.rs | 40 +++++++++++++++++++++-------------- crates/engine/src/tests.rs | 4 ++-- crates/geom/src/path.rs | 23 ++------------------ 3 files changed, 28 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 4d3b5d870..0e2a8e0b0 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 along the path (default: half the subpath's length)", + "{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, measured along the path (default: half the subpath's size)", has_doc, reshape ), @@ -680,22 +680,30 @@ fn reshape(s: &mut Session, p: &Value) -> Result { Some((i, dist)) if dist <= tol => i, _ => sp.insert_anchor(seg, t), }; - 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(); + // 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 is the subpath's size, as before, now measured along it. + 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); + let closed = sp.closed; for (i, a) in sp.anchors.iter_mut().enumerate() { - let v = delta * weights[i]; + let w = if i == grabbed { + 1.0 + } else { + let d = (pos.get(i).copied().unwrap_or(f64::INFINITY) - from).abs(); + let d = if closed { d.min(total - d) } else { d }; + // A distance or radius that isn't finite gives NaN here, which `min` reads as 1: no move. + let q = (d / r).min(1.0); + (1.0 - q * q).powi(2) + }; + let v = delta * w; 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 53278f3b3..06c7ec2a4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2355,8 +2355,8 @@ fn reshape_falloff_follows_arc_length_on_open_paths() { }), ) .unwrap()["id"] - .as_u64() - .unwrap(), + .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(); diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index 1a7bfbde8..110997efc 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -229,30 +229,11 @@ impl SubPath { } 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); + let len = kurbo::ParamCurveArclen::arclen(&self.segment(i), 1e-3); + pos.push(pos.last().copied().unwrap_or(0.0) + 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);