mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 13:47:21 -06:00
Perspective Selection: Shift constrains each object in its own plane
perspective.move takes constrain: true instead of a plane-space direction. For each object, the command snaps the move to the nearest axis or 45-degree diagonal of that object's own plane, so a selection on several planes keeps every object on a line of its plane. The tool sends the raw pointer with the flag. On a diagonal, Snap to Grid now moves along the diagonal by the nearer per-axis correction, so that edge lands on its gridline.
This commit is contained in:
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move in Perspective",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid), constrain?: [dx,dy] (nonzero plane-space direction; keeps snap correction on that line for in-plane moves)} slide objects within their plane (unattached objects attach to `plane`/the active plane first); snapping pulls the nearer edge of the objects' joint bounds toward a gridline within a quarter cell, along `constrain` when supplied; Transform Again repeats it",
|
||||
"{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid), constrain?: bool (default false; constrain each in-plane move to its plane's nearest axis or 45° diagonal)} slide objects within their own planes (unattached objects attach to `plane`/the active plane first); snapping pulls the nearer edge of the first plane's joint bounds toward a gridline within a quarter cell while preserving the constrained direction; Transform Again repeats it",
|
||||
has_doc,
|
||||
persp_move
|
||||
),
|
||||
|
||||
@@ -284,28 +284,42 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
|
||||
let (perp, copy) = (switch_or(p, "perpendicular", false, C)?, switch_or(p, "copy", false, C)?);
|
||||
let constrain = match p.get("constrain").filter(|v| !v.is_null()) {
|
||||
Some(v) => {
|
||||
let direction = v.as_array().filter(|v| v.len() == 2).and_then(|v| Some((v.first()?.as_f64()?, v.get(1)?.as_f64()?)));
|
||||
let (x, y) = direction.ok_or_else(|| bad(C, "constrain must be two finite numbers, not both zero"))?;
|
||||
if !x.is_finite() || !y.is_finite() || (x == 0.0 && y == 0.0) {
|
||||
return Err(bad(C, "constrain must be two finite numbers, not both zero"));
|
||||
}
|
||||
let scale = x.abs().max(y.abs());
|
||||
let (x, y) = (x / scale, y / scale);
|
||||
let length = x.hypot(y);
|
||||
Some(vectorcraft_geom::Vec2::new(x / length, y / length))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let constrain = switch_or(p, "constrain", false, C)?;
|
||||
// `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false.
|
||||
check_switches(p, &["snap"], C)?;
|
||||
// Snap to Grid: a correction for the objects on the first one's plane and depth.
|
||||
let fix = if perp { None } else { crate::cmd::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, to)) };
|
||||
let snapped = move |pl: Plane, depth: f64, dv: vectorcraft_geom::Vec2| {
|
||||
dv + fix
|
||||
.filter(|f| f.0 == pl && f.1 == depth)
|
||||
.map_or(vectorcraft_geom::Vec2::ZERO, |f| constrain.map_or(f.2, |unit| unit * (f.2.x * unit.x + f.2.y * unit.y)))
|
||||
let snap_to = if constrain && !perp {
|
||||
let doc = &s.doc()?.doc;
|
||||
let g = grid_of(doc);
|
||||
g.plane_bounds(doc, &ids)
|
||||
.and_then(|(plane, depth, _)| {
|
||||
let h = g.homography_at(plane, depth)?;
|
||||
let start = h.inverse()?.apply(from)?;
|
||||
let delta = g.plane_delta(plane, depth, from, to)?;
|
||||
h.apply(start + vectorcraft_tools::distort::perspective::constrain_move_delta(delta).1)
|
||||
})
|
||||
.unwrap_or(to)
|
||||
} else {
|
||||
to
|
||||
};
|
||||
let fix = if perp { None } else { crate::cmd::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, snap_to)) };
|
||||
let snapped = move |pl: Plane, depth: f64, raw: vectorcraft_geom::Vec2| {
|
||||
let (direction, dv) =
|
||||
if constrain { vectorcraft_tools::distort::perspective::constrain_move_delta(raw) } else { (vectorcraft_geom::Vec2::ZERO, raw) };
|
||||
dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| {
|
||||
if !constrain {
|
||||
return f.2;
|
||||
}
|
||||
if direction.x.abs() < 1e-12 {
|
||||
return vectorcraft_geom::Vec2::new(0.0, f.2.y);
|
||||
}
|
||||
if direction.y.abs() < 1e-12 {
|
||||
return vectorcraft_geom::Vec2::new(f.2.x, 0.0);
|
||||
}
|
||||
let (correction, axis) =
|
||||
if f.2.x == 0.0 || (f.2.y != 0.0 && f.2.y.abs() < f.2.x.abs()) { (f.2.y, direction.y) } else { (f.2.x, direction.x) };
|
||||
direction * (correction / axis)
|
||||
})
|
||||
};
|
||||
// In-plane: the plane-space offset under the pointer; perpendicular: the depth it reaches.
|
||||
let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> {
|
||||
|
||||
@@ -197,18 +197,67 @@ fn constrained_move_command_stays_on_axis_with_grid_snap() {
|
||||
let start = bounds_of(&before).center();
|
||||
let from = h.apply(start).unwrap();
|
||||
let to = h.apply(start + Vec2::new(80.0, 0.0)).unwrap();
|
||||
s.execute("perspective.move", &json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y], "constrain": [1.0, 0.0]})).unwrap();
|
||||
s.execute("perspective.move", &json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y], "constrain": true})).unwrap();
|
||||
for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) {
|
||||
assert!((b.y - a.y).abs() < 1e-6, "constrained command left axis: {a:?} to {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_move_rejects_invalid_direction() {
|
||||
fn diagonal_shift_snap_lands_an_edge_on_a_gridline() {
|
||||
let (mut s, id) = near_gridline_session();
|
||||
for direction in [json!([0.0, 0.0]), json!([1.0]), json!([1.0, "bad"]), json!([null, 1.0])] {
|
||||
let g = grid(&s);
|
||||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||||
let b = bounds_of(&before);
|
||||
let (raw, correction) = (1..200)
|
||||
.map(|n| {
|
||||
let raw = Vec2::new(n as f64 * g.cell * 0.05, n as f64 * g.cell * 0.05);
|
||||
(raw, g.snap_offset(b, raw) - raw)
|
||||
})
|
||||
.find(|(_, fix)| fix.x.abs() < 1e-9 && fix.y.abs() > 1e-6)
|
||||
.unwrap();
|
||||
let h = g.homography(Plane::Right).unwrap();
|
||||
let start = b.center();
|
||||
let from = h.apply(start).unwrap();
|
||||
let to = h.apply(start + raw).unwrap();
|
||||
s.execute("perspective.move", &json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y], "constrain": true})).unwrap();
|
||||
let after = plane_pts(&s, id, Plane::Right, 0.0);
|
||||
let delta = after[0] - before[0];
|
||||
assert!((delta.x - delta.y).abs() < 1e-6, "{delta:?}");
|
||||
let edge = [b.y0 + delta.y, b.y1 + delta.y];
|
||||
assert!(edge.iter().any(|y| ((y / g.cell).round() * g.cell - y).abs() < 1e-6), "{edge:?}, correction {correction:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_move_uses_each_objects_plane() {
|
||||
let (mut s, right) = persp_session();
|
||||
let left_result = s.execute("shape.rectangle", &json!({"x": 240, "y": 380, "width": 60, "height": 60})).unwrap();
|
||||
let left = NodeId(left_result["id"].as_u64().unwrap());
|
||||
s.execute("perspective.attach", &json!({"ids": [left.0], "plane": "left"})).unwrap();
|
||||
let right_before = plane_pts(&s, right, Plane::Right, 0.0);
|
||||
let left_before = plane_pts(&s, left, Plane::Left, 0.0);
|
||||
let from = grid(&s).homography(Plane::Right).unwrap().apply(bounds_of(&right_before).center()).unwrap();
|
||||
let to = from + Vec2::new(70.0, 30.0);
|
||||
let g = grid(&s);
|
||||
for plane in [Plane::Right, Plane::Left] {
|
||||
let raw = g.plane_delta(plane, 0.0, from, to).unwrap();
|
||||
assert!(raw.x.abs() > 1e-3 && raw.y.abs() > 1e-3 && (raw.x.abs() - raw.y.abs()).abs() > 1e-3, "raw {plane:?}: {raw:?}");
|
||||
}
|
||||
s.execute("perspective.move", &json!({"ids": [right.0, left.0], "from": [from.x, from.y], "to": [to.x, to.y], "constrain": true, "snap": false}))
|
||||
.unwrap();
|
||||
for (id, plane, before) in [(right, Plane::Right, right_before), (left, Plane::Left, left_before)] {
|
||||
let after = plane_pts(&s, id, plane, 0.0);
|
||||
let delta = after[0] - before[0];
|
||||
assert!(delta.x.abs() < 1e-6 || delta.y.abs() < 1e-6 || (delta.x.abs() - delta.y.abs()).abs() < 1e-6, "{plane:?}: {delta:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_move_rejects_non_boolean_switch() {
|
||||
let (mut s, id) = near_gridline_session();
|
||||
for direction in [json!([0.0, 0.0]), json!([1.0]), json!(1), json!("true")] {
|
||||
let error = s.execute("perspective.move", &json!({"ids": [id.0], "from": [1.0, 1.0], "to": [2.0, 2.0], "constrain": direction})).unwrap_err();
|
||||
assert!(error.to_string().contains("constrain must be two finite numbers, not both zero"), "{error}");
|
||||
assert!(error.to_string().contains("`constrain` must be true or false"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,14 @@ impl Plane {
|
||||
pub const ALL: [Plane; 3] = [Plane::Left, Plane::Right, Plane::Ground];
|
||||
}
|
||||
|
||||
/// Return the nearest 45-degree direction of `delta` in plane space, counted from the plane's x axis,
|
||||
/// and `delta` projected onto that direction.
|
||||
pub fn constrain_move_delta(delta: Vec2) -> (Vec2, Vec2) {
|
||||
let angle = (delta.y.atan2(delta.x) / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4;
|
||||
let direction = Vec2::new(angle.cos(), angle.sin());
|
||||
(direction, direction * (delta.x * direction.x + delta.y * direction.y))
|
||||
}
|
||||
|
||||
/// Plane Switching widget: (plane, face quad) list and the "no plane" circle (centre, radius).
|
||||
pub type WidgetGeom = (Vec<(Plane, [Point; 4])>, (Point, f64));
|
||||
|
||||
@@ -1247,16 +1255,37 @@ mod tests {
|
||||
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y).with_mods(Mods { shift: true, ..Default::default() }));
|
||||
let Some(Action::Preview(cmd, v)) = acts.last() else { panic!("{acts:?}") };
|
||||
assert_eq!(cmd, "perspective.move");
|
||||
let endpoint = Point::new(v["to"][0].as_f64().unwrap(), v["to"][1].as_f64().unwrap());
|
||||
let delta = hi.apply(endpoint).unwrap() - start;
|
||||
assert_eq!(v["to"], json!([to.x, to.y]));
|
||||
let raw_delta = hi.apply(to).unwrap() - start;
|
||||
let delta = constrain_move_delta(raw_delta).1;
|
||||
assert!((delta - expected).hypot() < 1e-6, "{plane:?}: {delta:?} != {expected:?}");
|
||||
let direction = expected / expected.hypot();
|
||||
let sent = Vec2::new(v["constrain"][0].as_f64().unwrap(), v["constrain"][1].as_f64().unwrap());
|
||||
assert!((sent - direction).hypot() < 1e-12, "{plane:?}: {sent:?} != {direction:?}");
|
||||
assert_eq!(v["constrain"], json!(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_shift_preview_sends_raw_pointer_and_boolean_constraint() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
grid(2).store(&mut d);
|
||||
set_attachment(d.node_mut(id).unwrap(), Plane::Right, 0.0);
|
||||
let mut selection = Selection::default();
|
||||
selection.add(id);
|
||||
let p = paint();
|
||||
let c = cx(&d, &selection, &p);
|
||||
let h = PerspectiveGrid::current(&d).homography(Plane::Right).unwrap();
|
||||
let from = d.node(id).unwrap().geometric_bounds().unwrap().center();
|
||||
let start = h.inverse().unwrap().apply(from).unwrap();
|
||||
let to = h.apply(start + Vec2::new(60.0, 12.0)).unwrap();
|
||||
let mut tool = PerspectiveSelectionTool::default();
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, from.x, from.y));
|
||||
let actions = tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y).with_mods(Mods { shift: true, ..Default::default() }));
|
||||
let Some(Action::Preview(command, params)) = actions.last() else { panic!("{actions:?}") };
|
||||
assert_eq!(command, "perspective.move");
|
||||
assert_eq!(params["to"], json!([to.x, to.y]));
|
||||
assert_eq!(params["constrain"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_move_recomputes_shift_copy_and_perpendicular() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
@@ -1279,8 +1308,8 @@ mod tests {
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y).with_mods(Mods { shift: true, alt: true, ..Default::default() }));
|
||||
let Some(Action::Preview(_, snapped)) = shifted.last() else { panic!("{shifted:?}") };
|
||||
assert_eq!(snapped["copy"], json!(true));
|
||||
assert_eq!(snapped["constrain"], json!([1.0, 0.0]));
|
||||
assert_ne!(snapped["to"], raw["to"]);
|
||||
assert_eq!(snapped["constrain"], json!(true));
|
||||
assert_eq!(snapped["to"], raw["to"]);
|
||||
let perpendicular = t.key(&c, ToolKey::Digit(5), Mods { shift: true, alt: true, ..Default::default() });
|
||||
let Some(Action::Preview(_, depth)) = perpendicular.last() else { panic!("{perpendicular:?}") };
|
||||
assert_eq!(depth["to"], raw["to"]);
|
||||
|
||||
@@ -169,34 +169,27 @@ impl PerspectiveSelectionTool {
|
||||
let at = ids.first().and_then(|id| g.attachment_of(cx.doc, *id)).or((g.plane != Plane::None).then_some((g.plane, g.offset(g.plane))));
|
||||
let constrained = if *shift && !*perp {
|
||||
at.and_then(|(plane, depth)| {
|
||||
let h = g.homography_at(plane, depth)?;
|
||||
let hi = h.inverse()?;
|
||||
let start = hi.apply(*from)?;
|
||||
let delta = hi.apply(*last)? - start;
|
||||
let angle = (delta.y.atan2(delta.x) / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4;
|
||||
let direction = Vec2::new(angle.cos(), angle.sin());
|
||||
let distance = delta.x * direction.x + delta.y * direction.y;
|
||||
h.apply(start + direction * distance).filter(|p| p.x.is_finite() && p.y.is_finite()).map(|to| (to, direction))
|
||||
let delta = g.plane_delta(plane, depth, *from, *last)?;
|
||||
Some(super::constrain_move_delta(delta).1)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let to = if let Some((to, _)) = constrained { to } else { *last };
|
||||
let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [to.x, to.y], "copy": copy, "perpendicular": perp});
|
||||
if let Some((_, direction)) = constrained {
|
||||
v["constrain"] = json!([direction.x, direction.y]);
|
||||
let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [last.x, last.y], "copy": copy, "perpendicular": perp});
|
||||
if *shift && !*perp {
|
||||
v["constrain"] = json!(true);
|
||||
}
|
||||
if g.plane != Plane::None {
|
||||
v["plane"] = json!(g.plane.id());
|
||||
}
|
||||
self.measure = at.and_then(|(pl, depth)| {
|
||||
let text = if *perp {
|
||||
format!("dZ: {}", cx.len(g.depth_at(pl, depth, *from, to)? - depth))
|
||||
format!("dZ: {}", cx.len(g.depth_at(pl, depth, *from, *last)? - depth))
|
||||
} else {
|
||||
let d = g.plane_delta(pl, depth, *from, to)?;
|
||||
let d = constrained.unwrap_or(g.plane_delta(pl, depth, *from, *last)?);
|
||||
cx.offset_label(d.x, d.y)
|
||||
};
|
||||
Some((to, text))
|
||||
Some((*last, text))
|
||||
});
|
||||
Some(Action::Preview("perspective.move".into(), v))
|
||||
}
|
||||
|
||||
+3
-2
@@ -5477,8 +5477,9 @@ distance along the plane's normal), so copies, duplicates and pastes stay attach
|
||||
`perspectiveSelection` tool, a drag moves the selection within its plane (`perspective.move`; Alt copies), the
|
||||
bounding-box handles scale it in plane space (`perspective.transform`; Shift keeps proportions, Alt scales about the
|
||||
centre), and `press_key` `5` during a drag switches the move to perpendicular to the plane (press again to switch
|
||||
back). Holding Shift during an in-plane move constrains the displacement to a plane axis or 45° diagonal; `perspective.move`
|
||||
passes that plane-space direction as `constrain: [dx, dy]`, so Snap to Grid adjusts the move only along that line.
|
||||
back). Holding Shift during an in-plane move constrains each object's displacement to an axis or 45° diagonal of its
|
||||
own plane. `perspective.move` accepts `constrain: true` (default false) and adjusts Snap to Grid along the constrained
|
||||
direction on the first object's plane and depth.
|
||||
`press_key` Escape during a move, a resize or a plane widget drag cancels it and puts the objects and the
|
||||
grid back. The arrow keys nudge a selection in perspective by the keyboard increment (`perspective.nudge`; Shift ×10, Alt
|
||||
copies). Object › Transform › Transform Again repeats the last perspective move or scale in plane space.
|
||||
|
||||
@@ -1104,7 +1104,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
An Alt-drag with the Selection, Direct Selection, Group Selection, Rotate, Scale, Reflect or Shear tool copies the selection on its first move, and the moves after it transform those copies; the previews of the Move, Rotate, Scale, Reflect and Shear dialogs with Copy and Preview on copy the selection once in the same way (#1429).
|
||||
An Alt-drag of objects in perspective with the Perspective Selection tool, and an Alt-drag of a plane widget with that tool or the Perspective Grid tool, copy the objects on the first move, and the moves after it move those copies (#1429).
|
||||
With the Perspective Selection tool, Ctrl+Alt-drag (Command+Option-drag on macOS) also copies objects on their perspective plane; Alt/Option-drag continues to do so.
|
||||
With the Perspective Selection tool, holding Shift while moving or copying on a plane constrains the displacement to the nearest plane axis or 45° diagonal; releasing Shift resumes the raw pointer position, and 5 still moves perpendicular to the plane.
|
||||
With the Perspective Selection tool, holding Shift while moving or copying constrains each object's displacement to the nearest axis or 45° diagonal of its own plane; releasing Shift resumes the raw pointer position, and 5 still moves perpendicular to the plane. Snap to Grid follows the constrained line on the first object's plane and depth.
|
||||
When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns.
|
||||
The Layers panel's Delete Selection, Duplicate Selection, Collect in New Layer, Merge Selected, Flatten Artwork, Release to Layers (Sequence) and row drags also take time in proportion to the size of the document, and so do Cut Artboard and Duplicate Artboard with their art and each move of an Alt-drag of one artboard with the Artboard tool while Move/Copy Artwork with Artboard is on (#1429).
|
||||
In Outline view the renderer draws paths and compound paths from the geometry it keeps for them between frames, and it culls the members of clip groups by the bounds it keeps for them. Layers with Preview off and the outlines of Image Trace objects are drawn the same way.
|
||||
|
||||
Reference in New Issue
Block a user