mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 04:07:12 -06:00
Part of #2441: Group Selection picks and moves one path of a compound path
A click with the Group Selection tool on a compound path selects the path under the pointer (its outline within the pick tolerance, else the innermost path whose area holds the click), so the outer shape and each hole can be selected on their own; Shift-click adds another, and a click on a selected path adds the compound path, as a click on a group member climbs to its group. A path selected this way is an object of its own for Move, Transform, Rotate and the other transforms, as it already was inside an isolated compound path: the hole of a ring moves without the ring. Paint and the other commands still go to the compound path, which holds the paint. Docs: docs/target-app-parity.md, docs/ui-parity.md. # Conflicts: # docs/target-app-parity.md
This commit is contained in:
@@ -470,6 +470,20 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option
|
||||
}
|
||||
}
|
||||
|
||||
/// The path of compound path `n` under `p`, for the Group Selection tool: the one whose outline is
|
||||
/// within `tol`, else the innermost one whose area holds `p` (a hole inside an outer shape, an
|
||||
/// island inside a hole). `None` when `n` isn't a compound path or no part is under `p`.
|
||||
pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option<NodeId> {
|
||||
let NodeKind::Compound { children, rule } = &n.kind else { return None };
|
||||
let parts: Vec<_> = children.iter().rev().filter_map(|c| c.path_data().map(|pd| (c.id, pd.to_bezpath(), pd.bounds()))).collect();
|
||||
if let Some((id, ..)) = parts.iter().find(|(_, bp, _)| stroke_contains(bp, 0.0, tol, p)) {
|
||||
return Some(*id);
|
||||
}
|
||||
// The innermost by the size of its box.
|
||||
let size = |b: &Option<Rect>| b.map_or(f64::INFINITY, |b| b.area());
|
||||
parts.iter().filter(|(_, bp, _)| fill_contains(bp, *rule, p)).min_by(|a, b| size(&a.2).total_cmp(&size(&b.2))).map(|(id, ..)| *id)
|
||||
}
|
||||
|
||||
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect. Symbol
|
||||
/// instances are measured by their art ([`Node::geometric_bounds_with`]). The art of a clip group
|
||||
/// or of a layer with a clipping mask counts only when the marquee meets the region its clipping
|
||||
|
||||
@@ -428,22 +428,23 @@ pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
|
||||
Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc)))
|
||||
}
|
||||
|
||||
/// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): in
|
||||
/// an isolated compound path its paths are objects of their own, so the hole of a ring moves
|
||||
/// without the ring. Paint and the rest still go to the compound path, which holds the paint.
|
||||
/// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): a
|
||||
/// path of a compound path selected on its own (Group Selection, or in the isolated compound path)
|
||||
/// is an object of its own, so the hole of a ring moves without the ring (#2441). Paint and the
|
||||
/// rest still go to the compound path, which holds the paint.
|
||||
pub(crate) fn selected_roots_to_transform(s: &Session) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), st.isolation))
|
||||
Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), true))
|
||||
}
|
||||
|
||||
/// The top-level objects among `ids` (in paint order): compound-path members stand for their
|
||||
/// compound, and ids whose ancestor is also listed, and layers, are dropped.
|
||||
pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
|
||||
roots_within(doc, ids, None)
|
||||
roots_within(doc, ids, false)
|
||||
}
|
||||
|
||||
/// [`roots_of`], less the swap for the members of `isolation` when it is a compound path.
|
||||
fn roots_within(doc: &Document, ids: Vec<NodeId>, isolation: Option<NodeId>) -> Vec<NodeId> {
|
||||
/// [`roots_of`]; with `members`, a compound path's member listed stays itself.
|
||||
fn roots_within(doc: &Document, ids: Vec<NodeId>, members: bool) -> Vec<NodeId> {
|
||||
// Every id's place in the tree, found in one walk (a lookup per id is a walk per id).
|
||||
let paths = doc.index_paths(ids.iter().copied());
|
||||
// A compound-path member acts as its compound (compounds are one object).
|
||||
@@ -453,7 +454,7 @@ fn roots_within(doc: &Document, ids: Vec<NodeId>, isolation: Option<NodeId>) ->
|
||||
let path = paths.get(&id);
|
||||
let parent = path.and_then(|p| p.split_last()).map(|(_, up)| up).filter(|up| !up.is_empty());
|
||||
match parent.and_then(|up| doc.node_at(up).map(|n| (up, n))) {
|
||||
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && Some(n.id) != isolation => (n.id, Some(up.to_vec())),
|
||||
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && !members => (n.id, Some(up.to_vec())),
|
||||
_ => (id, path.cloned()),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1952,13 +1952,14 @@ fn isolated_ring(s: &mut Session) -> (NodeId, NodeId, NodeId) {
|
||||
(c, outer, hole)
|
||||
}
|
||||
|
||||
/// Isolating a compound path makes its paths objects of their own: the hole of a ring, selected
|
||||
/// alone, moves without the ring around it. Outside isolation it still moves its compound path.
|
||||
/// A path of a compound path selected alone is an object of its own for the transforms: the hole
|
||||
/// of a ring moves without the ring around it, in the isolated compound path and outside it, where
|
||||
/// Group Selection selects it (#2441). Selecting the compound path moves the whole ring.
|
||||
#[test]
|
||||
fn an_isolated_compound_paths_member_moves_alone() {
|
||||
let x0 = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
|
||||
let mut s = session();
|
||||
let (_, outer, hole) = isolated_ring(&mut s);
|
||||
let (c, outer, hole) = isolated_ring(&mut s);
|
||||
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 30, 0]})).unwrap();
|
||||
assert_eq!((x0(&s, outer), x0(&s, hole)), (0.0, 40.0), "only the hole moved");
|
||||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||||
@@ -1966,7 +1967,10 @@ fn an_isolated_compound_paths_member_moves_alone() {
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [hole.0]})).unwrap();
|
||||
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 0]})).unwrap();
|
||||
assert_eq!((x0(&s, outer), x0(&s, hole)), (5.0, 50.0), "not isolated: the whole compound path moved");
|
||||
assert_eq!((x0(&s, outer), x0(&s, hole)), (0.0, 50.0), "not isolated: still only the hole");
|
||||
s.execute("select.set", &json!({"ids": [c.0]})).unwrap();
|
||||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||||
assert_eq!((x0(&s, outer), x0(&s, hole)), (5.0, 55.0), "the compound path selected: the whole ring");
|
||||
}
|
||||
|
||||
/// `depth` and `childLimit` slice a summary read; a level that shows fewer children
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::{HitKind, hit_test};
|
||||
use vectorcraft_doc::hit::{HitKind, compound_part_at, hit_test};
|
||||
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{Anchor, PathData, Point, Rect, Vec2};
|
||||
|
||||
@@ -425,10 +425,15 @@ impl Tool for DirectSelectionTool {
|
||||
self.state = State::Idle;
|
||||
return out;
|
||||
}
|
||||
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else {
|
||||
let Some(mut h) = hit_test(cx.doc, p, cx.hit_options()) else {
|
||||
self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false };
|
||||
return vec![];
|
||||
};
|
||||
// A compound path's paths are picked one at a time, below the compound path (#2441).
|
||||
if let Some(part) = cx.doc.node(h.leaf).and_then(|n| compound_part_at(n, p, tol)) {
|
||||
h.ancestry.push(part);
|
||||
h.leaf = part;
|
||||
}
|
||||
// Walk up from the leaf: select the first ancestor not yet selected (below the layer).
|
||||
// Stop at the isolated object: the next group up is outside isolation mode.
|
||||
let chain: Vec<NodeId> = h.ancestry.iter().skip(1).rev().copied().take_while(|id| in_scope(cx.doc, cx.isolation, *id)).collect();
|
||||
@@ -1331,6 +1336,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #2441: Group Selection picks one path of a compound path (the outer shape or a hole);
|
||||
/// a second click on it adds the compound path, like a group around a member.
|
||||
#[test]
|
||||
fn group_selection_picks_a_path_of_a_compound_path() {
|
||||
let mut d = vectorcraft_doc::Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let (outer, hole, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||||
let rect = |id, a: f64, b: f64| {
|
||||
std::sync::Arc::new(vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(a, a, b, b)), Default::default()))
|
||||
};
|
||||
let mut c = vectorcraft_doc::Node::new(
|
||||
compound,
|
||||
vectorcraft_doc::NodeKind::Compound {
|
||||
children: vec![rect(outer, 0.0, 200.0), rect(hole, 50.0, 150.0)],
|
||||
rule: vectorcraft_geom::FillRule::EvenOdd,
|
||||
},
|
||||
);
|
||||
c.appearance = vectorcraft_doc::Appearance::default_art().into();
|
||||
d.insert(Some(l), 0, c).unwrap();
|
||||
let p = paint();
|
||||
let none = Selection::default();
|
||||
let click = |s: &Selection, x, y| DirectSelectionTool::new(true).pointer(&cx(&d, s, &p), &PointerEvent::new(PointerKind::Down, x, y));
|
||||
let set = |id: NodeId| vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))];
|
||||
assert_eq!(click(&none, 25.0, 100.0), set(outer), "the fill between the outline and the hole");
|
||||
assert_eq!(click(&none, 50.0, 100.0), set(hole), "the hole's outline");
|
||||
assert!(click(&none, 100.0, 100.0).is_empty(), "inside the hole nothing is hit");
|
||||
let mut s = Selection::default();
|
||||
s.set([outer]);
|
||||
assert_eq!(click(&s, 25.0, 100.0), vec![Action::Exec("select.add".into(), json!({"ids": [compound.0]}))]);
|
||||
}
|
||||
|
||||
/// A path inside an inner group, a sibling path outside it, and the groups' ids.
|
||||
/// The inside path is the rectangle at (100, 100); the outside one at (300, 300).
|
||||
fn nested_group_doc() -> (vectorcraft_doc::Document, NodeId, NodeId, NodeId, NodeId) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
+3
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user