Fix #2723: Reshape falloff uses arc length along the path

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
nrq6993-a11y
2026-10-18 20:12:47 +07:00
committed by bflatastic
co-authored by Cursor
parent f2601137ab
commit e8e88c14f2
3 changed files with 71 additions and 15 deletions
+16 -15
View File
@@ -100,7 +100,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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<f64> = 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;
+23
View File
@@ -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();
+32
View File
@@ -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<f64> {
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);