diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 9a82de1e2..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 (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, measured along the path (default: half the subpath's size)", has_doc, reshape ), @@ -680,18 +680,27 @@ 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; + // 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 w = if i == grabbed { 1.0 } else { - let q = (a.p.distance(origin) / r).min(1.0); + 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; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5213d8aba..06c7ec2a4 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..110997efc 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -221,6 +221,19 @@ 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-3); + pos.push(pos.last().copied().unwrap_or(0.0) + len); + } + pos + } pub fn to_bezpath_into(&self, out: &mut BezPath) { let Some(first) = self.anchors.first() else { return }; out.move_to(first.p);