mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Shaper: the Lasso, Smart Guides and the anchor tools skip a Shaper Group's original shapes
A Shaper Group keeps its original shapes as its first child, at no opacity, and clicks do not pick them outside Construction mode. The Lasso, the Add Anchor Point, Delete Anchor Point, Anchor Point and Scissors tools still picked their anchor points and segments, also where the group erased the face, and Smart Guides, Snap to Point and the transform tools' reference point snapped to them. The Lasso and the anchor tools also picked art outside the isolated group or object in isolation mode. The Lasso and the anchor tools now visit only the art a click can pick: the isolated object in isolation mode, and a Shaper Group without its original shapes unless they are isolated (Construction mode). Smart Guides and Snap to Point leave the original shapes out of their targets, and Spacing Guides leave out their bounds when a face moves. The transform tools' reference point snaps to an original shape only when that shape is selected, or under the pointer in Construction mode. The Shaper Group test document moves into the shared test helpers. Docs: docs/shaper.md, docs/target-app-parity.md.
This commit is contained in:
@@ -204,11 +204,35 @@ pub fn polyline(pts: &[Point]) -> BezPath {
|
||||
bp
|
||||
}
|
||||
|
||||
/// Editable path nodes, selected ones first, then the rest top-most first.
|
||||
/// Visit `n` and everything inside it, depth first in paint order, but a Shaper Group's original
|
||||
/// shapes (its first child), which are not shown.
|
||||
pub(crate) fn walk_shown<'a>(n: &'a vectorcraft_doc::Node, f: &mut impl FnMut(&'a vectorcraft_doc::Node)) {
|
||||
f(n);
|
||||
for c in n.children().into_iter().flatten().skip(usize::from(n.extras().shaper.is_some())) {
|
||||
walk_shown(c, f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Visit the nodes the anchor tools and the Lasso may pick, bottom to top ([`walk_shown`]). In
|
||||
/// isolation mode that is the isolated object and its contents, so in Construction mode, where a
|
||||
/// Shaper Group's original shapes are isolated, it visits them.
|
||||
pub(crate) fn walk_pickable<'a>(cx: &ToolContext<'a>, mut f: impl FnMut(&'a vectorcraft_doc::Node)) {
|
||||
if let Some(id) = cx.isolation
|
||||
&& let Some(n) = cx.doc.node(id)
|
||||
{
|
||||
walk_shown(n, &mut f);
|
||||
} else if cx.isolation.is_none() {
|
||||
for l in &cx.doc.layers {
|
||||
walk_shown(l, &mut f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Editable path nodes, selected ones first, then the rest top-most first ([`walk_pickable`]).
|
||||
fn candidate_paths(cx: &ToolContext) -> Vec<NodeId> {
|
||||
let mut v: Vec<NodeId> = cx.selection.objects.iter().copied().filter(|id| cx.doc.node(*id).is_some_and(|n| n.path_data().is_some())).collect();
|
||||
let mut rest = vec![];
|
||||
cx.doc.walk(|n| {
|
||||
walk_pickable(cx, |n| {
|
||||
if let NodeKind::Path { guide: false, .. } = &n.kind {
|
||||
rest.push(n.id);
|
||||
}
|
||||
|
||||
@@ -715,6 +715,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The Add Anchor Point, Delete Anchor Point, Anchor Point and Scissors tools pick anchors and
|
||||
/// segments only on the art a click can pick: not on a Shaper Group's original shapes unless
|
||||
/// they are isolated (Construction mode), and in isolation mode only on the isolated object.
|
||||
#[test]
|
||||
fn anchor_tools_leave_out_shaper_sources_and_art_outside_isolation() {
|
||||
let (d, _, sources, face) = doc_with_shaper_group();
|
||||
let rect = d.layers[0].children().unwrap()[0].id;
|
||||
let b = d.node(sources).and_then(|n| n.children()).unwrap()[1].id;
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
// The path the first command of a press with `tool` at (x, y) acts on.
|
||||
let picked = |isolation: Option<NodeId>, tool: &str, x: f64, y: f64| {
|
||||
let cx = ToolContext { isolation, ..cx(&d, &s, &p) };
|
||||
let mut t = AnchorTool::new(tool);
|
||||
let mut a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y));
|
||||
a.extend(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 20.0, y - 10.0)));
|
||||
a.iter().find_map(|a| match a {
|
||||
Action::Exec(_, v) | Action::Preview(_, v) => v["id"].as_u64().map(NodeId),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
// The second square's top right corner and its right side, which only its original shape
|
||||
// has.
|
||||
for (tool, x, y) in [("deleteAnchor", 150.0, 300.0), ("anchorPoint", 150.0, 300.0), ("addAnchor", 150.0, 350.0), ("scissors", 150.0, 350.0)] {
|
||||
assert_eq!(picked(None, tool, x, y), None, "{tool}");
|
||||
assert_eq!(picked(Some(sources), tool, x, y), Some(b), "{tool}");
|
||||
}
|
||||
// The face's top left corner, outside the isolated rectangle.
|
||||
assert_eq!(picked(None, "deleteAnchor", 0.0, 300.0), Some(face));
|
||||
assert_eq!(picked(Some(rect), "deleteAnchor", 0.0, 300.0), None);
|
||||
assert_eq!(picked(Some(rect), "deleteAnchor", 100.0, 100.0), Some(rect));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_point_drag_pulls_handles_click_converts() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
@@ -137,12 +137,13 @@ fn within<T>(sorted: &[T], key: impl Fn(&T) -> f64, v: f64, tol: f64) -> &[T] {
|
||||
}
|
||||
|
||||
/// Visit `nodes` and everything inside them, but the nodes in `skip` and everything inside those:
|
||||
/// one pass, however many are skipped.
|
||||
/// one pass, however many are skipped. A Shaper Group's original shapes, its first child, are
|
||||
/// not shown and are skipped too.
|
||||
fn walk_but<'a>(nodes: &'a [Arc<Node>], skip: &HashSet<NodeId>, f: &mut impl FnMut(&'a Node)) {
|
||||
for n in nodes.iter().filter(|n| !skip.contains(&n.id)) {
|
||||
f(n);
|
||||
if let Some(ch) = n.children() {
|
||||
walk_but(ch, skip, f);
|
||||
walk_but(ch.get(usize::from(n.extras().shaper.is_some())..).unwrap_or_default(), skip, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +160,7 @@ fn span(r: &Rect, x: bool) -> (f64, f64) {
|
||||
|
||||
/// The objects a moved selection (`moving`) spaces itself evenly with, by their bounds: each
|
||||
/// visible object on a layer as a whole (a group too), and inside a group holding some of the
|
||||
/// moving objects its other objects. At most [`BUDGET`].
|
||||
/// moving objects its other objects, but a Shaper Group's original shapes. At most [`BUDGET`].
|
||||
fn object_boxes(doc: &Document, moving: &[NodeId]) -> Vec<Rect> {
|
||||
fn push(nodes: &[Arc<Node>], moving: &HashSet<NodeId>, holders: &HashSet<NodeId>, out: &mut Vec<Rect>) {
|
||||
for n in nodes.iter().filter(|n| n.visible && !moving.contains(&n.id)) {
|
||||
@@ -167,7 +168,9 @@ fn object_boxes(doc: &Document, moving: &[NodeId]) -> Vec<Rect> {
|
||||
return;
|
||||
}
|
||||
match n.children() {
|
||||
Some(ch) if n.is_layer() || holders.contains(&n.id) => push(ch, moving, holders, out),
|
||||
Some(ch) if n.is_layer() || holders.contains(&n.id) => {
|
||||
push(ch.get(usize::from(n.extras().shaper.is_some())..).unwrap_or_default(), moving, holders, out)
|
||||
}
|
||||
_ => out.extend(n.geometric_bounds()),
|
||||
}
|
||||
}
|
||||
@@ -445,7 +448,7 @@ impl Targets {
|
||||
fn points_near(doc: &Document, roots: &[NodeId], near: Rect) -> Self {
|
||||
let mut t = Targets::default();
|
||||
for n in roots.iter().filter_map(|id| doc.node(*id)) {
|
||||
n.walk(&mut |c| {
|
||||
crate::draw2::walk_shown(n, &mut |c| {
|
||||
if c.is_container() || !c.visible {
|
||||
return;
|
||||
}
|
||||
@@ -1479,6 +1482,31 @@ mod tests {
|
||||
assert_eq!(t.snap_point(Point::new(101.0, 99.0), 4.0).0, Point::new(100.0, 100.0), "in line with its neighbours, not on itself");
|
||||
}
|
||||
|
||||
/// A Shaper Group's original shapes, which are not shown, are no targets: not their
|
||||
/// anchors, edges or segments, not their bounds as an object a moved face spaces itself evenly
|
||||
/// with, and not the anchors a transform tool's reference point snaps to on the selected group.
|
||||
/// Selected, or under the pointer in Construction mode, an original shape still gives its anchors.
|
||||
#[test]
|
||||
fn shaper_sources_are_no_targets() {
|
||||
let (d, g, sources, face) = doc_with_shaper_group();
|
||||
let b = d.node(sources).and_then(|n| n.children()).unwrap()[1].id;
|
||||
// The top right corner of the second source square, which no shown art has.
|
||||
let corner = Point::new(150.0, 300.0);
|
||||
let gives_corner = |t: &Targets| {
|
||||
t.points.iter().any(|(p, _)| *p == corner)
|
||||
|| t.xs.iter().chain(&t.ys).any(|(_, p, _)| *p == corner)
|
||||
|| t.segments.iter().any(|(_, s)| s.start() == corner || s.end() == corner)
|
||||
};
|
||||
assert!(!gives_corner(&Targets::collect(&d, &[], None)));
|
||||
assert!(!gives_corner(&Targets::for_drawing(&d, &[], None)));
|
||||
assert!(!gives_corner(&Targets::for_anchor_drag(&d, &Selection::default())));
|
||||
assert!(!gives_corner(&Targets::for_handle(&d, face)));
|
||||
assert!(!object_boxes(&d, &[face]).contains(&Rect::new(0.0, 300.0, 150.0, 400.0)), "the source shapes' bounds");
|
||||
let near = Rect::new(146.0, 296.0, 154.0, 304.0);
|
||||
assert!(!gives_corner(&Targets::points_near(&d, &[g], near)));
|
||||
assert!(gives_corner(&Targets::points_near(&d, &[b], near)));
|
||||
}
|
||||
|
||||
/// #2120: the dragged end of a straight segment at any angle lands on the segment's own
|
||||
/// extension (Line Extension), past its other end or short of it, the guide drawn from that
|
||||
/// end; with two anchors moving there is none to keep to.
|
||||
|
||||
@@ -867,6 +867,29 @@ pub(crate) mod testutil {
|
||||
(d, id)
|
||||
}
|
||||
|
||||
/// [`doc_with_rect`] plus a Shaper Group of two source squares (x from 0 to 100 and from 50 to
|
||||
/// 150, y from 300 to 400) whose visible face is the first square: the document, the group, its
|
||||
/// source shapes and the face.
|
||||
pub fn doc_with_shaper_group() -> (Document, NodeId, NodeId, NodeId) {
|
||||
use std::sync::Arc;
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let square = |x: f64| shapes::rectangle(Rect::new(x, 300.0, x + 100.0, 400.0));
|
||||
let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||||
let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art());
|
||||
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]);
|
||||
sources.name = Some(vectorcraft_doc::shaper::SOURCES.into());
|
||||
sources.opacity = 0.0;
|
||||
let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||||
face.name = Some(format!("{}0", vectorcraft_doc::shaper::FILL_PREFIX));
|
||||
let (sources_id, face_id) = (sources.id, face.id);
|
||||
let mut group = Node::group(d.alloc_id(), vec![Arc::new(sources), Arc::new(face)]);
|
||||
group.extras_mut().shaper = Some(Box::default());
|
||||
let group_id = group.id;
|
||||
d.insert(Some(l), 1, group).unwrap();
|
||||
(d, group_id, sources_id, face_id)
|
||||
}
|
||||
|
||||
pub fn paint() -> PaintDefaults {
|
||||
PaintDefaults::default()
|
||||
}
|
||||
|
||||
@@ -82,12 +82,6 @@ pub fn click(cx: &ToolContext, p: Point) -> Option<Action> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::shaper::FILL_PREFIX;
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use super::*;
|
||||
use crate::extra::ShaperTool;
|
||||
use crate::select::SelectionTool;
|
||||
@@ -98,28 +92,6 @@ mod tests {
|
||||
PointerEvent::new(kind, p.x, p.y)
|
||||
}
|
||||
|
||||
/// A Shaper Group of two source squares (x from 0 to 100 and from 50 to 150, y from 300 to 400)
|
||||
/// whose visible face is the first square: the document, the group, its source shapes and the
|
||||
/// face.
|
||||
fn doc_with_shaper_group() -> (Document, NodeId, NodeId, NodeId) {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let square = |x: f64| shapes::rectangle(Rect::new(x, 300.0, x + 100.0, 400.0));
|
||||
let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||||
let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art());
|
||||
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]);
|
||||
sources.name = Some(SOURCES.into());
|
||||
sources.opacity = 0.0;
|
||||
let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||||
face.name = Some(format!("{FILL_PREFIX}0"));
|
||||
let (sources_id, face_id) = (sources.id, face.id);
|
||||
let mut group = Node::group(d.alloc_id(), vec![Arc::new(sources), Arc::new(face)]);
|
||||
group.extras_mut().shaper = Some(Box::default());
|
||||
let group_id = group.id;
|
||||
d.insert(Some(l), 1, group).unwrap();
|
||||
(d, group_id, sources_id, face_id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_widget_sits_right_of_the_group_and_a_click_switches_the_mode() {
|
||||
let (d, g, sources, face) = doc_with_shaper_group();
|
||||
|
||||
@@ -103,9 +103,9 @@ impl Tool for LassoTool {
|
||||
if poly.len() < 3 {
|
||||
return if add || sub { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
|
||||
}
|
||||
// Anchors inside the loop for every editable path.
|
||||
// Anchors inside the loop for every editable path the Lasso may pick.
|
||||
let mut hits: Vec<(NodeId, BTreeSet<AnchorRef>)> = vec![];
|
||||
cx.doc.walk(|n| {
|
||||
crate::draw2::walk_pickable(cx, |n| {
|
||||
if let NodeKind::Path { path, .. } = &n.kind {
|
||||
let v: BTreeSet<AnchorRef> =
|
||||
path.anchors().filter(|(_, _, a)| point_in_polygon(&poly, a.p)).map(|(s, i, _)| (s, i)).collect();
|
||||
@@ -171,6 +171,36 @@ mod tests {
|
||||
assert_eq!(miss, vec![Action::Exec("select.none".into(), json!({}))]);
|
||||
}
|
||||
|
||||
/// The Lasso selects anchor points only on the art a click can pick: not on a Shaper Group's
|
||||
/// original shapes unless they are isolated (Construction mode), and in isolation mode only on
|
||||
/// the isolated object.
|
||||
#[test]
|
||||
fn lasso_leaves_out_shaper_sources_and_art_outside_isolation() {
|
||||
let (d, _, sources, face) = doc_with_shaper_group();
|
||||
let rect = d.layers[0].children().unwrap()[0].id;
|
||||
let b = d.node(sources).and_then(|n| n.children()).unwrap()[1].id;
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let lasso = |isolation: Option<NodeId>, poly: [(f64, f64); 3]| {
|
||||
let cx = ToolContext { isolation, ..cx(&d, &s, &p) };
|
||||
let mut t = LassoTool::default();
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, poly[0].0, poly[0].1));
|
||||
for (x, y) in &poly[1..] {
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, *x, *y));
|
||||
}
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, poly[2].0, poly[2].1));
|
||||
let Some(Action::Exec(_, v)) = a.first() else { panic!("a selection command: {a:?}") };
|
||||
v["items"].as_array().unwrap().iter().map(|it| NodeId(it["id"].as_u64().unwrap())).collect::<Vec<_>>()
|
||||
};
|
||||
// Around the top right corner of the second square, which only its original shape has.
|
||||
let corner = [(130.0, 280.0), (180.0, 280.0), (150.0, 320.0)];
|
||||
assert_eq!(lasso(None, corner), vec![]);
|
||||
assert_eq!(lasso(Some(sources), corner), vec![b]);
|
||||
// Around the face's top left corner, where the first original shape has an anchor too.
|
||||
let shared = [(-20.0, 280.0), (30.0, 280.0), (0.0, 320.0)];
|
||||
assert_eq!(lasso(None, shared), vec![face]);
|
||||
assert_eq!(lasso(Some(rect), shared), vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lasso_selects_anchors_inside() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
@@ -13,6 +13,8 @@ Click once to select the group; click again to select its visible fill and chang
|
||||
|
||||
While the group or one of its faces is selected (Face Selection mode) and the Shaper or Selection tool is active, an arrow widget sits beside the middle of the right side of the original shapes’ bounds. Click it to enter Construction mode: the original shapes are isolated and nothing is selected, and a click with Shaper selects the group’s original shape under the pointer. Click the widget again to leave Construction mode with the group selected. The arrow points right in Face Selection mode and left in Construction mode.
|
||||
|
||||
Smart Guides and Snap to Point do not snap to the original shapes. The reference point of the transform tools snaps to an original shape only when that shape is selected, or under the pointer in Construction mode. Outside Construction mode the Lasso does not pick the anchor points of the original shapes, and the Add Anchor Point, Delete Anchor Point, Anchor Point and Scissors tools do not pick the anchor points or segments of an original shape that is not selected.
|
||||
|
||||
Object › Shaper › Release restores the original paths. Expand keeps the visible result as ordinary paths. Each scribble and source edit records one undo step. Sources in a composition must share a parent layer/group; Shaper limits a composition to 64 source paths and 4,096 segments.
|
||||
|
||||
Finer gesture heuristics remain to be implemented.
|
||||
|
||||
@@ -501,6 +501,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing, and with the Shaper or Selection tool a click on the arrow widget beside a Shaper Group's original shapes switches the group between Face Selection and Construction mode. Native save/reopen, face recoloring, Release and Expand are covered. Finer gesture heuristics remain.
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
|
||||
With the Selection or Group Selection tool, pressing E while dragging a marquee makes the marquee select only the objects that lie entirely inside it. Pressing E again switches the marquee back to its default selection.
|
||||
The Lasso picks anchor points only on art a click can pick, and the Add Anchor Point, Delete Anchor Point, Anchor Point and Scissors tools pick anchor points and segments only on the selected paths and on art a click can pick: not on a Shaper Group's original shapes outside Construction mode, and in isolation mode only inside the isolated group or object. Smart Guides and Snap to Point do not snap to a Shaper Group's original shapes, and the reference point of the transform tools snaps to one only when it is selected, or under the pointer in Construction mode.
|
||||
A marquee selects a clip group, or an object on a layer with a clipping mask, only when it reaches the area inside the clipping path, and it measures the art only within that path's bounds. Within the bounds of a mask that is not a rectangle, it can still select art that the mask hides. Direct Selection's marquee still selects the hidden anchor points inside it (#2345).
|
||||
A marquee selects a Shaper Group only when it reaches the art the group shows, not the original shapes kept under it, and Direct Selection's clicks and marquee do not select those shapes' anchor points. In Construction mode a marquee selects the original shapes, and Direct Selection selects their anchor points.
|
||||
A key object needs another selected object outside it. A Shift-click or any other change that leaves one object selected clears the key, and `select.key` sets none on one object or on a group selected with only its own members. With one object selected, the Align panel, the Control bar and the Properties panel align it to the artboard whatever Align To is set to.
|
||||
|
||||
Reference in New Issue
Block a user