diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 425505a72..451173e86 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -320,7 +320,12 @@ impl<'a> Scene<'a> { } } // Live blends, envelopes, meshes and repeats are written as their evaluated art. - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); for c in g.children().into_iter().flatten() { self.node(c, false); diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 6e624dbd5..890ac31f5 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -145,7 +145,7 @@ fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, symbols: &SymbolBox let skip = |id| seen.contains(&id); let mut chain = Vec::new(); let hit = match &c.kind { - NodeKind::Group { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some(), + NodeKind::Group { .. } | NodeKind::Intertwine { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some(), NodeKind::Blend { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some() || hit_leaf(c, p, opt, symbols).is_some(), _ => hit_leaf(c, p, opt, symbols).is_some(), }; @@ -262,7 +262,8 @@ fn hit_children( { return None; } - for (i, c) in children.iter().enumerate().rev() { + for i in hit_order(parent, children, p) { + let Some(c) = children.get(i) else { continue }; if !may_hit(parent, i, c, p, opt, symbols, editable_only) || skip(c.id) { continue; } @@ -270,7 +271,9 @@ fn hit_children( let hit = match &c.kind { // A sublayer whose Preview is off is clicked in outline, like an outline view. NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, symbols, skip, chain, editable_only), - NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, symbols, skip, chain, editable_only), + NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Intertwine { .. } => { + hit_children(c, p, opt, symbols, skip, chain, editable_only) + } // Edit Contents: the envelope's content hits, undistorted. NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, symbols, skip, chain, editable_only), // A blend's key objects first (Direct and Group Selection pick them); its steps hit @@ -287,6 +290,24 @@ fn hit_children( None } +/// 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. +fn hit_order(parent: &Node, children: &[std::sync::Arc], p: Point) -> Vec { + let mut order: Vec = (0..children.len()).rev().collect(); + if matches!(parent.kind, NodeKind::Intertwine { .. }) { + let over = |i: &usize| { + children + .get(*i) + .and_then(|c| crate::intertwine::over_area(c)) + .is_some_and(|a| fill_contains(&a.to_bezpath(), vectorcraft_geom::FillRule::NonZero, p)) + }; + // A stable sort: the members drawn over the others keep their own order among them. + order.sort_by_key(|i| !over(i)); + } + order +} + /// A click on the strokes of a path or compound path `n` along `bp`: what any visible stroke /// paints there (arrowheads, alignment, width profile, projecting caps, miter spikes), else its /// outline within the tolerance. Outline mode hits the outline only. diff --git a/crates/doc/src/intertwine.rs b/crates/doc/src/intertwine.rs new file mode 100644 index 000000000..e22ccbbaf --- /dev/null +++ b/crates/doc/src/intertwine.rs @@ -0,0 +1,98 @@ +//! 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. +//! +//! 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 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 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). +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 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); + zero_ids(&mut copy); + out.push(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true })); + } + out +} + +/// `n` without its Intertwine area (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; + } + n +} + +fn zero_ids(n: &mut Node) { + n.id = NodeId(0); + for c in n.children_mut().into_iter().flatten() { + zero_ids(Arc::make_mut(c)); + } +} + +#[cfg(test)] +mod tests { + use vectorcraft_geom::{Rect, shapes}; + + use super::*; + + fn square(id: u64, x: f64) -> Arc { + Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), crate::Appearance::default())) + } + + #[test] + fn members_without_areas_expand_to_themselves() { + let kids = vec![square(1, 0.0), square(2, 5.0)]; + let out = expand(&kids); + assert_eq!(out.len(), 2); + assert_eq!(out.iter().map(|n| n.id).collect::>(), [NodeId(1), NodeId(2)]); + } + + #[test] + fn a_member_with_an_area_is_drawn_again_clipped_to_it_on_top() { + let mut a = (*square(1, 0.0)).clone(); + a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(5.0, 0.0, 10.0, 10.0)))); + let kids = vec![Arc::new(a), square(2, 5.0)]; + let out = expand(&kids); + assert_eq!(out.len(), 3, "both members, then the raised copy"); + assert!(out.iter().all(|n| n.extras().intertwine.is_none()), "the expansion is plain art"); + let NodeKind::Group { children, clip: true } = &out[2].kind else { panic!("a clip group: {:?}", out[2].kind) }; + assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. })); + assert_eq!(children[1].path_data(), kids[0].path_data()); + assert_eq!((out[2].id, children[1].id), (NodeId(0), NodeId(0)), "generated art has no ids of its own"); + } + + #[test] + fn a_hidden_member_is_not_drawn_over_the_others() { + let mut a = (*square(1, 0.0)).clone(); + a.visible = false; + a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(5.0, 0.0, 10.0, 10.0)))); + assert_eq!(expand(&[Arc::new(a), square(2, 5.0)]).len(), 2); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 00af09406..6c606d11e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -18,6 +18,7 @@ pub mod hit; mod id_index; pub mod inks; mod inline; +pub mod intertwine; pub mod shared; pub use inline::{SYMBOL_HALF, SYMBOL_SIZES}; pub use shared::Shared; diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 183abcb10..5fc4d0335 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -1437,7 +1437,7 @@ fn map_node(n: &Node, w: &Warper) -> Node { } } NodeKind::Mesh(m) => m.map(w.f), - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => { + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => { if let Some(d) = &deeper { return map_node(&expanded_group_hooks(n, w.hooks), d); } @@ -1616,7 +1616,15 @@ pub fn envelope_bounds(content: &[Arc], kind: &EnvelopeKind, frame: Affine /// Is this one of the live kinds? pub fn is_live(n: &Node) -> bool { - matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_)) + matches!( + n.kind, + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } + ) } /// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch. @@ -1651,6 +1659,7 @@ pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec { NodeKind::Mesh(m) => mesh_quad_nodes(m, 8), NodeKind::Repeat(r) => r.expand(), NodeKind::PlacedDocument(p) => p.art(n), + NodeKind::Intertwine { children } => crate::intertwine::expand(children), _ => vec![n.clone()], } } diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index 1a51c4d70..810745163 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -251,10 +251,14 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize { b += ALLOC + size_of::(); b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective); 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)); } b += match &n.kind { NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live), - NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => vec_bytes(children), + NodeKind::Layer { children, .. } + | NodeKind::Group { children, .. } + | NodeKind::Compound { children, .. } + | NodeKind::Intertwine { children } => vec_bytes(children), NodeKind::Blend { children, .. } => vec_bytes(children) + ALLOC + size_of::(), NodeKind::Envelope { content, .. } => vec_bytes(content) + 2 * ALLOC + size_of::() + size_of::(), NodeKind::Repeat(r) => vec_bytes(&r.source) + OTHER_KIND, diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 8eab30551..0e3d74cb8 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -486,6 +486,12 @@ pub enum NodeKind { /// A placed document: an artboard of another VectorCraft file, linked and locked (see /// [`crate::placed_document`]). PlacedDocument(Box), + /// Object › Intertwine: the objects keep their own stacking order, except where one of them + /// is drawn over the others: the area [`NodeExtras::intertwine`] of that member (see + /// [`crate::intertwine`]). + Intertwine { + children: Vec>, + }, } fn yes() -> bool { @@ -561,6 +567,9 @@ pub struct NodeExtras { /// Dimension object: the live measurement it draws; its ordinary group children hold its /// exportable appearance (Object › Dimensions, the Dimension tool). pub dimension: Option>, + /// 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>, } /// No extras: what [`Node::extras`] gives for a node without any. @@ -577,6 +586,7 @@ static NO_EXTRAS: NodeExtras = NodeExtras { slice: None, perspective: None, dimension: None, + intertwine: None, }; /// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal @@ -709,6 +719,7 @@ impl Serialize for Node { is_default(&self.bbox_angle), e.perspective.is_none(), e.dimension.is_none(), + e.intertwine.is_none(), ]; let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; let mut skip = skips.into_iter(); @@ -745,6 +756,7 @@ impl Serialize for Node { field!("bbox_angle", &self.bbox_angle); field!("perspective", &e.perspective); field!("dimension", &e.dimension); + field!("intertwine", &e.intertwine); st.end() } } @@ -800,6 +812,8 @@ struct NodeWire { perspective: Option>, #[serde(default)] dimension: Option>, + #[serde(default)] + intertwine: Option>, } impl From for Node { @@ -817,6 +831,7 @@ impl From for Node { slice: w.slice, perspective: w.perspective, dimension: w.dimension, + intertwine: w.intertwine, }; Node { id: w.id, @@ -902,7 +917,8 @@ impl Node { | NodeKind::Compound { children, .. } | NodeKind::Blend { children, .. } | NodeKind::Envelope { content: children, .. } - | NodeKind::Repeat(RepeatSpec { source: children, .. }) => Some(children), + | NodeKind::Repeat(RepeatSpec { source: children, .. }) + | NodeKind::Intertwine { children } => Some(children), _ => None, } } @@ -913,7 +929,8 @@ impl Node { | NodeKind::Compound { children, .. } | NodeKind::Blend { children, .. } | NodeKind::Envelope { content: children, .. } - | NodeKind::Repeat(RepeatSpec { source: children, .. }) => Some(children), + | NodeKind::Repeat(RepeatSpec { source: children, .. }) + | NodeKind::Intertwine { children } => Some(children), _ => None, } } @@ -981,6 +998,7 @@ impl Node { NodeKind::Mesh(_) => "Mesh", NodeKind::Repeat(r) => r.kind.label(), NodeKind::PlacedDocument(_) => "Placed Document", + NodeKind::Intertwine { .. } => "Intertwine", } } /// Name shown in the Layers panel: explicit name or ``. @@ -1092,6 +1110,8 @@ impl Node { // copies out by the source's bounds measured without the document. NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?), NodeKind::PlacedDocument(p) => Some(p.bounds()), + // The members drawn over the others are drawn where they are. + NodeKind::Intertwine { children } => union(children), } } /// Visual bounds: what the object paints, its strokes included (paths and compound paths @@ -1126,6 +1146,7 @@ impl Node { clip.geometric(symbols) } } + NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))), NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { // The container's own strokes paint around its members. let o = self.appearance.outset(); @@ -1221,7 +1242,10 @@ impl Node { } } } - NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => { + NodeKind::Layer { children, .. } + | NodeKind::Group { children, .. } + | NodeKind::Compound { children, .. } + | NodeKind::Intertwine { children } => { for c in children.iter_mut() { Arc::make_mut(c).transform_scaled(a, sc); } @@ -1275,6 +1299,10 @@ 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); + } } /// Visit this node and all descendants depth first (paint order). pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Node)) { @@ -1403,7 +1431,7 @@ impl Node { let frame = shapes::rectangle(p.natural()).transformed(p.xf); out.push((frame.to_bezpath(), FillRule::NonZero)); } - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } => { crate::live::expand_deep(self, text).push_clip_shapes(text, out); } } @@ -1818,6 +1846,9 @@ mod kind_de { Mesh(GradientMesh), Repeat(RepeatSpec), PlacedDocument(Box), + Intertwine { + children: Vec>, + }, } impl<'de> Deserialize<'de> for NodeKind { diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index dd98fa6f3..8f55e845e 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -50,7 +50,7 @@ pub const CATEGORIES: [(&str, &str); 11] = [ ]; /// The object counts of `document.info` and their labels. -const OBJECT_LABELS: [(&str, &str); 17] = [ +const OBJECT_LABELS: [(&str, &str); 18] = [ ("paths", "Paths"), ("compoundPaths", "Compound Paths"), ("groups", "Groups"), @@ -65,6 +65,7 @@ const OBJECT_LABELS: [(&str, &str); 17] = [ ("blends", "Blends"), ("envelopes", "Envelopes"), ("repeats", "Repeats"), + ("intertwines", "Intertwine Groups"), ("opacityMasks", "Opacity Masks"), ("liveEffects", "Objects with Effects"), ("guides", "Guides"), @@ -284,6 +285,7 @@ fn info(s: &mut Session, p: &Value) -> Result { NodeKind::Mesh(_) => Some("meshes"), NodeKind::Repeat(_) => Some("repeats"), NodeKind::PlacedDocument(_) => Some("placedDocuments"), + NodeKind::Intertwine { .. } => Some("intertwines"), }; if let Some(k) = k { *counts.entry(k).or_default() += 1; diff --git a/crates/engine/src/cmd/intertwine.rs b/crates/engine/src/cmd/intertwine.rs new file mode 100644 index 000000000..9c407b63d --- /dev/null +++ b/crates/engine/src/cmd/intertwine.rs @@ -0,0 +1,405 @@ +//! Object › Intertwine (#1763): objects that pass over and under each other. +//! +//! `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`). +//! +//! 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). + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_doc::intertwine::over_area; +use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{FillRule, PathData, Point, SubPath, shapes}; +use vectorcraft_pathops as po; +use vectorcraft_render::effects; + +use super::edit::selected_roots; +use super::pathops::Beside; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "object.intertwine.make", + "Make", + ["Object", "Intertwine"], + None, + "{ids?: two or more objects (default: the selection)} put the objects in an Intertwine group, where overlap areas can be arranged with object.intertwine.arrange → {id}", + has_selection, + make + ), + cmd!( + "object.intertwine.release", + "Release", + ["Object", "Intertwine"], + None, + "{} give the objects of the selected Intertwine groups back as they were made, dropping the arranged areas → {ids}", + has_intertwine, + release + ), + cmd!( + "object.intertwine.arrange", + "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: back moves the top one)} → {count: areas changed, top: [id of the object now on top in each area]}", + has_intertwine, + arrange + ), + ] +} + +// ---------- predicates ---------- + +/// The Intertwine groups of the selection: selected ones, and those around a selected object. +fn selected_groups(s: &Session) -> Vec { + let Some(st) = s.active() else { return vec![] }; + let mut out = vec![]; + for id in &st.selection.objects { + let group = st.doc.ancestry(*id).unwrap_or_default().into_iter().rev().find(|a| st.doc.node(*a).is_some_and(is_intertwine)); + if let Some(g) = group + && !out.contains(&g) + { + out.push(g); + } + } + out +} + +fn is_intertwine(n: &Node) -> bool { + matches!(n.kind, NodeKind::Intertwine { .. }) +} + +fn has_intertwine(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + if selected_groups(s).is_empty() { Err("select an Intertwine group".into()) } else { Ok(()) } +} + +// ---------- Make / Release ---------- + +/// Why `n` can't go into an Intertwine group, if it can't (the help page's limits: no nested +/// Intertwine groups, Live Paint, Repeat or graphs). +fn refused(d: &Document, n: &Node) -> Option<&'static str> { + if d.ancestry(n.id).unwrap_or_default().iter().filter_map(|a| d.node(*a)).any(is_intertwine) { + return Some("an object already in an Intertwine group"); + } + let mut why = None; + n.walk(&mut |c| { + why = why.or(match &c.kind { + NodeKind::Intertwine { .. } => Some("an Intertwine group"), + NodeKind::Repeat(_) => Some("a Repeat"), + NodeKind::Group { .. } if c.extras().graph.is_some() => Some("a graph"), + NodeKind::Group { .. } if vectorcraft_tools::builder::is_live_paint(c) => Some("a Live Paint group"), + _ => None, + }); + }); + why +} + +fn make(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.intertwine.make"; + let roots = match p.get("ids") { + Some(_) => super::edit::roots_of(&s.doc()?.doc, checked_ids_param(s, p, "ids", C)?), + None => selected_roots(s)?, + }; + if roots.len() < 2 { + return Err(bad(C, "select two or more objects to intertwine")); + } + { + let d = &s.doc()?.doc; + for r in &roots { + let n = d.node(*r).ok_or(EngineError::NoNode(*r))?; + if n.is_layer() { + return Err(bad(C, "layers can't be intertwined: select the objects in them")); + } + if let Some(what) = refused(d, n) { + return Err(bad(C, format!("{what} can't be intertwined"))); + } + } + } + let id = s.edit("Make Intertwine", |d, sel| { + let id = d.alloc_id(); + d.wrap(&roots, |children| Node::new(id, NodeKind::Intertwine { children }))?; + sel.set([id]); + Ok(id) + })?; + Ok(json!({ "id": id.0 })) +} + +fn release(s: &mut Session, _: &Value) -> Result { + let groups = selected_groups(s); + let ids = s.edit("Release Intertwine", |d, sel| { + let mut out = vec![]; + let mut released = Beside::replacing(d, &groups); + for g in &groups { + let Some(n) = d.node(*g).cloned() else { continue }; + let members: Vec = n + .children() + .into_iter() + .flatten() + .map(|c| { + let mut c = (**c).clone(); + c.extras_mut().intertwine = None; + c + }) + .collect(); + out.extend(members.iter().map(|c| c.id)); + released.put(d, *g, members)?; + } + released.finish(d)?; + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) +} + +// ---------- Arrange ---------- + +#[derive(Clone, Copy, PartialEq)] +enum Order { + Toggle, + Front, + Back, +} + +/// The area `n` paints, in document coordinates, filled non-zero: its visible fills and stroke +/// outlines; type by its outlines; groups by their visible members; other objects by their +/// bounds. Effects are left out. `None` when it paints nothing. +pub(crate) fn painted_area(n: &Node) -> Option { + if !n.visible { + return None; + } + let mut parts: Vec = vec![]; + let shape = match &n.kind { + NodeKind::Path { path, rule, guide: false, clipping: false, .. } => Some((path.clone(), *rule)), + NodeKind::Compound { children, rule } => { + let subs: Vec = children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect(); + Some((PathData::new(subs), *rule)) + } + NodeKind::Path { .. } => return None, + NodeKind::Group { children, clip: true } => { + // What the members paint inside the clipping path. + let clip = children.first().and_then(|c| match &c.kind { + NodeKind::Path { path, rule, .. } => Some(po::normalize(path, *rule)), + _ => c.geometric_bounds().map(shapes::rectangle), + })?; + let inside = unite(children.iter().skip(1).filter_map(|c| painted_area(c)).collect())?; + return nonempty(po::boolean(&inside, FillRule::NonZero, &clip, FillRule::NonZero, po::BoolOp::Intersect)); + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Intertwine { children } => { + return unite(children.iter().filter_map(|c| painted_area(c)).collect()); + } + NodeKind::Text(_) => return effects::outline_text(n).as_ref().and_then(painted_area), + _ => None, + }; + match shape { + Some((path, rule)) => { + let filled = n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !f.paint.is_none())); + if filled { + parts.push(po::normalize(&path, rule)); + } + for st in n.appearance.painted_strokes().filter(|st| st.width > 0.0 && st.width.is_finite()) { + parts.push(effects::stroke::outline_region(&path, rule, st)); + } + } + None => parts.push(shapes::rectangle(n.visual_bounds()?)), + } + unite(parts) +} + +fn unite(parts: Vec) -> Option { + let parts: Vec = parts.into_iter().filter(|p| !p.is_empty()).collect(); + match parts.len() { + 0 => None, + 1 => parts.into_iter().next(), + _ => nonempty(po::unite_all(&parts.iter().map(|p| (p, FillRule::NonZero)).collect::>())), + } +} + +/// Areas smaller than this (pt²) are what a boolean leaves of an area taken from itself. +const SLIVER: f64 = 1e-3; + +fn nonempty(p: PathData) -> Option { + (!p.is_empty() && po::area(&p, FillRule::NonZero).abs() > SLIVER).then_some(p) +} + +/// A closed polygon from `[[x,y]…]` (three points or more, finite). +fn polygon(v: &Value) -> Option { + let pts: Vec = v.as_array()?.iter().map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect::>()?; + if pts.len() < 3 || pts.iter().any(|q| !(q.x.is_finite() && q.y.is_finite())) { + return None; + } + Some(PathData::single(SubPath::polyline(&pts, true))) +} + +fn contains(area: &PathData, p: Point) -> bool { + vectorcraft_geom::hit::fill_contains(&area.to_bezpath(), FillRule::NonZero, p) +} + +/// One overlap area to arrange: its outline, a point inside it, and the members (child indices, +/// bottom first) that paint there. +struct Face { + path: PathData, + inside: Point, + members: Vec, +} + +/// The overlap areas of `children` that `at` lies in, or that `lasso` encloses. +fn faces(children: &[Arc], at: Option, lasso: Option<&PathData>) -> Vec { + let shapes: Vec = + children.iter().enumerate().filter_map(|(i, c)| Some(po::Shape::new(painted_area(c)?, FillRule::NonZero, i as u64))).collect(); + let key = |k: usize| shapes.get(k).map(|s| s.key as usize); + po::regions(&shapes) + .into_iter() + .filter(|r| r.sources.len() >= 2) + .filter_map(|r| { + let inside = match at { + Some(p) => r.contains(p).then_some(p)?, + None => po::interior_point(&r.path)?, + }; + if lasso.is_some_and(|l| !contains(l, inside)) { + return None; + } + Some(Face { members: r.sources.iter().filter_map(|k| key(*k)).collect(), path: r.path, inside }) + }) + .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. +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 mut order = face.members.clone(); + order.sort_by_key(|i| (over(i), *i)); + order +} + +/// `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) { + (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); + } + } + drawn_order(children, face).last().copied() +} + +fn arrange(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.intertwine.arrange"; + let group = match p.get("id") { + Some(v) => { + let id = checked_id(s, v, C)?; + if !s.doc()?.doc.node(id).is_some_and(is_intertwine) { + return Err(bad(C, format!("object {} is not an Intertwine group", id.0))); + } + id + } + None => match selected_groups(s).as_slice() { + [g] => *g, + [] => return Err(bad(C, "select an Intertwine group")), + _ => return Err(bad(C, "select one Intertwine group")), + }, + }; + let at = match p.get("at") { + Some(_) => Some(point_param(p, "at").filter(|q| q.x.is_finite() && q.y.is_finite()).ok_or_else(|| bad(C, "at must be [x, y]"))?), + None => None, + }; + let lasso = match p.get("area") { + Some(v) => Some(polygon(v).ok_or_else(|| bad(C, "area must be a closed polygon of three or more [x, y] points"))?), + None => None, + }; + if at.is_some() == lasso.is_some() { + return Err(bad(C, "give either at (a click) or area (a path drawn around the overlaps)")); + } + let order = match str_param(p, "order").unwrap_or("toggle") { + "toggle" => Order::Toggle, + "front" => Order::Front, + "back" => Order::Back, + o => return Err(bad(C, format!("unknown order `{o}`: toggle, front 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")); + } + let children: Vec> = s.doc()?.doc.node(group).and_then(Node::children).cloned().unwrap_or_default(); + let member = match member { + Some(m) => Some(children.iter().position(|c| c.id == m).ok_or_else(|| bad(C, format!("object {} is not in this Intertwine group", m.0)))?), + None => None, + }; + let found = faces(&children, at, lasso.as_ref()); + if found.is_empty() { + return Err(bad( + C, + if at.is_some() { + "no overlap there: click where two or more objects of the group overlap" + } else { + "the path encloses no overlap of the group's objects" + }, + )); + } + if let Some(m) = member + && !found.iter().any(|f| f.members.contains(&m)) + { + return Err(bad(C, "that object is not in the overlap")); + } + let 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.last().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); + } + } + Ok(tops) + })?; + Ok(json!({ "count": tops.len(), "top": tops })) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f12e5939f..490548e5c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -34,6 +34,7 @@ mod halftone; pub mod help; pub mod importmatches; pub(crate) mod inline; +mod intertwine; mod layer; mod layerpanel; pub mod library; @@ -250,6 +251,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(shaper::specs()); v.extend(brushsym::specs()); v.extend(patterncmds::specs()); + v.extend(intertwine::specs()); v.extend(prefscmds::specs()); v.extend(distortcmds::specs()); v.extend(paint::proxy_specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 082619a62..a70821353 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1904,6 +1904,8 @@ mod tests_ids_without_selection; #[cfg(test)] mod tests_inline; #[cfg(test)] +mod tests_intertwine; +#[cfg(test)] mod tests_journal; #[cfg(test)] mod tests_knockout; diff --git a/crates/engine/src/tests_intertwine.rs b/crates/engine/src/tests_intertwine.rs new file mode 100644 index 000000000..bdf7a4751 --- /dev/null +++ b/crates/engine/src/tests_intertwine.rs @@ -0,0 +1,204 @@ +//! Object › Intertwine (#1763), as a tutorial does it: two thick rings that overlap twice are +//! made into an Intertwine group, then one overlap is clicked so the rings pass over and under +//! each other. Driven through `Session::execute`; the art is checked on screen, in the saved file +//! and in the SVG and PDF output. + +use serde_json::{Value, json}; +use vectorcraft_render::Rendered; + +use super::tests_clip::{hit, id_of, render, select, session}; +use super::*; + +/// Ring A (red, centre 35,50) and ring B (blue, centre 65,50), radius 25, 8 pt strokes, no fill: +/// B is in front. The rings cross at (50, 30) and (50, 70). +fn rings(s: &mut Session) -> (NodeId, NodeId) { + let ring = |s: &mut Session, cx: f64, color: &str| { + let id = id_of(&s.execute("shape.ellipse", &json!({"x": cx - 25.0, "y": 25, "width": 50, "height": 50})).unwrap()); + s.execute("paint.setFill", &json!({"ids": [id.0], "none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [id.0], "color": color})).unwrap(); + s.execute("stroke.set", &json!({"ids": [id.0], "weight": 8})).unwrap(); + id + }; + let a = ring(s, 35.0, "#ff0000"); + let b = ring(s, 65.0, "#0000ff"); + (a, b) +} + +/// The rings made into an Intertwine group: (group, a, b). +fn intertwined(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let (a, b) = rings(s); + select(s, &[a, b]); + let g = id_of(&s.execute("object.intertwine.make", &json!({})).unwrap()); + (g, a, b) +} + +fn red(r: &Rendered, x: u32, y: u32) -> bool { + let p = r.pixel(x, y); + p[3] > 200 && p[0] > 200 && p[2] < 60 +} + +fn blue(r: &Rendered, x: u32, y: u32) -> bool { + let p = r.pixel(x, y); + p[3] > 200 && p[2] > 200 && p[0] < 60 +} + +fn arrange(s: &mut Session, p: Value) -> Value { + s.execute("object.intertwine.arrange", &p).unwrap() +} + +fn area(s: &Session, id: NodeId) -> Option { + s.doc().unwrap().doc.node(id).unwrap().extras().intertwine.as_deref().cloned() +} + +#[test] +fn make_puts_the_objects_in_an_intertwine_group_drawn_as_before() { + let mut s = session(); + let (g, a, b) = intertwined(&mut s); + let d = &s.doc().unwrap().doc; + let n = d.node(g).unwrap(); + assert!(matches!(n.kind, NodeKind::Intertwine { .. })); + assert_eq!(n.kind_label(), "Intertwine"); + assert_eq!(n.children().unwrap().iter().map(|c| c.id).collect::>(), [a, b], "the stacking order is kept"); + assert_eq!(s.doc().unwrap().selection.objects.iter().copied().collect::>(), [g]); + let r = render(&s); + assert!(blue(&r, 50, 30) && blue(&r, 50, 70), "B is in front at both crossings until an area is arranged"); +} + +#[test] +fn clicking_an_overlap_brings_the_object_below_to_the_front_there_only() { + let mut s = session(); + let (_, a, b) = intertwined(&mut s); + let v = arrange(&mut s, json!({"at": [50, 30]})); + assert_eq!(v["count"], 1); + assert_eq!(v["top"], json!([a.0])); + let r = render(&s); + assert!(red(&r, 50, 30), "A passes over B at the top crossing"); + assert!(blue(&r, 50, 70), "and under it at the bottom one"); + // Elsewhere each ring looks as it did. + assert!(red(&r, 10, 50) && blue(&r, 90, 50)); + assert!(area(&s, a).is_some() && area(&s, b).is_none()); + // A click there picks the ring drawn on top. + assert_eq!(hit(&s, 50.0, 30.0), Some(a)); + assert_eq!(hit(&s, 50.0, 70.0), Some(b)); +} + +#[test] +fn clicking_the_same_overlap_again_swaps_back() { + let mut s = session(); + let (_, a, b) = intertwined(&mut s); + arrange(&mut s, json!({"at": [50, 30]})); + let v = arrange(&mut s, json!({"at": [50, 30]})); + assert_eq!(v["top"], json!([b.0])); + assert!(area(&s, a).is_none() && area(&s, b).is_none(), "no area is left over"); + assert!(blue(&render(&s), 50, 30)); +} + +#[test] +fn a_path_drawn_around_overlaps_arranges_every_overlap_inside_it() { + let mut s = session(); + let (_, a, _) = intertwined(&mut s); + // Around the bottom crossing only. + let v = arrange(&mut s, json!({"area": [[40, 60], [60, 60], [60, 80], [40, 80]]})); + assert_eq!(v["count"], 1); + let r = render(&s); + assert!(blue(&r, 50, 30) && red(&r, 50, 70)); + // Around both: each one swaps. + let v = arrange(&mut s, json!({"area": [[40, 20], [60, 20], [60, 80], [40, 80]]})); + assert_eq!(v["count"], 2); + let r = render(&s); + assert!(red(&r, 50, 30) && blue(&r, 50, 70)); + assert!(area(&s, a).is_some()); +} + +#[test] +fn front_and_back_choose_the_member_where_three_objects_overlap() { + let mut s = session(); + let (a, b) = rings(&mut s); + // A green bar across the top crossing, in front of both rings. + 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(&mut s, &[a, b, c]); + s.execute("object.intertwine.make", &json!({})).unwrap(); + let green = |r: &Rendered| { + let p = r.pixel(50, 30); + p[1] > 200 && p[0] < 60 && p[2] < 60 + }; + assert!(green(&render(&s))); + arrange(&mut s, json!({"at": [50, 30], "order": "front", "member": a.0})); + assert!(red(&render(&s), 50, 30), "A is brought to the front of all three"); + arrange(&mut s, json!({"at": [50, 30], "order": "back", "member": a.0})); + assert!(green(&render(&s)), "sent to the back, the others are over it again in their own order"); + arrange(&mut s, json!({"at": [50, 30], "order": "back"})); + assert!(blue(&render(&s), 50, 30), "back without a member sends the top one (C) back: B is on top"); + let e = s.execute("object.intertwine.arrange", &json!({"at": [50, 30], "order": "front", "member": 999})).unwrap_err(); + assert!(e.to_string().contains("999"), "{e}"); +} + +#[test] +fn release_gives_the_objects_back_as_they_were() { + let mut s = session(); + let (g, a, b) = intertwined(&mut s); + arrange(&mut s, json!({"at": [50, 30]})); + let v = s.execute("object.intertwine.release", &json!({})).unwrap(); + assert_eq!(v["ids"], json!([a.0, b.0])); + let d = &s.doc().unwrap().doc; + assert!(d.node(g).is_none()); + assert!(d.node(a).unwrap().extras().intertwine.is_none()); + assert!(blue(&render(&s), 50, 30)); + // Undo brings the group back, arranged. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(red(&render(&s), 50, 30)); +} + +#[test] +fn the_arranged_areas_move_with_the_group() { + let mut s = session(); + let (g, ..) = intertwined(&mut s); + arrange(&mut s, json!({"at": [50, 30]})); + select(&mut s, &[g]); + s.execute("object.move", &json!({"dx": 0, "dy": 10})).unwrap(); + let r = render(&s); + assert!(red(&r, 50, 40) && blue(&r, 50, 80)); +} + +#[test] +fn the_woven_rings_survive_save_and_export() { + let mut s = session(); + intertwined(&mut s); + arrange(&mut s, json!({"at": [50, 30]})); + for format in ["vectorcraft", "svg", "pdf"] { + let data = s.execute("document.export", &json!({"format": format})).unwrap()["dataBase64"].clone(); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": format!("rings.{format}"), "dataBase64": data})).unwrap(); + let r = render(&o); + assert!(red(&r, 50, 30) && blue(&r, 50, 70), "{format}"); + } +} + +#[test] +fn what_cannot_be_intertwined_is_refused_with_a_reason() { + let mut s = session(); + let (a, b) = rings(&mut s); + select(&mut s, &[a]); + let e = s.execute("object.intertwine.make", &json!({})).unwrap_err(); + assert!(e.to_string().contains("two or more"), "{e}"); + select(&mut s, &[a, b]); + let g = id_of(&s.execute("object.intertwine.make", &json!({})).unwrap()); + let c = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap()); + let e = s.execute("object.intertwine.make", &json!({"ids": [g.0, c.0]})).unwrap_err(); + assert!(e.to_string().contains("Intertwine group can't"), "{e}"); + let e = s.execute("object.intertwine.make", &json!({"ids": [a.0, c.0]})).unwrap_err(); + assert!(e.to_string().contains("already in an Intertwine group"), "{e}"); + select(&mut s, &[g]); + let e = s.execute("object.intertwine.arrange", &json!({"at": [5, 50]})).unwrap_err(); + assert!(e.to_string().contains("no overlap"), "{e}"); + let e = s.execute("object.intertwine.arrange", &json!({"at": [50, 30], "area": [[0, 0], [1, 0], [1, 1]]})).unwrap_err(); + assert!(e.to_string().contains("either"), "{e}"); + let e = s.execute("object.intertwine.arrange", &json!({"at": [50, 30], "order": "sideways"})).unwrap_err(); + assert!(e.to_string().contains("sideways"), "{e}"); + let e = s.execute("object.intertwine.arrange", &json!({"area": [[0, 0], [1, 0]]})).unwrap_err(); + assert!(e.to_string().contains("polygon"), "{e}"); + let e = s.execute("object.intertwine.arrange", &json!({"id": c.0, "at": [1, 1]})).unwrap_err(); + assert!(e.to_string().contains("not an Intertwine group"), "{e}"); +} diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index f43f840c0..d11cae6f0 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -297,7 +297,12 @@ impl<'a> Scene<'a> { } } // Live blends, envelopes, meshes and repeats are written as their evaluated art. - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); for c in g.children().into_iter().flatten() { self.node(c, false); diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 8a5d940c3..5654d44f3 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -203,7 +203,12 @@ impl<'a> Scene<'a> { } } // Live blends, envelopes, meshes and repeats are written as their evaluated art. - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); for c in g.children().into_iter().flatten() { self.node(c, false); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 84c731cc5..ba497e78c 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1050,7 +1050,12 @@ impl Exporter<'_> { } } // Live blends/envelopes/meshes export their evaluated (expanded) form. - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); for c in g.children().into_iter().flatten() { self.node(s, c, page, false); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d08efd9bf..1a2e41d98 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1314,9 +1314,12 @@ impl Renderer { self.made_again = outer; } } - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { - self.draw_live_node(ctx, f, n) - } + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => self.draw_live_node(ctx, f, n), } } diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index c9cad92b3..0eeaedb50 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -249,7 +249,12 @@ impl Renderer { match &a.kind { NodeKind::PlacedDocument(p) => self.draw_placed(ctx, f, p), NodeKind::Mesh(m) => self.draw_mesh(ctx, f, a, m, cache), - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => { + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Repeat(_) + | NodeKind::Intertwine { .. } + | NodeKind::Group { .. } + | NodeKind::Layer { .. } => { let items = self.live_expanded(f.doc, a, cache); // An evaluation the cache did not keep is made again in the next frame. let kept = cache && self.live.expanded.get(&(Arc::as_ptr(a) as usize), self.stamp).is_some_and(|e| Arc::ptr_eq(&e.1, &items)); diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index b609c1ed2..122c532b9 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -394,9 +394,12 @@ impl Reuse { } } match &n.kind { - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { - (self.turns, self.scales) = (false, false) - } + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => (self.turns, self.scales) = (false, false), NodeKind::SymbolInstance { symbol, .. } => { if let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol) { self.scan(doc, &s.art, depth + 1); @@ -1462,7 +1465,12 @@ impl Writer<'_> { (self.xf, self.instance_xf) = saved; } // Live blends/envelopes/meshes export their evaluated (expanded) form. - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); self.share_images(&g); self.node_body(&g);