mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 20:01:43 +00:00
Merge pull request #6519 from dotCooCoo/feat/reshape-focal-points
Reshape: a click sets a focal point and a drag moves every focal point
This commit is contained in:
+182
-61
@@ -4,7 +4,7 @@ use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Scaling, Selection};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
|
||||
|
||||
use super::create::anchor_from_json;
|
||||
@@ -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). 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)",
|
||||
"{id, x, y, dx, dy, tol=3, focal?: [[subpath, anchor]…], radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy). The grabbed anchor and the anchors in focal are focal points and move by the whole drag. Anchors that are not direct-selected stay in place (a path selected whole, or not selected, has every anchor selected), and so do an open path's two ends unless they are focal points. A selected anchor between two focal points moves whole; one between a focal point and an anchor that stays takes a share of the drag that falls smoothly from 1 at the focal point to 0 at the anchor that stays, by its distance along the path's control polygon. A closed subpath with every anchor selected moves whole; a subpath with no focal point does not move. With radius, only the grabbed point's subpath changes: anchors follow with a smooth falloff over radius, measured along the path (0 or more), whatever the selection; focal and radius cannot be used together",
|
||||
has_doc,
|
||||
reshape
|
||||
),
|
||||
@@ -1410,55 +1410,143 @@ 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);
|
||||
/// What an anchor does in a Reshape drag.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Stretch {
|
||||
/// Not selected: it stays in place.
|
||||
Fixed,
|
||||
/// Selected: it takes a share of the drag that depends on where it lies between the anchors
|
||||
/// that stay and the focal points.
|
||||
Selected,
|
||||
/// A focal point (the grabbed anchor, or one the Reshape tool highlighted): it moves by the
|
||||
/// whole drag.
|
||||
Focal,
|
||||
}
|
||||
|
||||
/// The share of a Reshape drag that each anchor of a subpath takes, as `[in handle, anchor, out
|
||||
/// handle]`, from what each anchor does (`roles`, one per anchor). A focal point takes all of the
|
||||
/// drag and an anchor that stays takes none. An open path's end anchors stay unless they are focal
|
||||
/// points. A selected anchor between two focal points takes all of the drag, and one between two
|
||||
/// anchors that stay takes none. For a selected anchor between an anchor that stays and a focal
|
||||
/// point, `u` is its distance from the anchor that stays along the control polygon (anchor, out
|
||||
/// handle, the next in handle, the next anchor) divided by the focal point's distance from it, 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. A closed subpath with no anchor that stays
|
||||
/// moves whole when it has a focal point.
|
||||
fn stretch_weights(anchors: &[Anchor], closed: bool, roles: &[Stretch]) -> Vec<[f64; 3]> {
|
||||
let n = anchors.len();
|
||||
let mut shares = vec![[0.0; 3]; n];
|
||||
let role = |i: usize| match roles.get(i) {
|
||||
Some(Stretch::Selected) if !closed && (i == 0 || i + 1 == n) => Stretch::Fixed,
|
||||
Some(&r) => r,
|
||||
None => Stretch::Fixed,
|
||||
};
|
||||
// The walk along the subpath starts at an anchor that stays, or else at a focal point, and on a
|
||||
// closed subpath it comes back round to the same anchor, so every selected anchor lies between
|
||||
// two anchors that are not selected ones.
|
||||
let start = if closed { (0..n).find(|&i| role(i) == Stretch::Fixed).or_else(|| (0..n).find(|&i| role(i) == Stretch::Focal)) } else { Some(0) };
|
||||
let Some(start) = start.filter(|_| n > 0) else { return shares };
|
||||
let len = if closed { n + 1 } else { n };
|
||||
let order: Vec<usize> = (0..len).map(|k| (start + k) % n).collect();
|
||||
// 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 pos: Vec<[f64; 3]> = Vec::with_capacity(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 mut before: Option<&Anchor> = None;
|
||||
for (k, a) in order.iter().filter_map(|&i| anchors.get(i)).enumerate() {
|
||||
if let Some(b) = before {
|
||||
run += b.h_out.distance(a.h_in);
|
||||
}
|
||||
let c_in = run;
|
||||
if i > 0 {
|
||||
if k > 0 {
|
||||
run += a.h_in.distance(a.p);
|
||||
}
|
||||
let c = run;
|
||||
if i < last {
|
||||
if k + 1 < len {
|
||||
run += a.p.distance(a.h_out);
|
||||
}
|
||||
pos.push([c_in, c, run]);
|
||||
before = Some(a);
|
||||
}
|
||||
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];
|
||||
// The nearest anchor before and after each place in the walk that is not a selected one.
|
||||
let bound = |k: usize| role(order.get(k).copied().unwrap_or(start)) != Stretch::Selected;
|
||||
let mut previous = vec![0; len];
|
||||
let mut last = 0;
|
||||
for (k, slot) in previous.iter_mut().enumerate() {
|
||||
if bound(k) {
|
||||
last = k;
|
||||
}
|
||||
*slot = last;
|
||||
}
|
||||
let mut next = vec![len - 1; len];
|
||||
let mut first = len - 1;
|
||||
for (k, slot) in next.iter_mut().enumerate().rev() {
|
||||
if bound(k) {
|
||||
first = k;
|
||||
}
|
||||
*slot = first;
|
||||
}
|
||||
let at = |k: usize| pos.get(k).map_or(0.0, |c| c[1]);
|
||||
// The share of the anchor at `k` in the walk, between an anchor that stays at `end` and a
|
||||
// focal point at `focal` along the walk.
|
||||
let smooth = |k: usize, end: f64, focal: f64| {
|
||||
let span = (focal - end).abs();
|
||||
let Some(&[c_in, c, c_out]) = pos.get(k).filter(|_| span > 0.0 && span.is_finite()) else { 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);
|
||||
[w + slope * (along(c_in) - u), w, w + slope * (along(c_out) - u)]
|
||||
};
|
||||
for (k, &i) in order.iter().enumerate().take(n) {
|
||||
let share = match role(i) {
|
||||
Stretch::Fixed => [0.0; 3],
|
||||
Stretch::Focal => [1.0; 3],
|
||||
Stretch::Selected => {
|
||||
let (kp, kn) = (previous.get(k).copied().unwrap_or(k), next.get(k).copied().unwrap_or(k));
|
||||
match (role(order.get(kp).copied().unwrap_or(i)), role(order.get(kn).copied().unwrap_or(i))) {
|
||||
(Stretch::Focal, Stretch::Focal) => [1.0; 3],
|
||||
(Stretch::Fixed, Stretch::Focal) => smooth(k, at(kp), at(kn)),
|
||||
(Stretch::Focal, Stretch::Fixed) => smooth(k, at(kn), at(kp)),
|
||||
_ => [0.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()
|
||||
};
|
||||
// A coordinate that isn't finite moves nothing.
|
||||
if let Some(s) = shares.get_mut(i)
|
||||
&& share.iter().all(|s| s.is_finite())
|
||||
{
|
||||
*s = share;
|
||||
}
|
||||
}
|
||||
shares
|
||||
}
|
||||
|
||||
/// After an anchor is added at `(si, added)` of path `id`, number the direct-selected anchors and
|
||||
/// selected segments after it on that subpath one higher, so the selection keeps the same points.
|
||||
/// A selected segment that the new anchor splits stays selected as its two halves, and the new
|
||||
/// anchor joins its ends in the anchors that move with it.
|
||||
fn renumber_after_insert(sel: &mut Selection, id: NodeId, si: usize, added: usize) {
|
||||
if let Some(set) = sel.anchors.get_mut(&id) {
|
||||
// A selected anchor index can name no anchor (`select.anchors` takes any index), so the
|
||||
// renumbering saturates instead of overflowing.
|
||||
*set = set.iter().map(|&(s, a)| if s == si && a >= added { (s, a.saturating_add(1)) } else { (s, a) }).collect();
|
||||
}
|
||||
let split = added.checked_sub(1);
|
||||
if sel.segments.get(&id).is_some_and(|set| split.is_some_and(|g| set.contains(&(si, g)))) {
|
||||
sel.anchors.entry(id).or_default().insert((si, added));
|
||||
}
|
||||
if let Some(set) = sel.segments.get_mut(&id) {
|
||||
*set = set
|
||||
.iter()
|
||||
.flat_map(|&(s, g)| match (s == si, Some(g) == split) {
|
||||
(true, true) => vec![(s, g), (s, g.saturating_add(1))],
|
||||
(true, false) if g >= added => vec![(s, g.saturating_add(1))],
|
||||
_ => vec![(s, g)],
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1476,45 +1564,78 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 {
|
||||
// The other focal points the Reshape tool highlighted on this path.
|
||||
let mut focal: BTreeSet<(usize, usize)> = match p.get("focal") {
|
||||
None | Some(Value::Null) => BTreeSet::new(),
|
||||
Some(v) if v.is_array() => super::select::checked_refs(Some(v), "path.reshape")?.into_iter().collect(),
|
||||
Some(_) => return Err(bad("path.reshape", "`focal` must be an array of [subpath, anchor] pairs")),
|
||||
};
|
||||
if radius.is_some() && !focal.is_empty() {
|
||||
return Err(bad("path.reshape", "`focal` and `radius` cannot be used together"));
|
||||
}
|
||||
{
|
||||
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();
|
||||
if let Some((si, ai)) = focal.iter().copied().find(|&(si, ai)| path.subpaths.get(si).is_none_or(|sp| ai >= sp.anchors.len())) {
|
||||
return Err(bad("path.reshape", format!("`focal` names [{si}, {ai}], which is not an anchor of the path")));
|
||||
}
|
||||
// A drag of no length on an anchor moves nothing, so it is not an edit.
|
||||
if delta == Vec2::ZERO {
|
||||
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, _| {
|
||||
s.edit("Reshape", |d, sel| {
|
||||
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 sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("empty path".into()))?;
|
||||
let grabbed = match near(sp) {
|
||||
Some(i) => i,
|
||||
None => sp.insert_anchor(seg, t),
|
||||
None => {
|
||||
let added = sp.insert_anchor(seg, t);
|
||||
focal = focal.iter().map(|&(s, a)| if s == si && a >= added { (s, a.saturating_add(1)) } else { (s, a) }).collect();
|
||||
renumber_after_insert(sel, id, si, added);
|
||||
// On a partly selected path the new anchor is selected too.
|
||||
if let Some(set) = sel.anchors.get_mut(&id) {
|
||||
set.insert((si, added));
|
||||
}
|
||||
added
|
||||
}
|
||||
};
|
||||
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;
|
||||
let Some(r) = radius else {
|
||||
focal.insert((si, grabbed));
|
||||
// Anchors that are not direct-selected stay; a path selected whole, or not selected,
|
||||
// has every anchor selected.
|
||||
let selected = sel.anchors.get(&id);
|
||||
for (k, sp) in path.subpaths.iter_mut().enumerate() {
|
||||
if focal.range((k, 0)..=(k, usize::MAX)).next().is_none() {
|
||||
continue;
|
||||
}
|
||||
let roles: Vec<Stretch> = (0..sp.anchors.len())
|
||||
.map(|i| match (focal.contains(&(k, i)), selected.is_none_or(|set| set.contains(&(k, i)))) {
|
||||
(true, _) => Stretch::Focal,
|
||||
(false, true) => Stretch::Selected,
|
||||
(false, false) => Stretch::Fixed,
|
||||
})
|
||||
.collect();
|
||||
let weights = stretch_weights(&sp.anchors, sp.closed, &roles);
|
||||
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(());
|
||||
}
|
||||
};
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("empty path".into()))?;
|
||||
let closed = sp.closed;
|
||||
// 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);
|
||||
for (i, a) in sp.anchors.iter_mut().enumerate() {
|
||||
let w = if i == grabbed {
|
||||
|
||||
+373
-2
@@ -3217,8 +3217,9 @@ fn flare_rotation_turns_only_the_rays() {
|
||||
fn reshape_moves_the_grabbed_point_and_its_neighbourhood() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
// Grab the middle of the top edge: a new anchor appears there and moves the full delta.
|
||||
s.execute("path.reshape", &json!({"id": r.0, "x": 50.0, "y": 0.0, "dx": 0.0, "dy": -40.0})).unwrap();
|
||||
// Grab the middle of the top edge: a new anchor appears there and moves the full delta. The
|
||||
// radius, about half the square's diagonal, bounds the reach.
|
||||
s.execute("path.reshape", &json!({"id": r.0, "x": 50.0, "y": 0.0, "dx": 0.0, "dy": -40.0, "radius": 70.0})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let pd = st.doc.node(r).unwrap().path_data().unwrap().clone();
|
||||
assert_eq!(pd.anchor_count(), 5);
|
||||
@@ -3455,6 +3456,376 @@ fn reshape_by_no_distance_on_an_anchor_records_no_undo_step() {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
}
|
||||
|
||||
/// Seven anchors 70 apart along a line, from x 0 to x 420.
|
||||
fn reshape_line_of_seven() -> Value {
|
||||
json!([{"x": 0, "y": 0}, {"x": 70, "y": 0}, {"x": 140, "y": 0}, {"x": 210, "y": 0}, {"x": 280, "y": 0}, {"x": 350, "y": 0}, {"x": 420, "y": 0}])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_with_two_focal_points_moves_the_anchors_between_them_whole() {
|
||||
let mut s = session();
|
||||
let id = reshape_open_path(&mut s, reshape_line_of_seven());
|
||||
s.execute("path.reshape", &json!({"id": id.0, "x": 280.0, "y": 0.0, "dx": 0.0, "dy": 100.0, "focal": [[0, 2]]})).unwrap();
|
||||
let y: Vec<f64> = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect();
|
||||
assert_eq!(&y[2..5], &[100.0, 100.0, 100.0], "both focal points and the anchor between them: {y:?}");
|
||||
assert_eq!((y[0], y[6]), (0.0, 0.0), "the ends stay: {y:?}");
|
||||
// Each side stretches from its own end to its nearer focal point, 140 along the path.
|
||||
assert!((y[1] - 100.0 * reshape_share(0.5)).abs() < 1e-9 && (y[5] - 100.0 * reshape_share(0.5)).abs() < 1e-9, "{y:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_of_a_closed_path_selected_whole_moves_all_of_it() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let before = s.doc().unwrap().doc.node(r).unwrap().path_data().unwrap().clone();
|
||||
s.execute("path.reshape", &json!({"id": r.0, "x": 0.0, "y": 0.0, "dx": 15.0, "dy": -40.0})).unwrap();
|
||||
let after = s.doc().unwrap().doc.node(r).unwrap().path_data().unwrap().clone();
|
||||
assert_eq!(after.anchor_count(), before.anchor_count(), "a grab on an anchor adds none");
|
||||
let drag = vectorcraft_geom::Vec2::new(15.0, -40.0);
|
||||
for ((_, _, a), (_, _, b)) in after.anchors().zip(before.anchors()) {
|
||||
assert_eq!((a.p - b.p, a.h_in - b.h_in, a.h_out - b.h_out), (drag, drag, drag), "{after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_leaves_the_anchors_that_are_not_selected_in_place() {
|
||||
let mut s = session();
|
||||
let id = reshape_open_path(&mut s, reshape_line_of_seven());
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2], [0, 3], [0, 4], [0, 5]], "mode": "set"})).unwrap();
|
||||
s.execute("path.reshape", &json!({"id": id.0, "x": 350.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[0], y[1], y[5], y[6]), (0.0, 0.0, 100.0, 0.0), "{y:?}");
|
||||
// The stretch runs from the last anchor that is not selected, 280 from the focal point.
|
||||
for i in 2..5 {
|
||||
let u = (i as f64 - 1.0) / 4.0;
|
||||
assert!((y[i] - 100.0 * reshape_share(u)).abs() < 1e-9, "anchor {i} takes {u} of the way: {y:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_of_a_partly_selected_closed_path_stretches_between_the_anchors_that_stay() {
|
||||
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": 200, "y": 100}, {"x": 100, "y": 100}, {"x": 0, "y": 100}], "closed": true}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
// Anchors 5, 0, 1 and 2 are selected; 3 and 4 stay. The walk runs 4, 5, 0, 1, 2, 3.
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 5], [0, 0], [0, 1], [0, 2]], "mode": "set"})).unwrap();
|
||||
s.execute("path.reshape", &json!({"id": id.0, "x": 0.0, "y": 0.0, "dx": 0.0, "dy": -50.0})).unwrap();
|
||||
let y: Vec<f64> = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect();
|
||||
assert_eq!((y[0], y[3], y[4]), (-50.0, 100.0, 100.0), "{y:?}");
|
||||
// From anchor 4 to the focal point is 200: anchor 5 is halfway. From anchor 3 back to it is
|
||||
// 300: anchor 2 is a third of the way and anchor 1 two thirds.
|
||||
let want = [(5, 100.0 + -50.0 * reshape_share(0.5)), (2, -50.0 * reshape_share(1.0 / 3.0)), (1, -50.0 * reshape_share(2.0 / 3.0))];
|
||||
for (i, w) in want {
|
||||
assert!((y[i] - w).abs() < 1e-9, "anchor {i}: {y:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_of_a_segment_keeps_the_selected_anchors_and_selects_the_new_one() {
|
||||
let mut s = session();
|
||||
let id = reshape_open_path(&mut s, reshape_line_of_seven());
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 3], [0, 4], [0, 5]], "mode": "set"})).unwrap();
|
||||
s.execute("path.reshape", &json!({"id": id.0, "x": 245.0, "y": 0.0, "dx": 0.0, "dy": 60.0, "focal": [[0, 5]]})).unwrap();
|
||||
let a = reshape_anchors(&s, id);
|
||||
assert_eq!(a.len(), 8, "the grab adds an anchor between 210 and 280");
|
||||
let selected: Vec<(usize, usize)> = s.doc().unwrap().selection.partial(id).unwrap().iter().copied().collect();
|
||||
assert_eq!(selected, vec![(0, 3), (0, 4), (0, 5), (0, 6)], "the same points stay selected, with the new one");
|
||||
// The focal point given before the anchor was added still names the anchor at 350.
|
||||
let y: Vec<f64> = a.iter().map(|a| a.p.y).collect();
|
||||
assert_eq!(&y[4..7], &[60.0, 60.0, 60.0], "{y:?}");
|
||||
assert_eq!((y[0], y[1], y[2], y[7]), (0.0, 0.0, 0.0, 0.0), "{y:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_of_a_segment_with_a_selected_anchor_index_past_the_end_does_not_panic() {
|
||||
let mut s = session();
|
||||
let id = reshape_open_path(&mut s, reshape_line_of_seven());
|
||||
let past = usize::MAX;
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1], [0, past]], "mode": "set"})).unwrap();
|
||||
s.execute("path.reshape", &json!({"id": id.0, "x": 175.0, "y": 0.0, "dx": 0.0, "dy": 10.0})).unwrap();
|
||||
s.execute("path.reshape", &json!({"id": id.0, "x": 105.0, "y": 0.0, "dx": 0.0, "dy": 10.0})).unwrap();
|
||||
assert_eq!(reshape_anchors(&s, id).len(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_of_a_segment_keeps_a_selected_segment_as_its_two_halves() {
|
||||
let mut s = session();
|
||||
let id = reshape_open_path(&mut s, reshape_line_of_seven());
|
||||
s.execute("select.segments", &json!({"id": id.0, "segments": [[0, 2], [0, 4]], "mode": "set"})).unwrap();
|
||||
s.execute("path.reshape", &json!({"id": id.0, "x": 175.0, "y": 0.0, "dx": 0.0, "dy": 0.0})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let segments: Vec<(usize, usize)> = st.selection.segments[&id].iter().copied().collect();
|
||||
assert_eq!(segments, vec![(0, 2), (0, 3), (0, 5)]);
|
||||
let anchors: Vec<(usize, usize)> = st.selection.partial(id).unwrap().iter().copied().collect();
|
||||
assert_eq!(anchors, vec![(0, 2), (0, 3), (0, 4), (0, 5), (0, 6)], "the new anchor moves with the segment's ends");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_moves_only_the_subpaths_with_a_focal_point() {
|
||||
let mut s = session();
|
||||
let id = NodeId(s.execute("path.create", &json!({"d": "M0 0 L100 0 L200 0 M0 100 L100 100 L200 100"})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("path.reshape", &json!({"id": id.0, "x": 200.0, "y": 0.0, "dx": 0.0, "dy": 40.0})).unwrap();
|
||||
let pd = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone();
|
||||
let y: Vec<Vec<f64>> = pd.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| a.p.y).collect()).collect();
|
||||
assert_eq!(y[1], vec![100.0, 100.0, 100.0], "{y:?}");
|
||||
assert_eq!((y[0][0], y[0][2]), (0.0, 40.0), "{y:?}");
|
||||
// A focal point on the other subpath stretches that one too.
|
||||
s.execute("path.reshape", &json!({"id": id.0, "x": 200.0, "y": 40.0, "dx": 0.0, "dy": 10.0, "focal": [[1, 1]]})).unwrap();
|
||||
let pd = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone();
|
||||
assert_eq!(pd.subpaths[1].anchors.iter().map(|a| a.p.y).collect::<Vec<_>>(), vec![100.0, 110.0, 100.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_refuses_a_focal_point_that_is_not_an_anchor() {
|
||||
let mut s = session();
|
||||
let id = reshape_open_path(&mut s, reshape_line_of_seven());
|
||||
let before = reshape_anchors(&s, id);
|
||||
for (focal, want) in [
|
||||
(json!([[0, 7]]), "`focal` names [0, 7], which is not an anchor of the path"),
|
||||
(json!([[1, 0]]), "`focal` names [1, 0], which is not an anchor of the path"),
|
||||
(json!([[0, -1]]), "invalid anchor index -1"),
|
||||
(json!({"0": 1}), "`focal` must be an array of [subpath, anchor] pairs"),
|
||||
] {
|
||||
let e = s.execute("path.reshape", &json!({"id": id.0, "x": 420.0, "y": 0.0, "dx": 0.0, "dy": 10.0, "focal": focal})).unwrap_err();
|
||||
assert!(e.to_string().contains(want), "{e}");
|
||||
}
|
||||
assert_eq!(reshape_anchors(&s, id), before);
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let e =
|
||||
s.execute("path.reshape", &json!({"id": id.0, "x": 420.0, "y": 0.0, "dx": 0.0, "dy": 10.0, "focal": [[0, 2]], "radius": 50.0})).unwrap_err();
|
||||
assert!(e.to_string().contains("`focal` and `radius` cannot be used together"), "{e}");
|
||||
assert_eq!(reshape_anchors(&s, id), before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
}
|
||||
|
||||
/// The line of seven, selected whole, with the Reshape tool picked.
|
||||
fn reshape_tool_on_a_line(s: &mut Session) -> NodeId {
|
||||
let id = reshape_open_path(s, reshape_line_of_seven());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.select_tool("reshape", ViewInfo::default()).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Presses at `a`, drags to `b` unless it is `a` (a click), and releases, with Shift when `shift`.
|
||||
fn reshape_gesture(s: &mut Session, a: (f64, f64), b: (f64, f64), shift: bool) {
|
||||
let v = ViewInfo::default();
|
||||
let mods = vectorcraft_tools::Mods { shift, ..vectorcraft_tools::Mods::default() };
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, a.0, a.1).with_mods(mods), v).unwrap();
|
||||
if a != b {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, b.0, b.1).with_mods(mods), v).unwrap();
|
||||
}
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, b.0, b.1).with_mods(mods), v).unwrap();
|
||||
}
|
||||
|
||||
/// Where the Reshape tool draws its highlighted focal points.
|
||||
fn reshape_highlights(s: &mut Session) -> Vec<Point> {
|
||||
s.overlays(ViewInfo::default())
|
||||
.into_iter()
|
||||
.filter_map(|o| match o {
|
||||
vectorcraft_tools::Overlay::Anchor { p, filled: true, .. } => Some(p),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_tool_shift_click_adds_focal_points_that_a_drag_moves_together() {
|
||||
let mut s = session();
|
||||
let id = reshape_tool_on_a_line(&mut s);
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
reshape_gesture(&mut s, (140.0, 0.0), (140.0, 0.0), false);
|
||||
reshape_gesture(&mut s, (280.0, 0.0), (280.0, 0.0), true);
|
||||
assert_eq!(reshape_highlights(&mut s), vec![Point::new(140.0, 0.0), Point::new(280.0, 0.0)]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "a click on an anchor is not an edit");
|
||||
reshape_gesture(&mut s, (280.0, 0.0), (280.0, 100.0), false);
|
||||
let y: Vec<f64> = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect();
|
||||
assert_eq!(&y[2..5], &[100.0, 100.0, 100.0], "{y:?}");
|
||||
assert!((y[1] - 50.0).abs() < 1e-9 && (y[5] - 50.0).abs() < 1e-9 && y[0] == 0.0 && y[6] == 0.0, "{y:?}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "the drag is one step");
|
||||
assert_eq!(reshape_highlights(&mut s), vec![Point::new(140.0, 100.0), Point::new(280.0, 100.0)], "the focal points stay highlighted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_tool_shift_click_on_a_focal_point_removes_it_and_a_click_starts_again() {
|
||||
for (clicks, want) in [
|
||||
// Shift-click 140 again: only 280 is a focal point.
|
||||
(vec![(140.0, false), (280.0, true), (140.0, true)], vec![Point::new(280.0, 0.0)]),
|
||||
// A click without Shift: only 350 is a focal point.
|
||||
(vec![(140.0, false), (280.0, true), (350.0, false)], vec![Point::new(350.0, 0.0)]),
|
||||
] {
|
||||
let mut s = session();
|
||||
let id = reshape_tool_on_a_line(&mut s);
|
||||
for (x, shift) in clicks {
|
||||
reshape_gesture(&mut s, (x, 0.0), (x, 0.0), shift);
|
||||
}
|
||||
assert_eq!(reshape_highlights(&mut s), want);
|
||||
let at = (want[0].x, 0.0);
|
||||
reshape_gesture(&mut s, at, (at.0, 100.0), false);
|
||||
let full = reshape_anchors(&s, id).iter().filter(|a| a.p.y == 100.0).count();
|
||||
assert_eq!(full, 1, "only the dragged focal point moves whole: {:?}", reshape_anchors(&s, id));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_tool_focal_point_stays_its_anchor_when_another_anchor_moves_onto_it() {
|
||||
let mut s = session();
|
||||
let id = reshape_tool_on_a_line(&mut s);
|
||||
reshape_gesture(&mut s, (280.0, 0.0), (280.0, 0.0), false);
|
||||
// Anchor 2 now lies where the focal point, anchor 4, lies.
|
||||
s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 2, "x": 280.0, "y": 0.0})).unwrap();
|
||||
// A Shift-click adds the last anchor, and a drag from it moves it and anchor 4 whole.
|
||||
reshape_gesture(&mut s, (420.0, 0.0), (420.0, 0.0), true);
|
||||
reshape_gesture(&mut s, (420.0, 0.0), (420.0, 100.0), false);
|
||||
let y: Vec<f64> = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect();
|
||||
assert!(y[4] == 100.0 && y[6] == 100.0 && y[2] < 100.0, "{y:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_tool_anchor_added_before_a_focal_point_keeps_it_highlighted() {
|
||||
let mut s = session();
|
||||
let id = reshape_tool_on_a_line(&mut s);
|
||||
reshape_gesture(&mut s, (280.0, 0.0), (280.0, 0.0), false);
|
||||
// A Shift-click on the segment from 70 to 140 adds anchor 2, before the focal point.
|
||||
reshape_gesture(&mut s, (105.0, 0.0), (105.0, 0.0), true);
|
||||
assert_eq!(reshape_anchors(&s, id).len(), 8);
|
||||
assert_eq!(reshape_highlights(&mut s), vec![Point::new(280.0, 0.0), Point::new(105.0, 0.0)]);
|
||||
// A drag from the added anchor moves both focal points whole.
|
||||
reshape_gesture(&mut s, (105.0, 0.0), (105.0, 40.0), false);
|
||||
let y: Vec<f64> = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect();
|
||||
assert!(y[2] == 40.0 && y[5] == 40.0, "{y:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_tool_focal_point_moved_by_another_edit_is_not_passed_to_another_anchor() {
|
||||
let mut s = session();
|
||||
let id = reshape_tool_on_a_line(&mut s);
|
||||
reshape_gesture(&mut s, (280.0, 0.0), (280.0, 0.0), false);
|
||||
// The focal point, anchor 4, moves away, and anchor 2 takes its old place.
|
||||
s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 4, "x": 280.0, "y": 50.0})).unwrap();
|
||||
s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 2, "x": 280.0, "y": 0.0})).unwrap();
|
||||
assert_eq!(reshape_highlights(&mut s), vec![]);
|
||||
// A Shift-click adds the last anchor, and a drag from it moves it alone as the focal point.
|
||||
reshape_gesture(&mut s, (420.0, 0.0), (420.0, 0.0), true);
|
||||
assert_eq!(reshape_highlights(&mut s), vec![Point::new(420.0, 0.0)]);
|
||||
reshape_gesture(&mut s, (420.0, 0.0), (420.0, 100.0), false);
|
||||
let y: Vec<f64> = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect();
|
||||
assert!(y[6] == 100.0 && y[2] < 100.0, "{y:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_tool_drag_from_an_anchor_that_is_not_highlighted_moves_only_it_whole() {
|
||||
let mut s = session();
|
||||
let id = reshape_tool_on_a_line(&mut s);
|
||||
reshape_gesture(&mut s, (140.0, 0.0), (140.0, 0.0), false);
|
||||
reshape_gesture(&mut s, (350.0, 0.0), (350.0, 100.0), false);
|
||||
let y: Vec<f64> = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect();
|
||||
assert!(y[2] < 100.0 && y[5] == 100.0, "{y:?}");
|
||||
assert_eq!(reshape_highlights(&mut s), vec![Point::new(350.0, 100.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_tool_click_on_a_segment_adds_a_highlighted_anchor() {
|
||||
let mut s = session();
|
||||
let id = reshape_tool_on_a_line(&mut s);
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
reshape_gesture(&mut s, (140.0, 0.0), (140.0, 0.0), false);
|
||||
reshape_gesture(&mut s, (245.0, 0.0), (245.0, 0.0), true);
|
||||
assert_eq!(reshape_anchors(&s, id).len(), 8);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "adding the anchor is one step");
|
||||
assert_eq!(reshape_highlights(&mut s), vec![Point::new(140.0, 0.0), Point::new(245.0, 0.0)]);
|
||||
reshape_gesture(&mut s, (245.0, 0.0), (245.0, 60.0), false);
|
||||
let a = reshape_anchors(&s, id);
|
||||
assert_eq!((a[2].p.y, a[3].p.y, a[4].p.y), (60.0, 60.0, 60.0), "{a:?}");
|
||||
// Undo takes back the drag, then the added anchor. The highlights stay where the drag left
|
||||
// them, where no anchor is now, so none shows.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(reshape_anchors(&s, id).len(), 7);
|
||||
assert!(reshape_highlights(&mut s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_tool_escape_during_a_drag_keeps_the_path_and_its_focal_points() {
|
||||
let mut s = session();
|
||||
let id = reshape_tool_on_a_line(&mut s);
|
||||
let before = reshape_anchors(&s, id);
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
reshape_gesture(&mut s, (140.0, 0.0), (140.0, 0.0), false);
|
||||
reshape_gesture(&mut s, (280.0, 0.0), (280.0, 0.0), true);
|
||||
let v = ViewInfo::default();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 280.0, 0.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 280.0, 90.0), v).unwrap();
|
||||
assert_ne!(reshape_anchors(&s, id), before, "the preview moves the path");
|
||||
s.tool_key(ToolKey::Escape, vectorcraft_tools::Mods::default(), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 280.0, 90.0), v).unwrap();
|
||||
assert_eq!(reshape_anchors(&s, id), before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
assert_eq!(reshape_highlights(&mut s), vec![Point::new(140.0, 0.0), Point::new(280.0, 0.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_tool_shift_drag_from_an_anchor_that_is_not_highlighted_moves_only_it() {
|
||||
let mut s = session();
|
||||
let id = reshape_tool_on_a_line(&mut s);
|
||||
reshape_gesture(&mut s, (140.0, 0.0), (140.0, 0.0), false);
|
||||
// Shift keeps the drag's angle; the highlighted anchor at 140 does not move whole with it.
|
||||
reshape_gesture(&mut s, (350.0, 0.0), (350.0, 100.0), true);
|
||||
let y: Vec<f64> = reshape_anchors(&s, id).iter().map(|a| a.p.y).collect();
|
||||
assert!(y[5] == 100.0 && y[2] < 100.0, "{y:?}");
|
||||
assert_eq!(reshape_highlights(&mut s), vec![Point::new(350.0, 100.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_tool_click_on_a_curved_segment_highlights_the_anchor_it_adds() {
|
||||
// Points on the curve from 0 to 300, away from its anchors, each clicked from off it on a
|
||||
// new copy of the path.
|
||||
let c = vectorcraft_geom::CubicBez::new(Point::new(0.0, 0.0), Point::new(40.0, -90.0), Point::new(230.0, 110.0), Point::new(300.0, 0.0));
|
||||
for k in 1..60 {
|
||||
let t = 0.05 + 0.015 * f64::from(k);
|
||||
for off in [(0.3, -0.2), (-2.1, 1.7), (2.6, 2.2)] {
|
||||
let mut s = session();
|
||||
let id = NodeId(
|
||||
s.execute(
|
||||
"path.create",
|
||||
&json!({"anchors": [{"x": 0, "y": 0, "out": [40, -90]}, {"x": 300, "y": 0, "in": [230, 110]}, {"x": 600, "y": 0}]}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.select_tool("reshape", ViewInfo::default()).unwrap();
|
||||
let q = vectorcraft_geom::ParamCurve::eval(&c, t);
|
||||
reshape_gesture(&mut s, (q.x + off.0, q.y + off.1), (q.x + off.0, q.y + off.1), false);
|
||||
let a = reshape_anchors(&s, id);
|
||||
assert_eq!(a.len(), 4, "t {t}, {off:?}");
|
||||
assert_eq!(reshape_highlights(&mut s), vec![a[1].p], "t {t}, {off:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_tool_leaves_a_locked_or_hidden_path_alone() {
|
||||
for command in ["object.lock", "object.hide"] {
|
||||
let mut s = session();
|
||||
let id = reshape_tool_on_a_line(&mut s);
|
||||
let before = reshape_anchors(&s, id);
|
||||
s.execute(command, &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).ok();
|
||||
reshape_gesture(&mut s, (420.0, 0.0), (420.0, 0.0), false);
|
||||
reshape_gesture(&mut s, (420.0, 0.0), (420.0, 60.0), false);
|
||||
assert_eq!(reshape_anchors(&s, id), before, "{command}");
|
||||
assert!(reshape_highlights(&mut s).is_empty(), "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_tool_drag_creates_a_graph_and_asks_for_data() {
|
||||
let mut s = session();
|
||||
|
||||
+192
-26
@@ -3,9 +3,10 @@
|
||||
//! Flare: drag sets the center and its size and turns the rays as the pointer moves up or down
|
||||
//! (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 stretches an open
|
||||
//! path from that point to its two ends, or moves the neighborhood of the point on a closed path.
|
||||
//! from the tool's options (the Flare Tool Options, which last). Reshape: a click on a selected
|
||||
//! path sets a focal point (adding an anchor on a segment), Shift-click adds or removes more, and
|
||||
//! a drag from a focal point moves them all; the engine's `path.reshape` stretches the selected
|
||||
//! anchors between them and the anchors that stay.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
@@ -268,27 +269,145 @@ impl Tool for FlareTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reshape Tool: drag a point on a selected path.
|
||||
/// Where a Reshape press landed on a selected path.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum ReshapeHit {
|
||||
/// On an anchor: (subpath, anchor).
|
||||
Anchor(usize, usize),
|
||||
/// On a segment, away from its anchors: (subpath, the index of the anchor `path.reshape` adds
|
||||
/// there).
|
||||
Segment(usize, usize),
|
||||
}
|
||||
|
||||
impl ReshapeHit {
|
||||
/// The anchor the press grabs, as `(subpath, anchor)` once any anchor has been added.
|
||||
fn anchor(self) -> (usize, usize) {
|
||||
match self {
|
||||
Self::Anchor(si, ai) | Self::Segment(si, ai) => (si, ai),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the anchor `r` is once the press has added its anchor: one further on after an
|
||||
/// anchor added before it on its subpath.
|
||||
fn renumber(self, r: (usize, usize)) -> (usize, usize) {
|
||||
match self {
|
||||
Self::Segment(si, added) if r.0 == si && r.1 >= added => (r.0, r.1.saturating_add(1)),
|
||||
_ => r,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Reshape press: the path, the point on it, what it landed on and whether Shift was held.
|
||||
#[derive(Clone, Debug)]
|
||||
struct ReshapePress {
|
||||
id: vectorcraft_doc::NodeId,
|
||||
at: Point,
|
||||
hit: ReshapeHit,
|
||||
shift: bool,
|
||||
/// The drag so far.
|
||||
d: Vec2,
|
||||
/// The other focal points the drag moves, found when it starts, before the preview moves them.
|
||||
others: Vec<((usize, usize), Point)>,
|
||||
}
|
||||
|
||||
/// How near a focal point's stored position an anchor must be to be that focal point. An anchor
|
||||
/// added on a segment lies where the curve's point was found, to within rounding.
|
||||
const FOCAL_MATCH: f64 = 1e-6;
|
||||
|
||||
/// A focal point: its `(subpath, anchor)` and where that anchor lies.
|
||||
type FocalPoint = ((usize, usize), Point);
|
||||
|
||||
/// Reshape Tool: a click on a selected path makes the anchor or the point on a segment there the
|
||||
/// focal point (on a segment it adds an anchor), Shift-click adds or removes more, and a drag
|
||||
/// moves the focal points with the drag and stretches the selected anchors around them.
|
||||
#[derive(Default)]
|
||||
pub struct ReshapeTool {
|
||||
/// (path, grab point) while dragging.
|
||||
grab: Option<(vectorcraft_doc::NodeId, Point)>,
|
||||
/// The press, until the button is released.
|
||||
press: Option<ReshapePress>,
|
||||
began: bool,
|
||||
/// Pointer and offset readout while a reshape drag is in progress.
|
||||
measure: Option<(Point, String)>,
|
||||
/// The focal points the tool highlighted: their path, and each one's `(subpath, anchor)` and
|
||||
/// where that anchor lies. An anchor the tool adds before one renumbers it. A focal point
|
||||
/// whose anchor no longer lies there (another edit moved or removed it) shows nothing and
|
||||
/// moves nothing.
|
||||
focal: Option<(vectorcraft_doc::NodeId, Vec<FocalPoint>)>,
|
||||
}
|
||||
|
||||
impl ReshapeTool {
|
||||
/// The selected path under `p` (Reshape works on selected paths only, like Illustrator).
|
||||
fn hit(cx: &ToolContext, p: Point) -> Option<(vectorcraft_doc::NodeId, Point)> {
|
||||
/// The selected path under `p` (Reshape works on selected paths only), the point on it and
|
||||
/// what the point is.
|
||||
fn hit(cx: &ToolContext, p: Point) -> Option<(vectorcraft_doc::NodeId, Point, ReshapeHit)> {
|
||||
let tol = cx.tol(4.0);
|
||||
let editable = cx.doc.editable();
|
||||
cx.selection.objects.iter().copied().filter(|id| editable(*id)).find_map(|id| {
|
||||
Self::paths(cx).into_iter().find_map(|id| {
|
||||
let pd = cx.doc.node(id)?.path_data()?;
|
||||
let (_, _, _, q, d) = pd.nearest(p)?;
|
||||
(d <= tol).then_some((id, q))
|
||||
let (si, seg, _, q, d) = pd.nearest(p)?;
|
||||
if d > tol {
|
||||
return None;
|
||||
}
|
||||
// `path.reshape` takes the anchor within `tol` of the point on the path.
|
||||
let anchor = pd
|
||||
.subpaths
|
||||
.get(si)?
|
||||
.anchors
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, a)| (i, a.p.distance(q)))
|
||||
.filter(|&(_, dist)| dist <= tol)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
Some(match anchor {
|
||||
Some((ai, _)) => (id, pd.subpaths.get(si)?.anchors.get(ai)?.p, ReshapeHit::Anchor(si, ai)),
|
||||
// `SubPath::insert_anchor` puts the new anchor after the segment's first anchor.
|
||||
None => (id, q, ReshapeHit::Segment(si, seg.saturating_add(1))),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The paths Reshape works on: the selected paths that can be edited, in selection order.
|
||||
fn paths(cx: &ToolContext) -> Vec<vectorcraft_doc::NodeId> {
|
||||
let editable = cx.doc.editable();
|
||||
cx.selection.objects.iter().copied().filter(|&id| editable(id) && cx.doc.node(id).is_some_and(|n| n.path_data().is_some())).collect()
|
||||
}
|
||||
|
||||
/// The highlighted focal points of path `id` as `(subpath, anchor)`, with their positions: the
|
||||
/// anchors of the path that lie where the tool stored them. None while Reshape does not work
|
||||
/// on the path.
|
||||
fn focal_points(&self, cx: &ToolContext, id: vectorcraft_doc::NodeId) -> Vec<((usize, usize), Point)> {
|
||||
let Some((fid, points)) = &self.focal else { return vec![] };
|
||||
if *fid != id || !Self::paths(cx).contains(&id) {
|
||||
return vec![];
|
||||
}
|
||||
let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { return vec![] };
|
||||
let mut out: Vec<((usize, usize), Point)> = vec![];
|
||||
for &((si, ai), p) in points {
|
||||
let here = pd.subpaths.get(si).and_then(|sp| sp.anchors.get(ai)).map(|a| a.p);
|
||||
if let Some(q) = here.filter(|q| q.distance(p) <= FOCAL_MATCH)
|
||||
&& !out.iter().any(|(o, _)| *o == (si, ai))
|
||||
{
|
||||
out.push(((si, ai), q));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The focal points other than the pressed point that a drag from `press` moves: all the
|
||||
/// highlighted ones when the press is on one of them, else none (Shift only keeps the drag's
|
||||
/// angle).
|
||||
fn dragged_focal(&self, cx: &ToolContext, press: &ReshapePress) -> Vec<((usize, usize), Point)> {
|
||||
let focal = self.focal_points(cx, press.id);
|
||||
let pressed = |r: &(usize, usize)| matches!(press.hit, ReshapeHit::Anchor(si, ai) if (si, ai) == *r);
|
||||
if focal.iter().any(|(r, _)| pressed(r)) { focal.into_iter().filter(|(r, _)| !pressed(r)).collect() } else { vec![] }
|
||||
}
|
||||
|
||||
/// `path.reshape`'s params for `press` dragged by `d`.
|
||||
fn params(cx: &ToolContext, press: &ReshapePress, d: Vec2) -> Value {
|
||||
let others: Vec<[usize; 2]> = press.others.iter().map(|&((si, ai), _)| [si, ai]).collect();
|
||||
let mut v = json!({ "id": press.id.0, "x": press.at.x, "y": press.at.y, "dx": d.x, "dy": d.y, "tol": cx.tol(4.0) });
|
||||
if !others.is_empty() {
|
||||
v["focal"] = json!(others);
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for ReshapeTool {
|
||||
@@ -296,18 +415,19 @@ impl Tool for ReshapeTool {
|
||||
"reshape"
|
||||
}
|
||||
fn busy(&self) -> bool {
|
||||
self.grab.is_some()
|
||||
self.press.is_some()
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
self.grab = Self::hit(cx, ev.pos);
|
||||
self.press =
|
||||
Self::hit(cx, ev.pos).map(|(id, at, hit)| ReshapePress { id, at, hit, shift: ev.mods.shift, d: Vec2::ZERO, others: vec![] });
|
||||
self.began = false;
|
||||
self.measure = None;
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
let Some((id, at)) = self.grab else { return vec![] };
|
||||
let Some(at) = self.press.as_ref().map(|p| p.at) else { return vec![] };
|
||||
let mut d: Vec2 = ev.pos - at;
|
||||
if ev.mods.shift {
|
||||
d = vectorcraft_geom::constrain_angle_from(d, 45.0, cx.constrain_angle);
|
||||
@@ -318,25 +438,58 @@ impl Tool for ReshapeTool {
|
||||
return out;
|
||||
}
|
||||
self.began = true;
|
||||
let others = self.press.as_ref().map(|p| self.dragged_focal(cx, p)).unwrap_or_default();
|
||||
if let Some(p) = self.press.as_mut() {
|
||||
p.others = others;
|
||||
}
|
||||
out.push(Action::Begin("Reshape".into()));
|
||||
}
|
||||
let Some(press) = self.press.as_mut() else { return out };
|
||||
press.d = d;
|
||||
self.measure = cx.measurement_labels.then(|| (ev.pos + Vec2::new(cx.tol(12.0), cx.tol(12.0)), cx.offset_label(d.x, d.y)));
|
||||
out.push(Action::Preview(
|
||||
"path.reshape".into(),
|
||||
json!({ "id": id.0, "x": at.x, "y": at.y, "dx": d.x, "dy": d.y, "tol": cx.tol(4.0) }),
|
||||
));
|
||||
out.push(Action::Preview("path.reshape".into(), Self::params(cx, press, d)));
|
||||
out
|
||||
}
|
||||
PointerKind::Up => {
|
||||
self.grab = None;
|
||||
self.measure = None;
|
||||
if std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![] }
|
||||
let Some(press) = self.press.take() else { return vec![] };
|
||||
if std::mem::take(&mut self.began) {
|
||||
// The focal points move with the whole drag, the grabbed point too, and are
|
||||
// renumbered after an anchor added on a segment.
|
||||
let mut points: Vec<((usize, usize), Point)> = press.others.iter().map(|&(r, p)| (press.hit.renumber(r), p + press.d)).collect();
|
||||
points.push((press.hit.anchor(), press.at + press.d));
|
||||
self.focal = Some((press.id, points));
|
||||
return vec![Action::Commit];
|
||||
}
|
||||
// A click: the point becomes the focal point, or with Shift is added to them or,
|
||||
// when it is one already, removed.
|
||||
let mut points: Vec<((usize, usize), Point)> = if press.shift { self.focal_points(cx, press.id) } else { vec![] };
|
||||
let mut out = vec![];
|
||||
match press.hit {
|
||||
ReshapeHit::Anchor(si, ai) => {
|
||||
let before = points.len();
|
||||
points.retain(|&(r, _)| r != (si, ai));
|
||||
if points.len() == before {
|
||||
points.push(((si, ai), press.at));
|
||||
}
|
||||
}
|
||||
ReshapeHit::Segment(..) => {
|
||||
// A reshape by no distance adds the anchor and keeps the selection as it is.
|
||||
out.push(Action::Exec("path.reshape".into(), Self::params(cx, &press, Vec2::ZERO)));
|
||||
for (r, _) in &mut points {
|
||||
*r = press.hit.renumber(*r);
|
||||
}
|
||||
points.push((press.hit.anchor(), press.at));
|
||||
}
|
||||
}
|
||||
self.focal = (!points.is_empty()).then_some((press.id, points));
|
||||
out
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Escape && self.grab.take().is_some() {
|
||||
if key == ToolKey::Escape && self.press.take().is_some() {
|
||||
self.measure = None;
|
||||
if std::mem::take(&mut self.began) {
|
||||
return vec![Action::Cancel];
|
||||
@@ -344,17 +497,30 @@ impl Tool for ReshapeTool {
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
self.focal = None;
|
||||
self.measure = None;
|
||||
if self.press.take().is_some() && std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] }
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut out = vec![];
|
||||
// Each focal point: a filled anchor square inside a larger hollow one.
|
||||
if let Some((id, _)) = &self.focal {
|
||||
let color = cx.doc.layer_color(*id);
|
||||
for (_, p) in self.focal_points(cx, *id) {
|
||||
out.push(Overlay::Anchor { p, color, filled: false, size: 10.0 });
|
||||
out.push(Overlay::Anchor { p, color, filled: true, size: 5.0 });
|
||||
}
|
||||
}
|
||||
if cx.measurement_labels
|
||||
&& let Some((p, t)) = &self.measure
|
||||
{
|
||||
vec![Overlay::Measure { p: *p, text: t.clone() }]
|
||||
} else {
|
||||
vec![]
|
||||
out.push(Overlay::Measure { p: *p, text: t.clone() });
|
||||
}
|
||||
out
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
|
||||
if self.grab.is_some() || Self::hit(cx, p).is_some() { Cursor::Move } else { Cursor::Arrow }
|
||||
if self.press.is_some() || Self::hit(cx, p).is_some() { Cursor::Move } else { Cursor::Arrow }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+28
-13
@@ -3676,21 +3676,36 @@ 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`.
|
||||
`path.reshape {id, x, y, dx, dy, tol?, focal?, 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
|
||||
has the Reshape tool stretch part of a path from the dragged point without losing the path's general form, with every
|
||||
highlighted focal point pulling the path. Which anchors stay, how far the stretch reaches and the share of the drag each
|
||||
point takes are VectorCraft's own rule. The grabbed point and the anchors that `focal` lists as `[subpath, anchor]` pairs are focal
|
||||
points and move by the whole drag; a pair that names no anchor of the path is refused ("`focal` names [0, 7], which is
|
||||
not an anchor of the path"). Anchors that are not direct-selected stay in place (on a path selected whole, or not
|
||||
selected, every anchor is selected), and so do the two ends of an open path unless they are focal points. A selected
|
||||
anchor between two focal points moves by the whole drag. A selected anchor between a focal point and an anchor that
|
||||
stays takes `u² (3 - 2u)` of the drag, where `u` is its distance from the anchor that stays along the path's control
|
||||
polygon (anchor, out handle, the next in handle, the next anchor) divided by the focal point's distance from it. 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.
|
||||
with it; a handle that is long beside that distance takes a share below 0 or above 1, and moves against the drag or
|
||||
farther than the focal point. A closed subpath with every anchor selected moves whole, and a subpath with no focal
|
||||
point does not move. An anchor added on a segment of a partly selected path is selected, and the other selected
|
||||
anchors stay selected. With `radius`, only the grabbed point's subpath changes, whatever the selection: the anchors
|
||||
within `radius` of the grabbed point along the path follow with a smooth falloff and the others stay; a negative
|
||||
`radius` is refused ("`radius` must be 0 or more"), and so is `radius` given with `focal` ("`focal` and `radius`
|
||||
cannot be used together"). 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.
|
||||
|
||||
With the Reshape tool, a click on an anchor of a selected path makes it the focal point, and a click on a segment adds
|
||||
an anchor there (one undo step) that becomes the focal point. Shift-click adds an anchor or a point on a segment to the
|
||||
focal points. The rest of the tool's behavior is VectorCraft's own rule: Shift-click on a focal point removes it, and
|
||||
the focal points belong to one path, so a click on another selected path starts again there. Each focal point shows a
|
||||
filled anchor square in a larger hollow one. A drag from a focal point moves all of them; a drag from another point
|
||||
moves that point alone as the focal point, with or without Shift (Shift keeps the drag's angle). After a drag the
|
||||
focal points stay highlighted where it left them; Escape during a drag puts the path back and keeps them, and
|
||||
switching to another tool clears them.
|
||||
|
||||
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
|
||||
|
||||
@@ -934,7 +934,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.
|
||||
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`). A click sets a focal point and a click on a segment adds a highlighted anchor there; Shift-click adds more, and a drag from any of them moves them all. The anchors that are not direct-selected stay in place and a closed path selected whole moves whole (VectorCraft's own rule, in `docs/mcp.md`).
|
||||
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)).
|
||||
|
||||
Reference in New Issue
Block a user