mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 13:47:21 -06:00
Perspective Selection: Shift constrains a move to the plane's axes and diagonals
Holding Shift while dragging objects with the Perspective Selection tool snaps the move, in the plane's own coordinates, to the nearest plane axis or 45 degree diagonal. This applies to plain moves and to Alt and Ctrl+Alt copies. The preview, the final move and the measurement label use the snapped point. Pressing or releasing Shift, pressing 5 and pressing 1 to 4 during the drag recompute the preview from the pointer; a move perpendicular to the plane is not constrained.
This commit is contained in:
@@ -111,6 +111,73 @@ fn alt_drag_copies_and_the_copy_stays_attached() {
|
||||
assert!(s.doc().unwrap().doc.node(copy).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_drag_moves_on_a_plane_axis() {
|
||||
let (mut s, id) = persp_session();
|
||||
s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap();
|
||||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||||
let h = grid(&s).homography(Plane::Right).unwrap();
|
||||
let start = bounds_of(&before).center();
|
||||
let (from, to) = (h.apply(start).unwrap(), h.apply(start + Vec2::new(80.0, 20.0)).unwrap());
|
||||
let n = undo_len(&s);
|
||||
drag(&mut s, &[from, to, to], Mods { shift: true, ..Default::default() });
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) {
|
||||
assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6, "shift move: {a:?} → {b:?}");
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_drag_keeps_its_displacement_after_a_plane_key() {
|
||||
let (mut s, id) = persp_session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("perspectiveSelection", v).unwrap();
|
||||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||||
let h = grid(&s).homography(Plane::Right).unwrap();
|
||||
let start = bounds_of(&before).center();
|
||||
let (from, to) = (h.apply(start).unwrap(), h.apply(start + Vec2::new(80.0, 20.0)).unwrap());
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), v).unwrap();
|
||||
s.tool_key(ToolKey::Digit(2), shift, v).unwrap();
|
||||
assert_eq!(grid(&s).plane, Plane::Ground);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y).with_mods(shift), v).unwrap();
|
||||
for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) {
|
||||
assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6, "plane key: {a:?} → {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_alt_shift_drag_copies_on_a_plane_axis() {
|
||||
let (mut s, id) = persp_session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("directSelection", v).unwrap();
|
||||
s.select_tool("perspectiveSelection", v).unwrap();
|
||||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||||
let count = s.doc().unwrap().doc.node_count();
|
||||
let h = grid(&s).homography(Plane::Right).unwrap();
|
||||
let start = bounds_of(&before).center();
|
||||
let (from, to) = (h.apply(start).unwrap(), h.apply(start + Vec2::new(80.0, 20.0)).unwrap());
|
||||
let n = undo_len(&s);
|
||||
drag(&mut s, &[from, to, to], Mods { cmd: true, alt: true, shift: true, ..Default::default() });
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), count + 1);
|
||||
let copy = *s.doc().unwrap().selection.objects.first().unwrap();
|
||||
assert_ne!(copy, id);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![copy]);
|
||||
assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before);
|
||||
assert_eq!(attached(&s, copy), Some((Plane::Right, 0.0)));
|
||||
for (a, b) in before.iter().zip(plane_pts(&s, copy, Plane::Right, 0.0)) {
|
||||
assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6, "shift copy: {a:?} → {b:?}");
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), count);
|
||||
assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_alt_drag_copies_on_the_perspective_plane() {
|
||||
let (mut s, id) = persp_session();
|
||||
|
||||
@@ -457,6 +457,11 @@ impl Session {
|
||||
// 1–4 pick the active perspective plane while the grid is shown.
|
||||
if let Some(plane) = self.plane_key(key) {
|
||||
self.execute("perspective.plane.set", &serde_json::json!({ "plane": plane.id() }))?;
|
||||
if self.tool_id() == "perspectiveSelection" {
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods));
|
||||
self.take_tool_panic()?;
|
||||
return self.apply_actions(acts);
|
||||
}
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods));
|
||||
|
||||
@@ -1204,11 +1204,90 @@ mod tests {
|
||||
let Action::Preview(cmd, v) = &acts[0] else { panic!("{acts:?}") };
|
||||
assert_eq!((cmd.as_str(), &v["perpendicular"], &v["to"]), ("perspective.move", &json!(true), &json!([mid.x + 20.0, mid.y])));
|
||||
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text.starts_with("dZ"))));
|
||||
assert!(t.key(&c, ToolKey::Digit(3), Mods::default()).is_empty());
|
||||
assert!(matches!(t.key(&c, ToolKey::Digit(3), Mods::default()).as_slice(), [Action::Preview(cmd, _)] if cmd == "perspective.move"));
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, mid.x + 20.0, mid.y)), vec![Action::Commit]);
|
||||
assert!(!t.busy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_move_shift_snaps_in_each_plane() {
|
||||
for plane in Plane::ALL {
|
||||
let g = grid(2);
|
||||
let h = g.homography(plane).unwrap();
|
||||
let from = h.apply(Point::new(50.0, 50.0)).unwrap();
|
||||
let mut d = Document::new(800.0, 600.0);
|
||||
let layer = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
d.insert(
|
||||
Some(layer),
|
||||
0,
|
||||
vectorcraft_doc::Node::path(
|
||||
id,
|
||||
vectorcraft_geom::shapes::rectangle(Rect::new(from.x - 20.0, from.y - 20.0, from.x + 20.0, from.y + 20.0)),
|
||||
vectorcraft_doc::Appearance::default_art(),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
g.store(&mut d);
|
||||
set_attachment(d.node_mut(id).unwrap(), plane, 0.0);
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let hi = h.inverse().unwrap();
|
||||
let start = hi.apply(from).unwrap();
|
||||
for (raw, expected) in [
|
||||
(Vec2::new(60.0, 12.0), Vec2::new(60.0, 0.0)),
|
||||
(Vec2::new(12.0, 60.0), Vec2::new(0.0, 60.0)),
|
||||
(Vec2::new(40.0, 50.0), Vec2::new(45.0, 45.0)),
|
||||
] {
|
||||
let to = h.apply(start + raw).unwrap();
|
||||
let mut t = PerspectiveSelectionTool::default();
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, from.x, from.y));
|
||||
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!((delta - expected).hypot() < 1e-6, "{plane:?}: {delta:?} != {expected:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_move_recomputes_shift_copy_and_perpendicular() {
|
||||
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 s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &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 t = PerspectiveSelectionTool::default();
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, from.x, from.y));
|
||||
let plain = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y));
|
||||
let Some(Action::Preview(_, raw)) = plain.last() else { panic!("{plain:?}") };
|
||||
assert_eq!(raw["to"], json!([to.x, to.y]));
|
||||
let shifted =
|
||||
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_ne!(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"]);
|
||||
let in_plane = t.key(&c, ToolKey::Digit(5), Mods { shift: true, alt: true, ..Default::default() });
|
||||
let Some(Action::Preview(_, snapped_again)) = in_plane.last() else { panic!("{in_plane:?}") };
|
||||
assert_eq!(snapped_again["to"], snapped["to"]);
|
||||
let released = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, to.x, to.y));
|
||||
let Some(Action::Preview(_, raw_again)) = released.last() else { panic!("{released:?}") };
|
||||
assert_eq!(raw_again["to"], raw["to"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels_a_perspective_selection_drag() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
|
||||
@@ -119,17 +119,18 @@ enum SelDrag {
|
||||
#[default]
|
||||
Idle,
|
||||
/// Pressed on an object: moves `ids` once the pointer leaves the drag threshold. `perp`: along
|
||||
/// the plane's normal (5 pressed while dragging); `copy`: Alt at the last event.
|
||||
Move { ids: Vec<NodeId>, from: Point, last: Point, began: bool, perp: bool, copy: bool },
|
||||
/// the plane's normal (5 pressed while dragging); `copy` and `shift`: modifiers at the last event.
|
||||
Move { ids: Vec<NodeId>, from: Point, last: Point, began: bool, perp: bool, copy: bool, shift: bool },
|
||||
/// Dragging `handle` of the perspective bounding box `rect` (plane space, on `plane` at `depth`).
|
||||
Scale { ids: Vec<NodeId>, plane: Plane, depth: f64, rect: Rect, handle: BoxHandle },
|
||||
/// Dragging a plane widget.
|
||||
Plane(PlaneDrag),
|
||||
}
|
||||
|
||||
/// The Perspective Selection tool: selects, moves (Alt copies; press 5 while dragging to move
|
||||
/// perpendicular to the plane) and scales (bounding-box handles; Shift proportional, Alt from the
|
||||
/// centre) objects within their perspective planes; the arrow keys nudge them in perspective.
|
||||
/// The Perspective Selection tool: selects, moves (Shift constrains to plane-space 45° directions,
|
||||
/// Alt copies; press 5 while dragging to move perpendicular to the plane) and scales (bounding-box
|
||||
/// handles; Shift proportional, Alt from the center) objects within their perspective planes; the
|
||||
/// arrow keys nudge them in perspective.
|
||||
#[derive(Default)]
|
||||
pub struct PerspectiveSelectionTool {
|
||||
drag: SelDrag,
|
||||
@@ -163,21 +164,36 @@ pub(super) fn resize_cursor(d: Vec2) -> Cursor {
|
||||
impl PerspectiveSelectionTool {
|
||||
/// The `perspective.move` preview for the move drag (and its measurement label).
|
||||
fn move_preview(&mut self, cx: &ToolContext) -> Option<Action> {
|
||||
let SelDrag::Move { ids, from, last, perp, copy, .. } = &self.drag else { return None };
|
||||
let SelDrag::Move { ids, from, last, perp, copy, shift, .. } = &self.drag else { return None };
|
||||
let g = PerspectiveGrid::current(cx.doc);
|
||||
let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [last.x, last.y], "copy": copy, "perpendicular": perp});
|
||||
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 to = 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())
|
||||
})
|
||||
.unwrap_or(*last)
|
||||
} 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 g.plane != Plane::None {
|
||||
v["plane"] = json!(g.plane.id());
|
||||
}
|
||||
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))));
|
||||
self.measure = at.and_then(|(pl, depth)| {
|
||||
let text = if *perp {
|
||||
format!("dZ: {}", cx.len(g.depth_at(pl, depth, *from, *last)? - depth))
|
||||
format!("dZ: {}", cx.len(g.depth_at(pl, depth, *from, to)? - depth))
|
||||
} else {
|
||||
let d = g.plane_delta(pl, depth, *from, *last)?;
|
||||
let d = g.plane_delta(pl, depth, *from, to)?;
|
||||
cx.offset_label(d.x, d.y)
|
||||
};
|
||||
Some((*last, text))
|
||||
Some((to, text))
|
||||
});
|
||||
Some(Action::Preview("perspective.move".into(), v))
|
||||
}
|
||||
@@ -223,19 +239,19 @@ impl Tool for PerspectiveSelectionTool {
|
||||
{
|
||||
acts.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()})));
|
||||
}
|
||||
self.drag = SelDrag::Move { ids, from: p, last: p, began: false, perp: false, copy: m.alt };
|
||||
self.drag = SelDrag::Move { ids, from: p, last: p, began: false, perp: false, copy: m.alt, shift: m.shift };
|
||||
acts
|
||||
}
|
||||
(PointerKind::Drag, SelDrag::Move { ids, from, began, perp, .. }) => {
|
||||
if !began && p.distance(from) < cx.tol(3.0) {
|
||||
self.drag = SelDrag::Move { ids, from, last: p, began, perp, copy: m.alt };
|
||||
self.drag = SelDrag::Move { ids, from, last: p, began, perp, copy: m.alt, shift: m.shift };
|
||||
return vec![];
|
||||
}
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
out.push(Action::Begin(if m.alt { "Copy in Perspective" } else { "Move in Perspective" }.into()));
|
||||
}
|
||||
self.drag = SelDrag::Move { ids, from, last: p, began: true, perp, copy: m.alt };
|
||||
self.drag = SelDrag::Move { ids, from, last: p, began: true, perp, copy: m.alt, shift: m.shift };
|
||||
out.extend(self.move_preview(cx));
|
||||
out
|
||||
}
|
||||
@@ -289,10 +305,17 @@ impl Tool for PerspectiveSelectionTool {
|
||||
_ => vec![Action::Cancel],
|
||||
};
|
||||
}
|
||||
if let (ToolKey::Digit(5), SelDrag::Move { perp, began, .. }) = (key, &mut self.drag) {
|
||||
if let (ToolKey::Digit(5), SelDrag::Move { perp, began, shift, .. }) = (key, &mut self.drag) {
|
||||
*perp = !*perp;
|
||||
*shift = mods.shift;
|
||||
return if *began { self.move_preview(cx).into_iter().collect() } else { vec![] };
|
||||
}
|
||||
if matches!(key, ToolKey::Digit(1..=4))
|
||||
&& let SelDrag::Move { began: true, shift, .. } = &mut self.drag
|
||||
{
|
||||
*shift = mods.shift;
|
||||
return self.move_preview(cx).into_iter().collect();
|
||||
}
|
||||
let (dx, dy) = match key {
|
||||
ToolKey::Left => (-1, 0),
|
||||
ToolKey::Right => (1, 0),
|
||||
|
||||
@@ -1104,6 +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.
|
||||
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