Merge pull request #6492 from dotCooCoo/fix/reshape-stretches-whole-path

Reshape: a drag stretches an open path from the dragged point to its two ends
This commit is contained in:
BFlat
2026-11-11 08:31:45 +07:00
committed by GitHub
5 changed files with 305 additions and 8 deletions
+83 -5
View File
@@ -131,7 +131,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, measured along the path (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). An open path stretches from the grabbed point to its two ends, which stay in place: each anchor between takes a share of the drag that falls smoothly from 1 at the grabbed point to 0 at the end, by its distance along the path's control polygon. On a closed path, or when radius is given, nearby anchors follow with a smooth falloff over radius, measured along the path (0 or more; default: half the diagonal of the subpath's bounding box)",
has_doc,
reshape
),
@@ -1410,6 +1410,57 @@ fn open_run(mut anchors: Vec<vectorcraft_geom::Anchor>) -> Option<SubPath> {
Some(SubPath::new(anchors, false))
}
/// The share of a Reshape drag that each anchor of an open subpath takes, as `[in handle, anchor,
/// out handle]`. The grabbed anchor takes all of it and the two end anchors take none. For an
/// anchor between the grabbed one and an end, `u` is its distance from that end along the control
/// polygon (anchor, out handle, the next in handle, the next anchor) divided by the grabbed
/// anchor's distance from that end, and its share is `u² (3 - 2u)`. A handle takes the share on
/// the tangent line of that curve at its anchor, so the two handles of a smooth anchor stay in
/// line with it. That share is below 0 or above 1 for a handle long enough beside the span.
fn stretch_weights(anchors: &[Anchor], grabbed: usize) -> Vec<[f64; 3]> {
let last = anchors.len().saturating_sub(1);
// Where each anchor's in handle, point and out handle lie along the control polygon. The in
// handle of the first anchor and the out handle of the last are not part of an open path.
let mut pos: Vec<[f64; 3]> = Vec::with_capacity(anchors.len());
let mut run = 0.0;
for (i, a) in anchors.iter().enumerate() {
if let Some(before) = i.checked_sub(1).and_then(|b| anchors.get(b)) {
run += before.h_out.distance(a.h_in);
}
let c_in = run;
if i > 0 {
run += a.h_in.distance(a.p);
}
let c = run;
if i < last {
run += a.p.distance(a.h_out);
}
pos.push([c_in, c, run]);
}
let at = pos.get(grabbed).map_or(0.0, |c| c[1]);
let length = pos.last().map_or(0.0, |c| c[1]);
pos.iter()
.enumerate()
.map(|(i, &[c_in, c, c_out])| {
if i == grabbed {
return [1.0; 3];
}
let end = if i < grabbed { 0.0 } else { length };
let span = (at - end).abs();
if i == 0 || i == last || span <= 0.0 || !span.is_finite() {
return [0.0; 3];
}
let along = |c: f64| (c - end).abs() / span;
let u = along(c);
let w = u * u * (3.0 - 2.0 * u);
let slope = 6.0 * u * (1.0 - u);
let shares = [w + slope * (along(c_in) - u), w, w + slope * (along(c_out) - u)];
// A coordinate that isn't finite moves nothing.
if shares.iter().all(|s| s.is_finite()) { shares } else { [0.0; 3] }
})
.collect()
}
fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?;
let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?);
@@ -1417,14 +1468,42 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
let delta = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let tol = f64_or(p, "tol", 3.0).max(0.0);
let radius = p.get("radius").and_then(Value::as_f64);
if radius.is_some_and(|r| r < 0.0) {
return Err(bad("path.reshape", "`radius` must be 0 or more"));
}
// The anchor within `tol` of the grab point, if there is one.
let near = |sp: &SubPath| match sp.anchors.iter().enumerate().map(|(i, a)| (i, a.p.distance(at))).min_by(|a, b| a.1.total_cmp(&b.1)) {
Some((i, dist)) if dist <= tol => Some(i),
_ => None,
};
// A drag of no length on an anchor moves nothing, so it is not an edit.
if delta == Vec2::ZERO {
let st = s.doc()?;
let node = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
let path = node.path_data().ok_or_else(|| EngineError::Other(format!("object {id} is not a path")))?;
let (si, ..) = path.nearest(at).ok_or_else(|| EngineError::Other("empty path".into()))?;
if path.subpaths.get(si).is_some_and(|sp| near(sp).is_some()) {
return ok();
}
}
s.edit("Reshape", |d, _| {
let path = path_mut(d, id)?;
let (si, seg, t, _, _) = path.nearest(at).ok_or_else(|| EngineError::Other("empty path".into()))?;
let sp = &mut path.subpaths[si];
let grabbed = match sp.anchors.iter().enumerate().map(|(i, a)| (i, a.p.distance(at))).min_by(|a, b| a.1.total_cmp(&b.1)) {
Some((i, dist)) if dist <= tol => i,
_ => sp.insert_anchor(seg, t),
let grabbed = match near(sp) {
Some(i) => i,
None => sp.insert_anchor(seg, t),
};
let closed = sp.closed;
if radius.is_none() && !closed {
let weights = stretch_weights(&sp.anchors, grabbed);
for (a, [w_in, w, w_out]) in sp.anchors.iter_mut().zip(weights) {
a.p += delta * w;
a.h_in += delta * w_in;
a.h_out += delta * w_out;
}
return Ok(());
}
// 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();
@@ -1437,7 +1516,6 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
(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
+203 -1
View File
@@ -3243,7 +3243,7 @@ fn reshape_falloff_follows_arc_length_on_open_paths() {
.as_u64()
.unwrap(),
);
s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap();
s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 0.0, "dx": 0.0, "dy": 100.0, "radius": 250.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:?}");
@@ -3251,6 +3251,208 @@ fn reshape_falloff_follows_arc_length_on_open_paths() {
assert!(pts[1].y < pts[2].y, "nearer anchors move more: {pts:?}");
}
/// An open path for the Reshape tests.
fn reshape_open_path(s: &mut Session, anchors: Value) -> NodeId {
NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": false})).unwrap()["id"].as_u64().unwrap())
}
/// The anchors of the path's first subpath.
fn reshape_anchors(s: &Session, id: NodeId) -> Vec<vectorcraft_geom::Anchor> {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors.clone()
}
/// The share of the drag an anchor takes at `u` of the way from an end to the grabbed point.
fn reshape_share(u: f64) -> f64 {
u * u * (3.0 - 2.0 * u)
}
/// Six anchors 100 apart along a zigzag 300 wide and 80 high.
fn reshape_zigzag() -> Value {
json!([{"x": 0, "y": 0}, {"x": 60, "y": 80}, {"x": 120, "y": 0}, {"x": 180, "y": 80}, {"x": 240, "y": 0}, {"x": 300, "y": 80}])
}
#[test]
fn reshape_of_an_end_stretches_an_open_path_to_its_other_end() {
let mut s = session();
let id = reshape_open_path(&mut s, reshape_zigzag());
let before = reshape_anchors(&s, id);
s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 80.0, "dx": 20.0, "dy": 50.0})).unwrap();
let after = reshape_anchors(&s, id);
assert_eq!(after.len(), 6);
let moved: Vec<vectorcraft_geom::Vec2> = after.iter().zip(&before).map(|(a, b)| a.p - b.p).collect();
assert_eq!(moved[0], vectorcraft_geom::Vec2::ZERO, "the other end stays in place: {moved:?}");
for (i, m) in moved.iter().enumerate() {
let share = reshape_share(i as f64 / 5.0);
assert!((m.x - 20.0 * share).abs() < 1e-9 && (m.y - 50.0 * share).abs() < 1e-9, "anchor {i} takes {share} of the drag: {moved:?}");
}
assert!(moved.windows(2).all(|w| w[0].y < w[1].y), "each anchor moves less than the one after it: {moved:?}");
// The path has no handles, and gets none.
assert!(after.iter().all(|a| a.h_in == a.p && a.h_out == a.p), "{after:?}");
}
#[test]
fn reshape_of_a_middle_anchor_leaves_both_ends_of_an_open_path_in_place() {
let mut s = session();
let id = reshape_open_path(&mut s, json!([{"x": 0, "y": 0}, {"x": 30, "y": 0}, {"x": 200, "y": 0}, {"x": 230, "y": 0}, {"x": 400, "y": 0}]));
s.execute("path.reshape", &json!({"id": id.0, "x": 200.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap();
let y: Vec<f64> = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect();
assert_eq!(y.len(), 5, "the grab was on an anchor, so none is added");
assert_eq!((y[0], y[2], y[4]), (0.0, 100.0, 0.0), "{y:?}");
// Each side is measured from its own end: 30 of 200 on the left, 170 of 200 on the right.
assert!((y[1] - 100.0 * reshape_share(0.15)).abs() < 1e-9, "{y:?}");
assert!((y[3] - 100.0 * reshape_share(0.85)).abs() < 1e-9, "{y:?}");
}
#[test]
fn reshape_keeps_the_handles_of_a_smooth_anchor_in_line() {
let mut s = session();
let id = reshape_open_path(
&mut s,
json!([
{"x": 0, "y": 0, "out": [40, 0]},
{"x": 100, "y": 0, "in": [60, 0], "out": [140, 0], "smooth": true},
{"x": 200, "y": 0, "in": [160, 0]}
]),
);
s.execute("path.reshape", &json!({"id": id.0, "x": 200.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap();
let a = reshape_anchors(&s, id);
// The control polygon is 200 long and the middle anchor is halfway: it takes half the drag,
// and its handles, 40 either side, take 0.2 and 0.8 of it.
assert!((a[1].p.y - 50.0).abs() < 1e-9 && (a[1].h_in.y - 20.0).abs() < 1e-9 && (a[1].h_out.y - 80.0).abs() < 1e-9, "{a:?}");
let (to_in, to_out) = (a[1].h_in - a[1].p, a[1].h_out - a[1].p);
assert!(to_in.cross(to_out).abs() < 1e-9 && to_in.dot(to_out) < 0.0, "the handles stay opposite each other: {a:?}");
// The end that stays keeps its handle, and the grabbed end's handle moves with it.
assert_eq!((a[0].p, a[0].h_out), (Point::new(0.0, 0.0), Point::new(40.0, 0.0)));
assert_eq!((a[2].p, a[2].h_in), (Point::new(200.0, 100.0), Point::new(160.0, 100.0)));
}
/// A `radius` still bounds the reach on an open path, as it did before the path stretched to its ends.
#[test]
fn reshape_with_a_radius_moves_only_the_anchors_within_it() {
let mut s = session();
let id = reshape_open_path(&mut s, reshape_zigzag());
s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 80.0, "dx": 0.0, "dy": 50.0, "radius": 150.0})).unwrap();
let y: Vec<f64> = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect();
assert_eq!(&y[..4], &[0.0, 80.0, 0.0, 80.0], "anchors 200 or more along the path stay: {y:?}");
assert!(y[4] > 0.0 && y[4] < 50.0 && y[5] == 130.0, "{y:?}");
}
#[test]
fn reshape_measures_an_open_path_along_its_control_polygon() {
let mut s = session();
// The first segment's control polygon is 60 + 100 + 60 long, its chord 100 and its arc 150 to
// 160; the second segment is a line 100 long.
let id = reshape_open_path(&mut s, json!([{"x": 0, "y": 0, "out": [0, 60]}, {"x": 100, "y": 0, "in": [100, 60]}, {"x": 200, "y": 0}]));
s.execute("path.reshape", &json!({"id": id.0, "x": 200.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap();
let a = reshape_anchors(&s, id);
let u: f64 = 220.0 / 320.0;
assert!((a[1].p.y - 100.0 * reshape_share(u)).abs() < 1e-9, "{a:?}");
// The in handle lies 160 along the polygon, and takes the share on the tangent line at its anchor.
let handle = reshape_share(u) + 6.0 * u * (1.0 - u) * (160.0 / 320.0 - u);
assert!((a[1].h_in.y - 60.0 - 100.0 * handle).abs() < 1e-9, "{a:?}");
// The end that stays keeps its handle.
assert_eq!((a[0].p, a[0].h_out), (Point::new(0.0, 0.0), Point::new(0.0, 60.0)));
}
#[test]
fn reshape_of_a_point_on_a_segment_stretches_an_open_path_from_the_new_anchor() {
let mut s = session();
let id = reshape_open_path(&mut s, json!([{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 200, "y": 0}, {"x": 300, "y": 0}]));
s.execute("path.reshape", &json!({"id": id.0, "x": 250.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap();
let p: Vec<Point> = reshape_anchors(&s, id).iter().map(|a| a.p).collect();
assert_eq!(p.len(), 5, "the grab adds an anchor: {p:?}");
assert_eq!((p[0], p[3], p[4]), (Point::new(0.0, 0.0), Point::new(250.0, 100.0), Point::new(300.0, 0.0)), "{p:?}");
assert!((p[1].y - 100.0 * reshape_share(0.4)).abs() < 1e-9 && (p[2].y - 100.0 * reshape_share(0.8)).abs() < 1e-9, "{p:?}");
}
#[test]
fn reshape_of_an_end_of_a_two_anchor_path_moves_only_that_end() {
let mut s = session();
let id = reshape_open_path(&mut s, json!([{"x": 0, "y": 0, "out": [30, 0]}, {"x": 100, "y": 0, "in": [70, 0]}]));
s.execute("path.reshape", &json!({"id": id.0, "x": 100.0, "y": 0.0, "dx": 10.0, "dy": 50.0})).unwrap();
let a = reshape_anchors(&s, id);
assert_eq!((a[0].p, a[0].h_out), (Point::new(0.0, 0.0), Point::new(30.0, 0.0)));
assert_eq!((a[1].p, a[1].h_in), (Point::new(110.0, 50.0), Point::new(80.0, 50.0)));
}
#[test]
fn reshape_keeps_smooth_anchors_on_both_sides_of_the_grabbed_one_in_line() {
let mut s = session();
// Smooth anchors with handles of unequal length, on a curve, before and after the grabbed anchor.
let id = reshape_open_path(
&mut s,
json!([
{"x": 0, "y": 0},
{"x": 100, "y": 50, "in": [80, 40], "out": [160, 80], "smooth": true},
{"x": 250, "y": 0, "in": [220, 0], "out": [290, 0], "smooth": true},
{"x": 400, "y": 60, "in": [340, 90], "out": [420, 50], "smooth": true},
{"x": 500, "y": 0}
]),
);
let before = reshape_anchors(&s, id);
s.execute("path.reshape", &json!({"id": id.0, "x": 250.0, "y": 0.0, "dx": 35.0, "dy": -80.0})).unwrap();
let a = reshape_anchors(&s, id);
let drag = vectorcraft_geom::Vec2::new(35.0, -80.0);
assert_eq!((a[2].p - before[2].p, a[2].h_in - before[2].h_in, a[2].h_out - before[2].h_out), (drag, drag, drag));
for i in [1, 3] {
let (to_in, to_out) = (a[i].h_in - a[i].p, a[i].h_out - a[i].p);
assert!(to_in.cross(to_out).abs() < 1e-9 && to_in.dot(to_out) < 0.0, "anchor {i}'s handles stay opposite each other: {a:?}");
let moved = a[i].p - before[i].p;
assert!(
moved.cross(drag).abs() < 1e-9 && moved.hypot() > 1.0 && moved.hypot() < drag.hypot() - 1.0,
"anchor {i} takes part of the drag: {a:?}"
);
// The handle on the grabbed anchor's side moves farther than its anchor, and the other one less.
let (h_in, h_out) = ((a[i].h_in - before[i].h_in).hypot(), (a[i].h_out - before[i].h_out).hypot());
let (nearer, farther) = if i == 1 { (h_out, h_in) } else { (h_in, h_out) };
assert!(nearer > moved.hypot() + 1.0 && farther < moved.hypot() - 1.0, "anchor {i}: {a:?}");
}
assert_eq!((a[0].p, a[4].p), (before[0].p, before[4].p));
}
#[test]
fn reshape_moves_a_long_handle_against_the_drag_to_keep_its_anchor_smooth() {
let mut s = session();
let id =
reshape_open_path(&mut s, json!([{"x": 0, "y": 0}, {"x": 100, "y": 0, "in": [10, 0], "out": [190, 0], "smooth": true}, {"x": 400, "y": 0}]));
s.execute("path.reshape", &json!({"id": id.0, "x": 400.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap();
let a = reshape_anchors(&s, id);
// The anchor is a quarter of the way along and takes 0.15625 of the drag. The tangent line
// there has slope 1.125, and each handle lies 0.225 of the way from the anchor: the in handle
// takes 0.15625 - 0.253125, a share below 0, and the out handle 0.15625 + 0.253125.
assert!((a[1].p.y - 15.625).abs() < 1e-9 && (a[1].h_in.y + 9.6875).abs() < 1e-9 && (a[1].h_out.y - 40.9375).abs() < 1e-9, "{a:?}");
let (to_in, to_out) = (a[1].h_in - a[1].p, a[1].h_out - a[1].p);
assert!(to_in.cross(to_out).abs() < 1e-9 && to_in.dot(to_out) < 0.0, "the handles stay opposite each other: {a:?}");
}
#[test]
fn reshape_refuses_a_negative_radius() {
let mut s = session();
let id = reshape_open_path(&mut s, reshape_zigzag());
let before = reshape_anchors(&s, id);
let steps = s.doc().unwrap().history.undo.len();
let e = s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 80.0, "dx": 0.0, "dy": 50.0, "radius": -50.0})).unwrap_err();
assert!(e.to_string().contains("`radius` must be 0 or more"), "{e}");
assert_eq!(reshape_anchors(&s, id), before);
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
}
#[test]
fn reshape_by_no_distance_on_an_anchor_records_no_undo_step() {
let mut s = session();
let id = reshape_open_path(&mut s, reshape_zigzag());
let before = reshape_anchors(&s, id);
let steps = s.doc().unwrap().history.undo.len();
for params in [json!({"id": id.0, "x": 300.0, "y": 80.0}), json!({"id": id.0, "x": 120.0, "y": 0.0, "dx": 0.0, "dy": 0.0, "radius": 40.0})] {
s.execute("path.reshape", &params).unwrap();
}
assert_eq!(reshape_anchors(&s, id), before);
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
// A drag that moves the anchor is still one step.
s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 80.0, "dx": 0.0, "dy": 1.0})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
}
#[test]
fn graph_tool_drag_creates_a_graph_and_asks_for_data() {
let mut s = session();
+2 -2
View File
@@ -4,8 +4,8 @@
//! (Shift holds their turn; click without dragging opens the Flare Tool Options), then a click
//! sets the end point of the rings; Space held during either drag moves the flare. The rest comes
//! from the tool's options (the Flare Tool Options, which last). Reshape: drag a point of a
//! selected path; the engine's `path.reshape` adds an anchor there if needed and moves the
//! neighbourhood smoothly.
//! selected path; the engine's `path.reshape` adds an anchor there if needed and stretches an open
//! path from that point to its two ends, or moves the neighborhood of the point on a closed path.
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Vec2};
+16
View File
@@ -3627,6 +3627,22 @@ the canvas (coordinates out of range)".
is not a whole number of 0 or more, such as 1.5, -1 or "1" ("missing integer `subpath`"); `path.split` refuses such
an `anchor` or `segment` in the same way. The document does not change and no undo step is recorded.
`path.reshape {id, x, y, dx, dy, tol?, radius?}` (a Reshape tool drag) grabs the path at `x`, `y`, adding an anchor
there unless one lies within `tol` (3 by default), and moves that point by `dx`, `dy`.
[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. How far
the stretch reaches and the share of the drag each point takes are VectorCraft's own rule. An open path stretches from
the grabbed point to its two ends: the two end anchors stay in place, and each anchor between an end and the grabbed
point takes `u² (3 - 2u)` of the drag, where `u` is its distance from that end along the path's control polygon
(anchor, out handle, the next in handle, the next anchor) divided by the grabbed point's distance from that end. A
handle takes the share on the tangent line of that curve at its anchor, so the handles of a smooth anchor stay in line
with it; a handle that is long beside the distance from the end to the grabbed point takes a share below 0 or above 1,
and moves against the drag or farther than the grabbed point. On a closed path, or when `radius` is given, the anchors
within `radius` of the grabbed point along the path follow with a smooth falloff and the others stay; `radius`
defaults to half the diagonal of the subpath's bounding box, and a negative `radius` is refused ("`radius` must be 0
or more"). 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.
These commands refuse a string or another value that is not a number with `` `dy` must be a number, not "10" ``,
naming the key; the document does not change and no undo step is recorded: `t`, `dx` and `dy` in
`path.reshapeSegment` and `object.blend.spine.reshapeSegment`; `x`, `y` and `t` in `path.curvatureEdit`; `x` and `y`
+1
View File
@@ -929,6 +929,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
With Smart Guides on, a straight Knife cut snaps to anchor points and paths: [Cut objects using the Knife and Scissors tools](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) describes the Knife snapping to anchor points and paths while Smart Guides are on. Its ends snap as the ends of the Pencil's straight line do, to anchor points and path segments of art that is shown: the press, and the release of an Alt-drag. Shift+Alt steps 45° from the snapped press, and its release stays on that step. With Smart Guides off, or with Snap to Pixel or Snap to Grid on, the cut is not snapped. The dashed line and `path.knife` use the snapped points.
Eraser Tool Options has Angle and Roundness besides Size, as [Erase paths using the Eraser tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/erase-paths-using-eraser-tool.html) describes: the Eraser's tip is an ellipse Size across, its long axis at Angle and its short axis Roundness percent of the long one, and a drag with a tip that is not round shows the outline of the area it erases (`path.eraseRegion` `angle` and `roundness`).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
A Reshape drag stretches part of a path from the dragged point without losing the path's general form, as [Adjust path segments](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/adjust-path-segments.html) has it. On an open path the stretch reaches both ends: the end anchors stay in place, each anchor between moves less the nearer it lies to an end, and the handles of a smooth anchor stay in line (`path.reshape`; the reach and the shares are VectorCraft's own rule, in `docs/mcp.md`). On a closed path the anchors within half the diagonal of the subpath's bounding box, measured along the path, follow the dragged point. Clicks do not set focal points.
Align, Distribute, Distribute Spacing, Reflect and Shear return an error for any `horizontal`, `vertical`, `to`, `axis`, `bounds` or `spacing` value they don't take, listing the choices, and Align returns one when a call gives neither `horizontal` nor `vertical`; nothing moves and no undo step is recorded. Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing).
Transform Each opens on the scale, move, rotate, reflect, Random, reference point, Transform Objects and Preview it was last confirmed with (kept with the UI state; Copy never is), and its Copy button, left of Cancel, transforms a copy of each object as the [Scale multiple objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-multiple-objects.html) help page has it; the Copy checkbox is gone (#1732; the remembered values follow a maintainer comment on the issue, not the help page). Its Scale section has the Relative and Absolute radio buttons the same page describes: percentages of each object's size, or the width and height it becomes (`object.transformEach` `relative`, which Transform Again repeats); Absolute measures each object's own box, and the scales act along its axes about that box's reference point, so a turned shape keeps its shape and objects of different sizes come out alike; the fields show the width and height the selected objects share and read "Mixed" when they differ or a side is 0 (a line's), each object measured by its own box, rotated with it, as the Transform panel and the Control bar show it (sizes they show alike count as the same), and a size typed into them sets every object to it (a side left Mixed keeps its size), our choices where the page leaves them open; a live Repeat scales its spacing by the geometric mean of the two factors, so its Absolute size is approximate. The link beside the two scales keeps them in proportion, as the shape dialogs' Width and Height link does (`__constrain`, kept with the dialog's values; where it sits is our choice, the public pages don't establish it). The panel lists Scale, Move and Rotate, then the Options in two columns (Transform Objects, Transform Patterns, Scale Strokes & Effects, Scale Corners; Reflect X, Reflect Y, Random), the Reference Point, and Preview at the left of the button row, level with Copy, Cancel and OK.
Transform Again uses the Copy, Transform Patterns, Scale Strokes & Effects and Scale Corners choices of the transform it repeats; after a bounding-box handle drag on area type, it resizes the type area again and the type keeps its size (`object.transformAgain`). With anchors selected with Direct Selection, Move, Rotate, Scale, Reflect, Shear and Transform Again (the commands, their dialogs and the four tools' drags) transform only those anchors, their handles with them, about the centre of the box around them unless a point was clicked; a copy still copies the whole object (#1408; behaviour reference: [adjust path segments](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html); the default reference point and the copy are our choices). The Move, Rotate, Scale, Reflect and Shear dialogs have a Preview checkbox, off when they open: while it is on the canvas follows the values, Cancel rolls it back and OK is still one undo step; Up/Down step the Rotate and Shear angles and the Scale percentages by 1, Shift by 10 (#1412). The five dialogs and Transform Each have Transform Objects beside Transform Patterns: off, the art stays and only its pattern tiles transform (`objects` on the transform commands), and Transform Again repeats the choice (#1448; behaviour reference: [scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html); the checkboxes are always enabled and the Scale page's pair is applied to all of them, our choices). Reflect's axis is Horizontal, Vertical or an angle (#1460; [reflect objects along an axis](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/reflect-objects-along-an-axis.html)).