From ec8c1cb8d1f59563691017da8db96a52ddd22405 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 00:36:29 +0700 Subject: [PATCH] Intertwine: a right-click where three or more objects overlap arranges the nearest one Squashed from #4862 onto main. --- crates/doc/src/hit.rs | 21 ++- crates/doc/src/intertwine.rs | 79 +++++++++--- crates/doc/src/mem.rs | 3 + crates/doc/src/node/extras.rs | 6 + crates/doc/src/node/ops.rs | 9 +- crates/doc/src/node/tests.rs | 17 +++ crates/doc/src/node/wire.rs | 37 +++++- crates/engine/src/cmd/intertwine.rs | 164 ++++++++++++++++-------- crates/engine/src/tests/intertwine.rs | 127 ++++++++++++++++++ crates/tools/src/intertwine.rs | 124 +++++++++++++++--- crates/ui-egui/src/canvas/context.rs | 13 +- crates/ui-egui/src/menus/context.rs | 23 +++- crates/ui-egui/src/state.rs | 8 ++ crates/ui-egui/src/tests/contextmenu.rs | 55 ++++++++ docs/gaps.md | 8 +- docs/mcp.md | 13 ++ 16 files changed, 592 insertions(+), 115 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 837985874..eb436e971 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -538,20 +538,15 @@ fn hit_children( } /// The order a click at `p` tries the children of `parent` in, front first: their stacking order -/// from the top, except that in an Intertwine group the members drawn over the others at `p` -/// come first (from the top among them too). +/// from the top, except that in an Intertwine group the members are tried in the order they are +/// drawn at `p`, from the top ([`crate::intertwine::tier_at`]). fn hit_order<'a>(parent: &Node, children: &'a [std::sync::Arc], p: Point) -> impl Iterator + 'a { - let over: Vec = if matches!(parent.kind, NodeKind::Intertwine { .. }) { - let contains = |a: &vectorcraft_geom::PathData| fill_contains(&a.to_bezpath(), vectorcraft_geom::FillRule::NonZero, p); - (0..children.len()).rev().filter(|i| children.get(*i).and_then(|c| crate::intertwine::over_area(c)).is_some_and(contains)).collect() - } else { - vec![] - }; - let rest = (0..children.len()).rev().filter({ - let over = over.clone(); - move |i| !over.contains(i) - }); - over.into_iter().chain(rest) + let mut order: Vec = (0..children.len()).rev().collect(); + if matches!(parent.kind, NodeKind::Intertwine { .. }) { + let tier = |i: usize| children.get(i).map_or(0, |c| crate::intertwine::tier_at(c, p)); + order.sort_by_cached_key(|i| std::cmp::Reverse((tier(*i), *i))); + } + order.into_iter() } /// A click on the strokes of a path or compound path `n` along `bp`: what any visible stroke diff --git a/crates/doc/src/intertwine.rs b/crates/doc/src/intertwine.rs index e22ccbbaf..8a6ab6786 100644 --- a/crates/doc/src/intertwine.rs +++ b/crates/doc/src/intertwine.rs @@ -1,36 +1,51 @@ //! Object › Intertwine: objects that pass over and under each other. //! //! An Intertwine group ([`NodeKind::Intertwine`]) keeps its members as they are, in their own -//! stacking order. A member can be drawn over the other members in part of the group: that area -//! is the member's [`crate::node::NodeExtras::intertwine`], in document coordinates. The group -//! draws (and exports, and is hit) as its live expansion ([`expand`]): the members, then, for -//! each member with an area, a copy of it clipped to that area. Where several members are drawn -//! over the others in one place, the upper one in the stacking order ends on top. +//! stacking order. A member can be drawn over other members in part of the group, at a tier: the +//! area of tier 1 is the member's [`crate::node::NodeExtras::intertwine`], and the areas of tiers 2 +//! and up are its [`crate::node::NodeExtras::intertwine_tiers`], in document coordinates. In each +//! place the members are drawn by their tier there (0 outside their areas), and members of one +//! tier in their stacking order, so any order of the members can be set for each overlap area. +//! The group draws (and exports, and is hit) as its live expansion ([`expand`]): the members, +//! then, tier by tier, a copy of each member with an area of that tier clipped to it. //! //! The members stay editable, and Release gives them back as they were (the areas let go). //! Behaviour: . use std::sync::Arc; -use vectorcraft_geom::{FillRule, PathData}; +use vectorcraft_geom::hit::fill_contains; +use vectorcraft_geom::{FillRule, PathData, Point}; use crate::{Node, NodeId, NodeKind}; -/// The area where `n` is drawn over the other members of its Intertwine group (empty: nowhere). -pub fn over_area(n: &Node) -> Option<&PathData> { - n.extras().intertwine.as_deref().filter(|a| !a.is_empty()) +/// The areas where `n` is drawn over other members of its Intertwine group, each with its tier +/// (1 and up); empty ones are left out. +pub fn tier_areas(n: &Node) -> impl Iterator { + let e = n.extras(); + e.intertwine + .as_deref() + .map(|a| (1, a)) + .into_iter() + .chain(e.intertwine_tiers.iter().enumerate().map(|(k, a)| (k + 2, a))) + .filter(|(_, a)| !a.is_empty()) } -/// The members of an Intertwine group evaluated as plain art: the members (ids kept), then each -/// member with an area drawn over the others, as a clip group of that area around a copy of it -/// (ids 0: generated art). +/// The tier `n` is drawn at at `p`: the highest tier whose area holds `p` (0: none does). +pub fn tier_at(n: &Node, p: Point) -> usize { + tier_areas(n).filter(|(_, a)| fill_contains(&a.to_bezpath(), FillRule::NonZero, p)).map(|(t, _)| t).max().unwrap_or(0) +} + +/// The members of an Intertwine group evaluated as plain art: the members (ids kept), then, for +/// each tier from 1 up, each member with an area of that tier, as a clip group of that area +/// around a copy of it (ids 0: generated art). pub fn expand(children: &[Arc]) -> Vec { let mut out: Vec = children.iter().map(|c| plain(c)).collect(); - for c in children { - let Some(area) = over_area(c) else { continue }; - if !c.visible { - continue; - } + let mut over: Vec<(usize, usize, &PathData)> = + children.iter().enumerate().filter(|(_, c)| c.visible).flat_map(|(i, c)| tier_areas(c).map(move |(t, a)| (t, i, a))).collect(); + over.sort_by_key(|(t, i, _)| (*t, *i)); + for (_, i, area) in over { + let Some(c) = children.get(i) else { continue }; let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area.clone(), rule: FillRule::NonZero, live: None, clipping: true, guide: false }); clip.appearance = crate::Appearance::default().into(); let mut copy = plain(c); @@ -40,11 +55,13 @@ pub fn expand(children: &[Arc]) -> Vec { out } -/// `n` without its Intertwine area (the expansion is plain art). +/// `n` without its Intertwine areas (the expansion is plain art). fn plain(n: &Node) -> Node { let mut n = n.clone(); - if n.extras().intertwine.is_some() { - n.extras_mut().intertwine = None; + if n.extras().intertwine.is_some() || !n.extras().intertwine_tiers.is_empty() { + let mut e = n.extras_mut(); + e.intertwine = None; + e.intertwine_tiers = Vec::new(); } n } @@ -88,6 +105,28 @@ mod tests { assert_eq!((out[2].id, children[1].id), (NodeId(0), NodeId(0)), "generated art has no ids of its own"); } + #[test] + fn copies_are_drawn_tier_by_tier() { + let over = || shapes::rectangle(Rect::new(5.0, 0.0, 10.0, 10.0)); + // The lower member at tier 2, the upper one at tier 1: the lower one ends on top. + let mut a = (*square(1, 0.0)).clone(); + a.extras_mut().intertwine_tiers = vec![over()]; + let mut b = (*square(2, 5.0)).clone(); + b.extras_mut().intertwine = Some(Box::new(over())); + let kids = vec![Arc::new(a), Arc::new(b)]; + assert_eq!((tier_at(&kids[0], Point::new(7.0, 5.0)), tier_at(&kids[1], Point::new(7.0, 5.0))), (2, 1)); + assert_eq!(tier_at(&kids[0], Point::new(2.0, 5.0)), 0); + let out = expand(&kids); + assert_eq!(out.len(), 4, "both members, then the copy of tier 1, then the one of tier 2"); + assert!(out.iter().all(|n| n.extras().intertwine.is_none() && n.extras().intertwine_tiers.is_empty()), "the expansion is plain art"); + let copied = |n: &Node| match &n.kind { + NodeKind::Group { children, clip: true } => children[1].path_data().cloned(), + k => panic!("a clip group: {k:?}"), + }; + assert_eq!(copied(&out[2]).as_ref(), kids[1].path_data()); + assert_eq!(copied(&out[3]).as_ref(), kids[0].path_data()); + } + #[test] fn a_hidden_member_is_not_drawn_over_the_others() { let mut a = (*square(1, 0.0)).clone(); diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index a971a383b..bf9259943 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -253,6 +253,9 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize { b += boxed(&e.dimension) + boxed(&e.live_paint_gaps) + boxed(&e.colorize); b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::() + json_bytes(t)); b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::() + path_bytes(a)); + if e.intertwine_tiers.capacity() > 0 { + b += ALLOC + e.intertwine_tiers.capacity() * size_of::() + e.intertwine_tiers.iter().map(path_bytes).sum::(); + } b += e.objects_on_path.as_deref().map_or(0, |o| { ALLOC + size_of::() + o.members.capacity() * size_of::() }); diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs index 633f9b696..93145985b 100644 --- a/crates/doc/src/node/extras.rs +++ b/crates/doc/src/node/extras.rs @@ -44,6 +44,11 @@ pub struct NodeExtras { /// Object › Intertwine: as a member of an Intertwine group, the area (document coordinates, /// filled non-zero) where this object is drawn over the group's other members. pub intertwine: Option>, + /// Object › Intertwine: as a member of an Intertwine group, the areas (document coordinates, + /// filled non-zero) where this object is drawn over members that are themselves drawn over + /// others there. Item `k` is the area of tier `k + 2`; [`NodeExtras::intertwine`] is tier 1 + /// (see [`crate::intertwine`]). + pub intertwine_tiers: Vec, /// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults). pub live_paint_gaps: Option>, /// Object › Objects on Path: the group's path and the objects attached to it (see @@ -76,6 +81,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras { perspective: None, dimension: None, intertwine: None, + intertwine_tiers: Vec::new(), live_paint_gaps: None, objects_on_path: None, colorize: None, diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index edab5f73c..e5c98886a 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -496,8 +496,13 @@ impl Node { Arc::make_mut(&mut m.art).transform_scaled(a, sc); } // The area where an Intertwine member is drawn over the others moves with it. - if let Some(area) = self.extras_if_any().and_then(|e| e.intertwine.as_mut()) { - area.transform(a); + if let Some(e) = self.extras_if_any() { + if let Some(area) = e.intertwine.as_mut() { + area.transform(a); + } + for area in &mut e.intertwine_tiers { + area.transform(a); + } } } diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index b4cc7b072..df675580e 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -484,6 +484,23 @@ fn objects_are_read_within_the_node_budget() { assert!(with_node_budget(3, read).is_ok()); } +/// An Intertwine member's areas of tier 2 and up are written and read back, and each one counts +/// against [`with_node_budget`] as an object does, so a file of many empty areas is refused. +#[test] +fn intertwine_tiers_are_read_within_the_node_budget() { + let mut n = Node::group(NodeId(1), vec![]); + let area = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)); + n.extras_mut().intertwine_tiers = vec![PathData::default(), area]; + let json = serde_json::to_string(&n).unwrap(); + assert!(json.contains("\"intertwine_tiers\""), "{json}"); + let read = || serde_json::from_str::(&json); + assert_eq!(with_node_budget(3, read).unwrap(), n, "the object and its two areas fit a budget of three"); + let why = with_node_budget(2, read).unwrap_err().to_string(); + assert!(why.contains("more than 2 objects"), "{why}"); + let plain = serde_json::to_string(&Node::group(NodeId(1), vec![])).unwrap(); + assert!(!plain.contains("intertwine_tiers"), "an object without tiers writes none: {plain}"); +} + /// #3587: fields read before an object's `type` are buffered value by value within /// [`with_buffered_value_budget`], each counted once: a group read back from the buffer buffers /// its own fields again without counting them twice. VectorCraft writes `type` first, which diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index 76d4c3c14..3456b359e 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -46,6 +46,7 @@ impl Serialize for Node { e.perspective.is_none(), e.dimension.is_none(), e.intertwine.is_none(), + e.intertwine_tiers.is_empty(), e.live_paint_gaps.is_none(), e.objects_on_path.is_none(), e.colorize.is_none(), @@ -85,6 +86,7 @@ impl Serialize for Node { field!("perspective", &e.perspective); field!("dimension", &e.dimension); field!("intertwine", &e.intertwine); + field!("intertwine_tiers", &e.intertwine_tiers); field!("live_paint_gaps", &e.live_paint_gaps); field!("objects_on_path", &e.objects_on_path); field!("colorize", &e.colorize); @@ -123,11 +125,39 @@ pub fn with_node_budget(max: usize, f: impl FnOnce() -> T) -> T { /// each counts once, and the id usually comes first, before the node's children are read. fn counted_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result { let id = NodeId::deserialize(d)?; + count_one()?; + Ok(id) +} + +/// Count one object against [`with_node_budget`]. +fn count_one() -> Result<(), E> { if let Some((left, max)) = NODES_LEFT.get() { - let left = left.checked_sub(1).ok_or_else(|| serde::de::Error::custom(format!("the document has more than {max} objects")))?; + let left = left.checked_sub(1).ok_or_else(|| E::custom(format!("the document has more than {max} objects")))?; NODES_LEFT.set(Some((left, max))); } - Ok(id) + Ok(()) +} + +/// A member's Intertwine areas of tier 2 and up, each counting against [`with_node_budget`] as +/// an object does: a compressed file of repeated empty areas stays small while they take memory +/// once read. +fn counted_tiers<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + struct Tiers; + impl<'de> serde::de::Visitor<'de> for Tiers { + type Value = Vec; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a list of areas") + } + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + let mut out = Vec::new(); + while let Some(area) = seq.next_element::()? { + count_one()?; + out.push(area); + } + Ok(out) + } + } + d.deserialize_seq(Tiers) } /// How a [`Node`] is read: the flat field list of the file format. Fields move into the node @@ -186,6 +216,8 @@ pub(super) struct NodeWire { dimension: Option>, #[serde(default)] intertwine: Option>, + #[serde(default, deserialize_with = "counted_tiers")] + intertwine_tiers: Vec, #[serde(default)] live_paint_gaps: Option>, #[serde(default)] @@ -215,6 +247,7 @@ impl From for Node { perspective: w.perspective, dimension: w.dimension, intertwine: w.intertwine, + intertwine_tiers: w.intertwine_tiers, live_paint_gaps: w.live_paint_gaps, objects_on_path: w.objects_on_path, colorize: w.colorize, diff --git a/crates/engine/src/cmd/intertwine.rs b/crates/engine/src/cmd/intertwine.rs index 27f1080e1..23f1be190 100644 --- a/crates/engine/src/cmd/intertwine.rs +++ b/crates/engine/src/cmd/intertwine.rs @@ -3,18 +3,19 @@ //! `object.intertwine.make` puts the selected objects in an Intertwine group //! (`NodeKind::Intertwine`), `object.intertwine.release` gives them back as they were. In between, //! `object.intertwine.arrange` picks overlap areas, by a click (`at`) or by an enclosed path drawn -//! around them (`area`), and changes which member is drawn on top there: each member keeps the -//! area where it is drawn over the others (`NodeExtras::intertwine`, see -//! `vectorcraft_doc::intertwine`). Object › Expand turns the group into a plain group of what it -//! draws ([`expand_intertwines`]), which Ungroup takes apart. +//! around them (`area`), and changes the order the members are drawn in there: each member keeps +//! the areas where it is drawn over others, by tier (`NodeExtras::intertwine` and +//! `NodeExtras::intertwine_tiers`, see `vectorcraft_doc::intertwine`). Object › Expand turns the +//! group into a plain group of what it draws ([`expand_intertwines`]), which Ungroup takes apart. //! //! An overlap area is a face of the planar arrangement of what the members paint (their fills and //! strokes, type outlined; other objects by their bounds): the part where two or more of them //! overlap, split wherever a member's edge crosses it. //! //! Behaviour: (Make, -//! Release, clicking or drawing around an overlap, Bring to Front and Send to Back where three or -//! more objects overlap). +//! Release, clicking or drawing around an overlap, Bring to Front, Bring Forward, Send Backward +//! and Send to Back where three or more objects overlap: +//! ). use std::sync::Arc; @@ -62,7 +63,7 @@ pub fn specs() -> Vec { "Arrange Intertwine Area", [], None, - "{id?: Intertwine group (default: the selected one), at?: [x,y] (the overlap area under this point, as a click picks it) | area?: [[x,y]…] (a closed polygon drawn around overlap areas: every area whose inside it encloses), order?: toggle|front|back (toggle: the object drawn right below the top comes to the front, so two objects swap), member?: id (front and back: the object to move; default: front brings the object right below the top, back sends the top one)} → {count: areas changed, top: [id of the object now on top in each area]}", + "{id?: Intertwine group (default: the selected one), at?: [x,y] (the overlap area under this point, as a click picks it) | area?: [[x,y]…] (a closed polygon drawn around overlap areas: every area whose inside it encloses), order?: toggle|front|forward|backward|back (toggle: the object drawn right below the top comes to the front, so two objects swap; forward and backward move an object one place up or down among the objects in that area; the others keep their order there), member?: id (front, forward, backward and back: the object to move; default: front and forward move the object right below the top, back and backward the top one)} → {count: areas changed (an area already in that order is not changed, and with none changed there is no undo step), top: [id of the object now on top in each changed area]}", has_doc, arrange ), @@ -173,7 +174,10 @@ fn release(s: &mut Session, _: &Value) -> Result { .flatten() .map(|c| { let mut c = (**c).clone(); - c.extras_mut().intertwine = None; + let mut e = c.extras_mut(); + e.intertwine = None; + e.intertwine_tiers = Vec::new(); + drop(e); c }) .collect(); @@ -228,6 +232,8 @@ pub(crate) fn expand_intertwines(d: &mut Document, roots: &[NodeId]) -> Result Option { Some(PathData::single(SubPath::polyline(&pts, true))) } -/// `n`'s area with `face` added (`add`) or taken out. -fn with_area(n: &mut Node, face: &PathData, add: bool) { - let old = n.extras().intertwine.as_deref().cloned(); - let new = match (old, add) { +/// Area `old` with `face` added (`add`) or taken out. +fn with_face(old: Option, face: &PathData, add: bool) -> Option { + match (old, add) { (None, true) => nonempty(face.clone()), (None, false) => None, (Some(a), true) => nonempty(po::boolean(&a, FillRule::NonZero, face, FillRule::NonZero, po::BoolOp::Union)), (Some(a), false) => nonempty(po::boolean(&a, FillRule::NonZero, face, FillRule::NonZero, po::BoolOp::Difference)), - }; - if new.as_ref() != n.extras().intertwine.as_deref() { - n.extras_mut().intertwine = new.map(Box::new); } } -/// Bring member `x` to the front of `face` (`front`) or send it to the back there; returns the -/// member now on top there. -fn arrange_face(children: &mut [Arc], face: &Face, x: usize, front: bool) -> Option { - let lowest = face.members.iter().min().copied()?; - let highest = face.members.iter().max().copied()?; - for &i in &face.members { - let Some(c) = children.get_mut(i) else { continue }; - let c = Arc::make_mut(c); - if front { - // Only `x` is drawn over the others here, unless it is on top in its own order. - with_area(c, &face.path, i == x && x != highest); - } else if i == x { - with_area(c, &face.path, false); - } else if x != lowest { - // Every other member is drawn over `x`, keeping their own order among them. - with_area(c, &face.path, true); +/// Draw `n` at `tier` in `face` (0: in its own order): `face` joins its area of that tier and +/// leaves its areas of the other tiers. +fn set_tier(n: &mut Node, face: &PathData, tier: usize) { + let e = n.extras(); + let mut areas: Vec> = + std::iter::once(e.intertwine.as_deref().cloned()).chain(e.intertwine_tiers.iter().map(|a| (!a.is_empty()).then(|| a.clone()))).collect(); + if areas.len() < tier { + areas.resize(tier, None); + } + for (k, a) in areas.iter_mut().enumerate() { + *a = with_face(a.take(), face, k + 1 == tier); + } + while areas.len() > 1 && areas.last().is_some_and(Option::is_none) { + areas.pop(); + } + let mut areas = areas.into_iter(); + let first = areas.next().flatten(); + let rest: Vec = areas.map(Option::unwrap_or_default).collect(); + if first.as_ref() != n.extras().intertwine.as_deref() || rest != n.extras().intertwine_tiers { + let mut e = n.extras_mut(); + e.intertwine = first.map(Box::new); + e.intertwine_tiers = rest; + } +} + +/// The tiers that draw members in `order` (bottom first): each member one tier above the member +/// below it when it comes before that member in the group's order, else at the same tier. +fn tiers(order: &[usize]) -> Vec<(usize, usize)> { + let mut tier = 0; + let mut below: Option = None; + order + .iter() + .map(|&i| { + if below.is_some_and(|b| i < b) { + tier += 1; + } + below = Some(i); + (i, tier) + }) + .collect() +} + +/// Draw the members of `face` in `order` there (bottom first), leaving the other areas as they +/// are. +fn arrange_face(children: &mut [Arc], face: &Face, order: &[usize]) { + for (i, tier) in tiers(order) { + if let Some(c) = children.get_mut(i) { + set_tier(Arc::make_mut(c), &face.path, tier); } } - drawn_order(children, face).last().copied() +} + +/// `drawn` (the members of an area, bottom first) with member `x` moved as `order` says. +fn moved(drawn: &[usize], x: usize, order: Order) -> Vec { + let mut out = drawn.to_vec(); + let Some(at) = out.iter().position(|i| *i == x) else { return out }; + match order { + Order::Toggle | Order::Front => { + out.remove(at); + out.push(x); + } + Order::Back => { + out.remove(at); + out.insert(0, x); + } + Order::Forward if at + 1 < out.len() => out.swap(at, at + 1), + Order::Backward if at > 0 => out.swap(at - 1, at), + Order::Forward | Order::Backward => {} + } + out } fn arrange(s: &mut Session, p: &Value) -> Result { @@ -309,15 +362,17 @@ fn arrange(s: &mut Session, p: &Value) -> Result { let order = match str_param(p, "order").unwrap_or("toggle") { "toggle" => Order::Toggle, "front" => Order::Front, + "forward" => Order::Forward, + "backward" => Order::Backward, "back" => Order::Back, - o => return Err(bad(C, format!("unknown order `{o}`: toggle, front or back"))), + o => return Err(bad(C, format!("unknown order `{o}`: toggle, front, forward, backward or back"))), }; let member = match p.get("member") { Some(v) => Some(checked_id(s, v, C)?), None => None, }; if member.is_some() && order == Order::Toggle { - return Err(bad(C, "member is for order front or back")); + return Err(bad(C, "member is for order front, forward, backward or back")); } let children: Vec> = s.doc()?.doc.node(group).and_then(Node::children).cloned().unwrap_or_default(); let member = match member { @@ -345,25 +400,32 @@ fn arrange(s: &mut Session, p: &Value) -> Result { { return Err(bad(C, "that object is not in the overlap")); } - let tops = s.edit("Intertwine", |d, _| { + // The new order of each area that changes; areas already in that order are left out. + let mut plans: Vec<(&Face, Vec)> = vec![]; + for face in found.iter().filter(|f| member.is_none_or(|m| f.members.contains(&m))) { + let drawn = drawn_order(&children, face); + let x = match (order, member) { + (_, Some(m)) => Some(m), + (Order::Toggle | Order::Front | Order::Forward, None) => drawn.iter().rev().nth(1).copied(), + (Order::Back | Order::Backward, None) => drawn.last().copied(), + }; + let Some(x) = x else { continue }; + let new = moved(&drawn, x, order); + if new != drawn { + plans.push((face, new)); + } + } + let tops: Vec = plans.iter().filter_map(|(_, new)| children.get(*new.last()?)).map(|c| c.id.0).collect(); + if plans.is_empty() { + return Ok(json!({ "count": 0, "top": tops })); + } + s.edit("Intertwine", |d, _| { let n = d.node_mut(group).ok_or(EngineError::NoNode(group))?; let NodeKind::Intertwine { children } = &mut n.kind else { return Err(EngineError::NoNode(group)) }; - let mut tops = vec![]; - for face in found.iter().filter(|f| member.is_none_or(|m| f.members.contains(&m))) { - let drawn = drawn_order(children, face); - let (x, front) = match (order, member) { - (Order::Toggle, _) => (drawn.iter().rev().nth(1).copied(), true), - (Order::Front, m) => (m.or(drawn.iter().rev().nth(1).copied()), true), - (Order::Back, m) => (m.or(drawn.last().copied()), false), - }; - let Some(x) = x else { continue }; - if let Some(top) = arrange_face(children, face, x, front) - && let Some(c) = children.get(top) - { - tops.push(c.id.0); - } + for (face, new) in &plans { + arrange_face(children, face, new); } - Ok(tops) + Ok(()) })?; - Ok(json!({ "count": tops.len(), "top": tops })) + Ok(json!({ "count": plans.len(), "top": tops })) } diff --git a/crates/engine/src/tests/intertwine.rs b/crates/engine/src/tests/intertwine.rs index bdd3455f7..6895d74a0 100644 --- a/crates/engine/src/tests/intertwine.rs +++ b/crates/engine/src/tests/intertwine.rs @@ -135,6 +135,133 @@ fn front_and_back_choose_the_member_where_three_objects_overlap() { assert!(e.to_string().contains("999"), "{e}"); } +/// The rings with a green bar across their top crossing, in front of both, made into an +/// Intertwine group: (group, a, b, c). A, B and C overlap at (50, 30); only A and B at (50, 70). +fn rings_and_bar(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) { + let (a, b) = rings(s); + let c = id_of(&s.execute("shape.rectangle", &json!({"x": 46, "y": 20, "width": 8, "height": 20})).unwrap()); + s.execute("paint.setFill", &json!({"ids": [c.0], "color": "#00ff00"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [c.0], "none": true})).unwrap(); + select(s, &[a, b, c]); + let g = id_of(&s.execute("object.intertwine.make", &json!({})).unwrap()); + (g, a, b, c) +} + +/// The members of the overlap area of Intertwine group `g` at (x, y), in the order they are drawn +/// there, bottom first. +fn order_at(d: &Document, g: NodeId, x: f64, y: f64) -> Vec { + use vectorcraft_tools::intertwine::{drawn_order, face_at, faces}; + let children = d.node(g).unwrap().children().unwrap(); + let face = face_at(&faces(children), vectorcraft_geom::Point::new(x, y)).unwrap(); + drawn_order(children, &face).into_iter().map(|i| children[i].id).collect() +} + +/// Bring Forward and Send Backward move an object one place among the objects of one overlap +/// area, keeping the others' order, so every order of three objects can be set there (C, B, A +/// included); the other areas stay as they are. +#[test] +fn forward_and_backward_move_an_object_one_place_in_an_area() { + let mut s = session(); + let (g, a, b, c) = rings_and_bar(&mut s); + let order = |s: &Session| order_at(&s.doc().unwrap().doc, g, 50.0, 30.0); + let v = arrange(&mut s, json!({"at": [50, 30], "order": "forward", "member": a.0})); + assert_eq!((&v["count"], &v["top"]), (&json!(1), &json!([c.0]))); + assert_eq!(order(&s), [b, a, c]); + arrange(&mut s, json!({"at": [50, 30], "order": "backward", "member": c.0})); + assert_eq!(order(&s), [b, c, a]); + assert!(red(&render(&s), 50, 30), "A is on top"); + let v = arrange(&mut s, json!({"at": [50, 30], "order": "forward", "member": b.0})); + assert_eq!(v["top"], json!([a.0])); + assert_eq!(order(&s), [c, b, a], "the reverse of the group's order"); + assert_eq!(hit(&s, 50.0, 30.0), Some(a), "a click picks the object drawn on top"); + // Without a member: forward moves the object right below the top, backward the top one. + arrange(&mut s, json!({"at": [50, 30], "order": "backward"})); + assert_eq!(order(&s), [c, a, b]); + assert!(blue(&render(&s), 50, 30)); + assert_eq!(hit(&s, 50.0, 30.0), Some(b)); + arrange(&mut s, json!({"at": [50, 30], "order": "forward"})); + assert_eq!(order(&s), [c, b, a]); + // Bring to Front and Send to Back keep the order of the others. + arrange(&mut s, json!({"at": [50, 30], "order": "front", "member": c.0})); + assert_eq!(order(&s), [b, a, c]); + arrange(&mut s, json!({"at": [50, 30], "order": "back", "member": a.0})); + assert_eq!(order(&s), [a, b, c]); + // The crossing where only the rings overlap is as it was. + assert_eq!(order_at(&s.doc().unwrap().doc, g, 50.0, 70.0), [a, b]); + assert!(blue(&render(&s), 50, 70)); + // There only the rings count: forward moves A over B, and C is not in that area. + let v = arrange(&mut s, json!({"at": [50, 70], "order": "forward"})); + assert_eq!(v["top"], json!([a.0])); + assert!(red(&render(&s), 50, 70)); + let e = s.execute("object.intertwine.arrange", &json!({"at": [50, 70], "order": "backward", "member": c.0})).unwrap_err(); + assert!(e.to_string().contains("not in the overlap"), "{e}"); + assert_eq!(order(&s), [a, b, c]); +} + +/// An object already at the end it is moved to is not moved: no area changes and no undo step is +/// recorded. One move is one undo step, and undo, redo and a saved file keep the order. +#[test] +fn a_move_that_changes_nothing_records_nothing_and_orders_survive_undo_and_save() { + let mut s = session(); + let (g, a, b, c) = rings_and_bar(&mut s); + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = undo(&s); + for (order, member) in [("forward", c), ("front", c), ("backward", a), ("back", a)] { + let v = arrange(&mut s, json!({"at": [50, 30], "order": order, "member": member.0})); + assert_eq!((&v["count"], &v["top"]), (&json!(0), &json!([])), "{order}"); + } + assert_eq!(undo(&s), before, "nothing to undo"); + arrange(&mut s, json!({"at": [50, 30], "order": "forward", "member": b.0})); + arrange(&mut s, json!({"at": [50, 30], "order": "backward", "member": c.0})); + arrange(&mut s, json!({"at": [50, 30], "order": "forward", "member": a.0})); + assert_eq!(undo(&s), before + 3, "one undo step a move"); + let order = |s: &Session| order_at(&s.doc().unwrap().doc, g, 50.0, 30.0); + assert_eq!(order(&s), [c, b, a]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(order(&s), [c, a, b]); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(order(&s), [c, b, a]); + let data = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone(); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "rings.vectorcraft", "dataBase64": data})).unwrap(); + assert_eq!(order_at(&o.doc().unwrap().doc, g, 50.0, 30.0), [c, b, a]); + assert!(red(&render(&o), 50, 30) && blue(&render(&o), 50, 70)); + // Expand draws the same. + s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap(); + assert!(red(&render(&s), 50, 30) && blue(&render(&s), 50, 70)); +} + +/// Where three or more objects overlap, hovering highlights the outline of the object nearest to +/// the pointer. +#[test] +fn hovering_where_three_overlap_highlights_the_nearest_object() { + use kurbo::Shape; + let mut s = session(); + let view = ViewInfo::default(); + // Squares from x = 0, 20 and 40, 60 wide: all three overlap from x = 40 to 60. + let ids: Vec = + [0, 20, 40].iter().map(|x| id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 60, "height": 60})).unwrap())).collect(); + select(&mut s, &ids); + s.execute("object.intertwine.make", &json!({})).unwrap(); + let highlighted = |s: &mut Session, x: f64| { + s.pointer(&vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::Move, x, 30.0), view).unwrap(); + let o = s.overlays(view); + let [vectorcraft_tools::Overlay::Path { path, .. }] = o.as_slice() else { panic!("one highlight: {o:?}") }; + let r = path.bounding_box(); + (r.x0, r.x1) + }; + // Within the half point a 1 pt stroke reaches past each edge. + let near = |(x0, x1): (f64, f64), (e0, e1): (f64, f64)| (x0 - e0).abs() <= 0.6 && (x1 - e1).abs() <= 0.6; + // By the left edge of the third square, then by the right edge of the first one. + let h = highlighted(&mut s, 41.0); + assert!(near(h, (40.0, 100.0)), "{h:?}"); + let h = highlighted(&mut s, 59.0); + assert!(near(h, (0.0, 60.0)), "{h:?}"); + // Where two overlap, the area is highlighted. + let h = highlighted(&mut s, 30.0); + assert!(near(h, (20.0, 40.0)), "{h:?}"); +} + #[test] fn release_gives_the_objects_back_as_they_were() { let mut s = session(); diff --git a/crates/tools/src/intertwine.rs b/crates/tools/src/intertwine.rs index 984b272e6..fb292ea37 100644 --- a/crates/tools/src/intertwine.rs +++ b/crates/tools/src/intertwine.rs @@ -7,7 +7,9 @@ //! which member is drawn on top there. //! //! The Intertwine tool is what `object.intertwine.make` and `object.intertwine.edit` leave the -//! canvas in: hovering highlights the overlap area under the pointer, a click arranges it, a drag +//! canvas in: hovering highlights the overlap area under the pointer (where three or more members +//! overlap, the outline of the member nearest to the pointer, which the right-click menu +//! arranges: [`menu_target`]), a click arranges it, a drag //! draws a path around overlap areas (Shift: a rectangle) and arranges every one inside it. //! Esc or Enter goes back to the tool chosen before. Behaviour: //! . @@ -16,7 +18,7 @@ use std::sync::Arc; use kurbo::BezPath; use serde_json::{Value, json}; -use vectorcraft_doc::intertwine::over_area; +use vectorcraft_doc::intertwine::tier_at; use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind}; use vectorcraft_effects as effects; use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath, shapes}; @@ -101,8 +103,18 @@ pub struct Face { /// Every overlap area of `children`. pub fn faces(children: &[Arc]) -> Vec { + faces_of(&painted_areas(children)) +} + +/// What each of `children` paints ([`painted_area`]), by child index. +pub fn painted_areas(children: &[Arc]) -> Vec> { + children.iter().map(|c| painted_area(c)).collect() +} + +/// Every overlap area of the members that paint `areas` ([`painted_areas`]). +pub fn faces_of(areas: &[Option]) -> Vec { let shapes: Vec = - children.iter().enumerate().filter_map(|(i, c)| Some(po::Shape::new(painted_area(c)?, FillRule::NonZero, i as u64))).collect(); + areas.iter().enumerate().filter_map(|(i, a)| Some(po::Shape::new(a.clone()?, FillRule::NonZero, i as u64))).collect(); let key = |k: usize| shapes.get(k).map(|s| s.key as usize); po::regions(&shapes) .into_iter() @@ -124,15 +136,37 @@ pub fn faces_in(faces: &[Face], lasso: &PathData) -> Vec { faces.iter().filter(|f| contains(lasso, f.inside)).cloned().collect() } -/// The members of `face` in the order they are drawn there, bottom first: those drawn in their -/// own order, then those drawn over the others there, each run in the group's order. +/// The members of `face` in the order they are drawn there, bottom first: by their tier there +/// ([`tier_at`]), and members of one tier in the group's order. pub fn drawn_order(children: &[Arc], face: &Face) -> Vec { - let over = |i: &usize| children.get(*i).and_then(|c| over_area(c)).is_some_and(|a| contains(a, face.inside)); + let tier = |i: usize| children.get(i).map_or(0, |c| tier_at(c, face.inside)); let mut order = face.members.clone(); - order.sort_by_key(|i| (over(i), *i)); + order.sort_by_cached_key(|i| (tier(*i), *i)); order } +/// The fewest members an overlap area needs for the right-click menu that arranges one of them. +pub const MENU_MEMBERS: usize = 3; + +/// The member of `face` whose outline (what it paints, `areas` from [`painted_areas`]) is nearest +/// to `p`. +pub fn nearest_member(areas: &[Option], face: &Face, p: Point) -> Option { + let dist = |i: usize| areas.get(i).and_then(Option::as_ref).map(|a| vectorcraft_geom::hit::distance_to_outline(&a.to_bezpath(), p)); + face.members.iter().filter_map(|i| Some((*i, dist(*i)?))).filter(|(_, d)| d.is_finite()).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i) +} + +/// What a right-click at `p` arranges with the Intertwine tool: the Intertwine group of the +/// selection, and the member nearest to `p` (by id) in the overlap area under `p`, when +/// [`MENU_MEMBERS`] or more members overlap there. +pub fn menu_target(doc: &Document, selected: &[NodeId], p: Point) -> Option<(NodeId, NodeId)> { + let g = selected_group(doc, selected)?; + let children = doc.node(g)?.children()?; + let areas = painted_areas(children); + let face = face_at(&faces_of(&areas), p).filter(|f| f.members.len() >= MENU_MEMBERS)?; + let m = nearest_member(&areas, &face, p)?; + Some((g, children.get(m)?.id)) +} + /// The Intertwine group the selection is in, or is (the innermost one around the first selected /// object that is in one). pub fn selected_group(doc: &Document, selected: &[NodeId]) -> Option { @@ -149,6 +183,8 @@ pub fn is_intertwine(n: &Node) -> bool { struct Found { revision: (u64, u64), group: NodeId, + /// What each member paints, by child index. + areas: Arc>>, faces: Arc>, } @@ -159,7 +195,8 @@ pub struct IntertwineTool { prev: Option, /// The overlap areas of the group, for the document revision they were found in. cache: Option, - /// The overlap area under the pointer. + /// What the pointer highlights: the overlap area under it, or where [`MENU_MEMBERS`] or more + /// members overlap, the outline of the member nearest to it. hover: Option, /// A drag's points (document space); the first is where it started. points: Vec, @@ -170,17 +207,31 @@ pub struct IntertwineTool { impl IntertwineTool { fn faces(&mut self, cx: &ToolContext) -> Option<(NodeId, Arc>)> { + self.found(cx).map(|c| (c.group, c.faces.clone())) + } + + /// The overlap areas of the selection's group, found again when the document changed. + fn found(&mut self, cx: &ToolContext) -> Option<&Found> { let g = selected_group(cx.doc, &cx.selection.objects)?; - if let Some(c) = &self.cache - && c.revision == cx.revision - && c.group == g - { - return Some((g, c.faces.clone())); + if !self.cache.as_ref().is_some_and(|c| c.revision == cx.revision && c.group == g) { + let children = cx.doc.node(g)?.children()?; + let areas = painted_areas(children); + let faces = Arc::new(faces_of(&areas)); + self.cache = Some(Found { revision: cx.revision, group: g, areas: Arc::new(areas), faces }); } - let children = cx.doc.node(g)?.children()?; - let f = Arc::new(faces(children)); - self.cache = Some(Found { revision: cx.revision, group: g, faces: f.clone() }); - Some((g, f)) + self.cache.as_ref() + } + + /// What hovering `p` highlights ([`IntertwineTool::hover`]). + fn highlight(&mut self, cx: &ToolContext, p: Point) -> Option { + let found = self.found(cx)?; + let face = face_at(&found.faces, p)?; + if face.members.len() >= MENU_MEMBERS + && let Some(a) = nearest_member(&found.areas, &face, p).and_then(|m| found.areas.get(m)).and_then(Option::as_ref) + { + return Some(a.to_bezpath()); + } + Some(face.path.to_bezpath()) } /// The closed path the drag drew. @@ -209,7 +260,7 @@ impl Tool for IntertwineTool { let p = ev.pos; match ev.kind { PointerKind::Move => { - self.hover = self.faces(cx).and_then(|(_, f)| face_at(&f, p)).map(|f| f.path.to_bezpath()); + self.hover = self.highlight(cx, p); vec![] } PointerKind::Down => { @@ -332,6 +383,43 @@ mod tests { assert_eq!(drawn_order(&kids, &f[0]), [1, 0]); } + #[test] + fn members_drawn_at_higher_tiers_are_drawn_later() { + // Three squares over one another; the bottom one at tier 2, the middle one at tier 1. + let over = || shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); + let mut a = (*square(1, 0.0)).clone(); + a.extras_mut().intertwine_tiers = vec![over()]; + let mut b = (*square(2, 0.0)).clone(); + b.extras_mut().intertwine = Some(Box::new(over())); + let kids = vec![Arc::new(a), Arc::new(b), square(3, 0.0)]; + let f = faces(&kids); + assert_eq!(f.len(), 1); + assert_eq!(drawn_order(&kids, &f[0]), [2, 1, 0]); + } + + #[test] + fn the_member_nearest_to_the_pointer_is_picked_where_three_overlap() { + // Squares 20 wide from x = 0, 4 and 8: all three overlap from x = 8 to 20. + let wide = + |id: u64, x: f64| Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 20.0)), Appearance::default_art())); + let kids = vec![wide(1, 0.0), wide(2, 4.0), wide(3, 8.0)]; + let areas = painted_areas(&kids); + let all = faces_of(&areas); + let face = face_at(&all, Point::new(14.0, 10.0)).unwrap(); + assert_eq!(face.members, [0, 1, 2]); + // By the left edge of the third square, then by the right edge of the first one. + assert_eq!(nearest_member(&areas, &face, Point::new(8.5, 10.0)), Some(2)); + assert_eq!(nearest_member(&areas, &face, Point::new(19.5, 10.0)), Some(0)); + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let g = d.alloc_id(); + d.insert(Some(l), 0, Node::new(g, NodeKind::Intertwine { children: kids })).unwrap(); + assert_eq!(menu_target(&d, &[g], Point::new(8.5, 10.0)), Some((g, NodeId(3)))); + assert_eq!(menu_target(&d, &[g], Point::new(19.5, 10.0)), Some((g, NodeId(1)))); + assert_eq!(menu_target(&d, &[g], Point::new(6.0, 10.0)), None, "only two overlap there"); + assert_eq!(menu_target(&d, &[], Point::new(8.5, 10.0)), None, "no Intertwine group selected"); + } + #[test] fn a_lasso_takes_the_areas_whose_inside_it_encloses() { let kids = vec![square(1, 0.0), square(2, 5.0)]; diff --git a/crates/ui-egui/src/canvas/context.rs b/crates/ui-egui/src/canvas/context.rs index 8db01d422..38311b773 100644 --- a/crates/ui-egui/src/canvas/context.rs +++ b/crates/ui-egui/src/canvas/context.rs @@ -4,7 +4,9 @@ use super::{Ui, VectorcraftApp, Xf, json, widgets}; /// Right-click: the object under the pointer is selected first unless it already is, then the /// context menu lists what applies to the selection ([`crate::menus::context_items`]). The locked /// objects under the pointer ([`vectorcraft_doc::hit::locked_objects_at`]) are kept in -/// `ui.context_locked` while the menu is open, for its Unlock submenu. +/// `ui.context_locked` while the menu is open, for its Unlock submenu. With the Intertwine tool, a +/// right-click where three or more objects of the group overlap keeps the selection and puts what +/// it arranges in `ui.context_intertwine` ([`vectorcraft_tools::intertwine::menu_target`]). pub(super) fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: &Xf) { if resp.secondary_clicked() && let Some(p) = resp.interact_pointer_pos() @@ -12,7 +14,13 @@ pub(super) fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: let p = xf.to_doc(p); let locked = hit_with(app, p, xf.zoom, |doc, p, opt| Some(vectorcraft_doc::hit::locked_objects_at(doc, p, opt))); app.ui.context_locked = app.session.active().map(|st| st.uid).zip(locked); - if let Some(st) = app.session.active() + app.ui.context_intertwine = app + .session + .active() + .filter(|_| app.session.tool_id() == "intertwine") + .and_then(|st| vectorcraft_tools::intertwine::menu_target(&st.doc, &st.selection.objects, p).map(|(g, m)| (st.uid, g, p, m))); + if app.ui.context_intertwine.is_none() + && let Some(st) = app.session.active() && let Some(top) = selection_hit_at(app, p, xf.zoom).map(|h| h.top_object(st.isolation)) && !st.selection.contains(top) { @@ -23,6 +31,7 @@ pub(super) fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: let mut clicked = None; if resp.context_menu(|ui| crate::menus::context_menu_body(app, ui, &mut clicked)).is_none() { app.ui.context_locked = None; + app.ui.context_intertwine = None; } if let Some((id, p)) = clicked { crate::menus::invoke(app, &id, p); diff --git a/crates/ui-egui/src/menus/context.rs b/crates/ui-egui/src/menus/context.rs index 9a4284bb1..3af95c607 100644 --- a/crates/ui-egui/src/menus/context.rs +++ b/crates/ui-egui/src/menus/context.rs @@ -15,6 +15,21 @@ pub fn context_items(app: &VectorcraftApp) -> Vec { if st.isolation.is_some() { v.extend([c("Exit Isolation Mode", "object.exitIsolation"), Sep]); } + // With the Intertwine tool, where three or more objects overlap: the object nearest to the + // right-click moves in that overlap area only (`object.intertwine.arrange`), in place of the + // Arrange submenu, which moves objects in the whole group. + let intertwine = app.ui.context_intertwine.filter(|(uid, ..)| *uid == st.uid && app.session.tool_id() == "intertwine"); + if let Some((_, group, at, member)) = intertwine { + let item = + |label, order| cp(label, "object.intertwine.arrange", json!({"id": group.0, "at": [at.x, at.y], "order": order, "member": member.0})); + v.extend([ + item("Bring to Front", "front"), + item("Bring Forward", "forward"), + item("Send Backward", "backward"), + item("Send to Back", "back"), + Sep, + ]); + } // Managing artboards from the canvas: with the Artboard tool, the menu locks and unlocks them // (`artboard.setProps` with `locked` for each artboard named; an entry with none to change is left out). if app.session.tool_id() == "artboard" { @@ -107,11 +122,11 @@ pub fn context_items(app: &VectorcraftApp) -> Vec { if crate::panels::character::text_style(app).is_some() { v.push(sub("Bullets and Numbering", vec![c("Apply Bullets", "type.applyBullets"), c("Apply Numbers", "type.applyNumbers")])); } + v.extend([c("Make Guides", "view.guides.make"), Sep, sub("Transform", transform_items())]); + if intertwine.is_none() { + v.push(sub("Arrange", arrange_items())); + } v.extend([ - c("Make Guides", "view.guides.make"), - Sep, - sub("Transform", transform_items()), - sub("Arrange", arrange_items()), sub( "Select", vec![c("Next Object Above", "select.nextAbove"), c("Next Object Below", "select.nextBelow"), Sep, c("Deselect", "select.none")], diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c85f6e668..2b5c9c379 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -357,6 +357,13 @@ pub struct UiState { /// saved. #[serde(skip)] pub context_locked: Option<(u64, Vec)>, + /// What the last right-click on the canvas with the Intertwine tool arranges, with the + /// document's `uid`: the Intertwine group, the point and the object nearest to it where three + /// or more objects overlap ([`vectorcraft_tools::intertwine::menu_target`]). The context menu + /// lists Bring to Front, Bring Forward, Send Backward and Send to Back for that object while it + /// is open. Not saved. + #[serde(skip)] + pub context_intertwine: Option<(u64, vectorcraft_doc::NodeId, vectorcraft_geom::Point, vectorcraft_doc::NodeId)>, /// Last tool shown in each toolbar slot (keyed by the slot's first tool id). #[serde(default)] pub slot_tool: std::collections::BTreeMap, @@ -833,6 +840,7 @@ impl Default for UiState { task_bar: true, task_bar_place: TaskBarPlace::default(), context_locked: None, + context_intertwine: None, slot_tool: Default::default(), floating_flyouts: vec![], toolbar_edits: ToolbarEdits::default(), diff --git a/crates/ui-egui/src/tests/contextmenu.rs b/crates/ui-egui/src/tests/contextmenu.rs index 74e5c2785..e4445ca34 100644 --- a/crates/ui-egui/src/tests/contextmenu.rs +++ b/crates/ui-egui/src/tests/contextmenu.rs @@ -288,6 +288,61 @@ fn selected_type_has_apply_numbers() { assert_eq!(text.para_at(0).list.and_then(|l| l.number), Some(vectorcraft_doc::text::NumberStyle::Arabic)); } +/// The members of the overlap area of Intertwine group `g` at (x, y), in the order they are drawn +/// there, bottom first. +fn intertwine_order(app: &VectorcraftApp, g: NodeId, x: f64, y: f64) -> Vec { + use vectorcraft_tools::intertwine::{drawn_order, face_at, faces}; + let st = app.session.active().unwrap(); + let children = st.doc.node(g).unwrap().children().unwrap(); + let face = face_at(&faces(children), Point::new(x, y)).unwrap(); + drawn_order(children, &face).into_iter().map(|i| children[i].id).collect() +} + +/// With the Intertwine tool, a right-click where three or more objects of the group overlap lists +/// Bring to Front, Bring Forward, Send Backward and Send to Back for the object nearest to the +/// pointer in place of the Arrange submenu, and they move it in that overlap area only. Elsewhere +/// the menu is the usual one. +#[test] +fn right_click_where_three_intertwined_objects_overlap_arranges_the_nearest_one() { + let mut app = app(); + // Rectangles from x = 50, 90 and 130, 120 wide: all three overlap from x = 130 to 170. + let wide = |app: &mut VectorcraftApp, x: f64| { + NodeId(app.run("shape.rectangle", json!({"x": x, "y": 50, "width": 120, "height": 100})).unwrap()["id"].as_u64().unwrap()) + }; + let (a, b, c) = (wide(&mut app, 50.0), wide(&mut app, 90.0), wide(&mut app, 130.0)); + app.run("select.set", json!({"ids": [a.0, b.0, c.0]})).unwrap(); + let g = NodeId(app.run("object.intertwine.make", json!({})).unwrap()["id"].as_u64().unwrap()); + assert_eq!(app.session.tool_id(), "intertwine"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let items = ["Bring to Front", "Bring Forward", "Send Backward", "Send to Back"]; + // By the right edge of A (the first rectangle), where all three overlap. + let p = screen(&app, 169.0, 100.0); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + assert_eq!(app.session.active().unwrap().selection.objects, [g], "the selection stays the group"); + for label in items { + assert!(has(&texts, label), "{label} in {texts:?}"); + } + assert!(!has(&texts, "Arrange"), "no Arrange submenu: {texts:?}"); + let rows: Vec = items.iter().map(|l| at(&texts, l).y).collect(); + assert!(rows.windows(2).all(|w| w[0] < w[1]), "in that order: {rows:?}"); + let Some(Item::Cmd(_, id, params)) = menus::context_items(&app).into_iter().find(|i| matches!(i, Item::Cmd("Bring Forward", ..))) else { + panic!("no Bring Forward item") + }; + assert_eq!(id, "object.intertwine.arrange"); + assert_eq!((¶ms["id"], ¶ms["order"], ¶ms["member"]), (&json!(g.0), &json!("forward"), &json!(a.0))); + // Bring Forward moves A one place up in that area only. + assert_eq!(intertwine_order(&app, g, 150.0, 100.0), [a, b, c]); + let texts = click(&mut app, &ctx, at(&texts, "Bring Forward"), PointerButton::Primary); + assert!(!has(&texts, "Bring Forward"), "the menu closes"); + assert_eq!(intertwine_order(&app, g, 150.0, 100.0), [b, a, c]); + assert_eq!(intertwine_order(&app, g, 110.0, 100.0), [a, b], "where only A and B overlap"); + // Where only two overlap, the menu is the usual one. + let p = screen(&app, 110.0, 100.0); + let texts = click(&mut app, &ctx, p, PointerButton::Secondary); + assert!(has(&texts, "Arrange") && !has(&texts, "Bring Forward"), "{texts:?}"); +} + /// Every label the context menu can show is a menu string, so complete catalogs must translate it. #[test] fn every_context_label_is_a_menu_string() { diff --git a/docs/gaps.md b/docs/gaps.md index eb8d42b54..b7ffe7f75 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -439,9 +439,11 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** Object › Intertwine (#1763): Make, Edit and Release; the Intertwine tool highlights the overlap area under the pointer, a click swaps which object is on top there, and a path (Shift: a rectangle) drawn around overlaps - arranges each one inside it; `object.intertwine.arrange` also brings an object to the front or sends it to the back - where three or more overlap. The group renders, saves and exports through its live expansion. Still missing: the - right-click Bring Forward / Send Backward menu where three or more objects overlap; adding objects to the group by + arranges each one inside it. Where three or more objects overlap, hovering highlights the object nearest to the + pointer, and a right-click offers Bring to Front, Bring Forward, Send Backward and Send to Back for it in that area + (`object.intertwine.arrange` with `order` front, forward, backward or back; + ). + The group renders, saves and exports through its live expansion. Still missing: adding objects to the group by dragging them in the Layers panel is untested. - **Done (from users' reports):** Object › Create Gradient Mesh on an embedded image turns it into a mesh over its rectangle, with the image's transform kept; each point takes the average colour and opacity of the picture around it diff --git a/docs/mcp.md b/docs/mcp.md index 89afecd0d..d4898b517 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1433,6 +1433,19 @@ Group. Construction mode ends when the group was in it. outside the bounds the group's original shapes had before the move, in the move's undo step. A shape whose bounds still overlap them only moves. The Selection tool sends it at the end of a move in Construction mode. +## Intertwine + +`object.intertwine.arrange {id?, at?|area?, order?, member?}` changes the order the objects of an Intertwine group are +drawn in, in the overlap areas under `at` or enclosed by the polygon `area`. `order` is `toggle` (the default: the +object right below the top comes to the front), `front`, `forward`, `backward` or `back`. `forward` and `backward` move +`member` one place up or down among the objects that overlap in that area, and `front` and `back` move it to the top or +the bottom there; the other objects keep their order. Without `member`, `front` and `forward` move the object right +below the top, and `back` and `backward` move the top one. The command returns `{count, top}`: the number of areas +whose order changed and the id of the object now on top in each. A move that changes no area returns +`{count: 0, top: []}` and records no undo step. With the Intertwine tool, a right-click where three or more objects +overlap offers Bring to Front, Bring Forward, Send Backward and Send to Back, which run this command for the object +nearest to the pointer. + ## Global Edit Select › Start Global Edit is `select.globalEdit {appearance?, size?, artboards?, includeCanvas?}`. With one path,