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
+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,