Intertwine: a right-click where three or more objects overlap arranges the nearest one

Squashed from #4862 onto main.
This commit is contained in:
dotCooCoo
2026-11-01 00:36:29 +07:00
committed by bflatastic
parent d6297dbfb9
commit ec8c1cb8d1
16 changed files with 592 additions and 115 deletions
+8 -13
View File
@@ -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<Node>], p: Point) -> impl Iterator<Item = usize> + 'a {
let over: Vec<usize> = 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<usize> = (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
+59 -20
View File
@@ -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: <https://helpx.adobe.com/illustrator/using/intertwine-objects.html>.
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<Item = (usize, &PathData)> {
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<Node>]) -> Vec<Node> {
let mut out: Vec<Node> = 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<Node>]) -> Vec<Node> {
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();
+3
View File
@@ -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::<Value>() + json_bytes(t));
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
if e.intertwine_tiers.capacity() > 0 {
b += ALLOC + e.intertwine_tiers.capacity() * size_of::<PathData>() + e.intertwine_tiers.iter().map(path_bytes).sum::<usize>();
}
b += e.objects_on_path.as_deref().map_or(0, |o| {
ALLOC + size_of::<crate::objects_on_path::ObjectsOnPath>() + o.members.capacity() * size_of::<crate::objects_on_path::Member>()
});
+6
View File
@@ -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<Box<PathData>>,
/// 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<PathData>,
/// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults).
pub live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
/// 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,
+7 -2
View File
@@ -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);
}
}
}
+17
View File
@@ -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::<Node>(&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
+35 -2
View File
@@ -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<T>(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<NodeId, D::Error> {
let id = NodeId::deserialize(d)?;
count_one()?;
Ok(id)
}
/// Count one object against [`with_node_budget`].
fn count_one<E: serde::de::Error>() -> 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<Vec<PathData>, D::Error> {
struct Tiers;
impl<'de> serde::de::Visitor<'de> for Tiers {
type Value = Vec<PathData>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a list of areas")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<PathData>, A::Error> {
let mut out = Vec::new();
while let Some(area) = seq.next_element::<PathData>()? {
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<Box<crate::dimension::Dimension>>,
#[serde(default)]
intertwine: Option<Box<PathData>>,
#[serde(default, deserialize_with = "counted_tiers")]
intertwine_tiers: Vec<PathData>,
#[serde(default)]
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
#[serde(default)]
@@ -215,6 +247,7 @@ impl From<NodeWire> 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,
+113 -51
View File
@@ -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: <https://helpx.adobe.com/illustrator/using/intertwine-objects.html> (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:
//! <https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-intertwined-objects.html>).
use std::sync::Arc;
@@ -62,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
.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<u
enum Order {
Toggle,
Front,
Forward,
Backward,
Back,
}
@@ -244,39 +250,86 @@ fn polygon(v: &Value) -> Option<PathData> {
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<PathData>, face: &PathData, add: bool) -> Option<PathData> {
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<Node>], face: &Face, x: usize, front: bool) -> Option<usize> {
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<Option<PathData>> =
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<PathData> = 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<usize> = 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<Node>], 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<usize> {
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<Value> {
@@ -309,15 +362,17 @@ fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
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<Arc<Node>> = 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<Value> {
{
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<usize>)> = 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<u64> = 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 }))
}
+127
View File
@@ -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<NodeId> {
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<NodeId> =
[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();
+106 -18
View File
@@ -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:
//! <https://helpx.adobe.com/illustrator/using/intertwine-objects.html>.
@@ -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<Node>]) -> Vec<Face> {
faces_of(&painted_areas(children))
}
/// What each of `children` paints ([`painted_area`]), by child index.
pub fn painted_areas(children: &[Arc<Node>]) -> Vec<Option<PathData>> {
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<PathData>]) -> Vec<Face> {
let shapes: Vec<po::Shape> =
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<Face> {
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<Node>], face: &Face) -> Vec<usize> {
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<PathData>], face: &Face, p: Point) -> Option<usize> {
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<NodeId> {
@@ -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<Vec<Option<PathData>>>,
faces: Arc<Vec<Face>>,
}
@@ -159,7 +195,8 @@ pub struct IntertwineTool {
prev: Option<String>,
/// The overlap areas of the group, for the document revision they were found in.
cache: Option<Found>,
/// 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<BezPath>,
/// A drag's points (document space); the first is where it started.
points: Vec<Point>,
@@ -170,17 +207,31 @@ pub struct IntertwineTool {
impl IntertwineTool {
fn faces(&mut self, cx: &ToolContext) -> Option<(NodeId, Arc<Vec<Face>>)> {
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<BezPath> {
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)];
+11 -2
View File
@@ -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);
+19 -4
View File
@@ -15,6 +15,21 @@ pub fn context_items(app: &VectorcraftApp) -> Vec<Item> {
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<Item> {
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")],
+8
View File
@@ -357,6 +357,13 @@ pub struct UiState {
/// saved.
#[serde(skip)]
pub context_locked: Option<(u64, Vec<vectorcraft_doc::NodeId>)>,
/// 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<String, String>,
@@ -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(),
+55
View File
@@ -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<NodeId> {
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<f32> = 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!((&params["id"], &params["order"], &params["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() {
+5 -3
View File
@@ -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;
<https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-intertwined-objects.html>).
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
+13
View File
@@ -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,