From 74c391c7baab4ca49f13a0fb64222b77f46b73a2 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 06:27:52 +0200 Subject: [PATCH 1/4] =?UTF-8?q?G13:=20Object=20=E2=80=BA=20Intertwine:=20M?= =?UTF-8?q?ake,=20Release=20and=20arranging=20overlap=20areas=20(#1763)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Intertwine group keeps its objects as they are, in their own stacking order; each member can be drawn over the others in an area of its own. The group draws, hits, saves and exports through its live expansion, so the members stay editable and Release gives them back unchanged. object.intertwine.arrange picks an overlap area by a click point or by a closed path drawn around overlaps and toggles which member is on top there, or brings a member to the front or sends it to the back where three or more objects overlap. --- crates/cad/src/scene.rs | 7 +- crates/doc/src/hit.rs | 27 +- crates/doc/src/intertwine.rs | 98 +++++++ crates/doc/src/lib.rs | 1 + crates/doc/src/live.rs | 13 +- crates/doc/src/mem.rs | 6 +- crates/doc/src/node.rs | 39 ++- crates/engine/src/cmd/docinfo.rs | 4 +- crates/engine/src/cmd/intertwine.rs | 405 ++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/lib.rs | 2 + crates/engine/src/tests_intertwine.rs | 204 +++++++++++++ crates/eps/src/scene.rs | 7 +- crates/metafile/src/scene.rs | 7 +- crates/pdf/src/export.rs | 7 +- crates/render/src/lib.rs | 9 +- crates/render/src/live.rs | 7 +- crates/svg/src/export.rs | 16 +- 18 files changed, 838 insertions(+), 23 deletions(-) create mode 100644 crates/doc/src/intertwine.rs create mode 100644 crates/engine/src/cmd/intertwine.rs create mode 100644 crates/engine/src/tests_intertwine.rs 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); From 3ae6cf28e4fd8f638574231f85851b6c3d0f6fe2 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 09:08:53 +0200 Subject: [PATCH 2/4] =?UTF-8?q?G13:=20Intertwine=20tool,=20Object=20?= =?UTF-8?q?=E2=80=BA=20Intertwine=20=E2=80=BA=20Edit,=20menu=20and=20trans?= =?UTF-8?q?lations=20(#1763)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make and Edit choose the Intertwine tool: hovering highlights the overlap area under the pointer, a click swaps which object is on top there, and a path drawn around overlaps (Shift: a rectangle) arranges every overlap inside it; Esc or Enter goes back to the tool chosen before. The overlap faces move to vectorcraft-tools, shared by the tool and the command. Clicks inside a raised area pick the member drawn on top there, the Object menu gets the Intertwine submenu, and the new labels and the predicate message are translated in the complete catalogs. docs/gaps.md records what landed under G13. --- Cargo.lock | 1 + crates/doc/src/hit.rs | 27 +- crates/engine/src/cmd/intertwine.rs | 164 ++++-------- crates/engine/src/tests_intertwine.rs | 70 +++++- crates/tools/Cargo.toml | 1 + crates/tools/src/intertwine.rs | 343 ++++++++++++++++++++++++++ crates/tools/src/lib.rs | 2 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/menus.rs | 4 + docs/gaps.md | 11 +- 24 files changed, 530 insertions(+), 136 deletions(-) create mode 100644 crates/tools/src/intertwine.rs diff --git a/Cargo.lock b/Cargo.lock index 03217449e..9214e3ded 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4930,6 +4930,7 @@ dependencies = [ "serde_json", "vectorcraft-color", "vectorcraft-doc", + "vectorcraft-effects", "vectorcraft-geom", "vectorcraft-pathops", "vectorcraft-text", diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 890ac31f5..a562fa3ff 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -292,20 +292,19 @@ 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. -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 +/// come first (from the top among them too). +fn hit_order<'a>(parent: &Node, children: &'a [std::sync::Arc], p: Point) -> impl Iterator + 'a { + let over: Vec = if matches!(parent.kind, NodeKind::Intertwine { .. }) { + let contains = |a: &vectorcraft_geom::PathData| fill_contains(&a.to_bezpath(), vectorcraft_geom::FillRule::NonZero, p); + (0..children.len()).rev().filter(|i| children.get(*i).and_then(|c| crate::intertwine::over_area(c)).is_some_and(contains)).collect() + } else { + vec![] + }; + let rest = (0..children.len()).rev().filter({ + let over = over.clone(); + move |i| !over.contains(i) + }); + over.into_iter().chain(rest) } /// A click on the strokes of a path or compound path `n` along `bp`: what any visible stroke diff --git a/crates/engine/src/cmd/intertwine.rs b/crates/engine/src/cmd/intertwine.rs index 9c407b63d..566cb7b20 100644 --- a/crates/engine/src/cmd/intertwine.rs +++ b/crates/engine/src/cmd/intertwine.rs @@ -18,11 +18,10 @@ 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_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{FillRule, PathData, Point, SubPath}; use vectorcraft_pathops as po; -use vectorcraft_render::effects; +use vectorcraft_tools::intertwine::{Face, drawn_order, face_at, faces, faces_in, is_intertwine, nonempty}; use super::edit::selected_roots; use super::pathops::Beside; @@ -35,10 +34,19 @@ pub fn specs() -> Vec { "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}", + "{ids?: two or more objects (default: the selection)} put the objects in an Intertwine group and choose the Intertwine tool, which arranges its overlap areas (object.intertwine.arrange) → {id}", has_selection, make ), + cmd!( + "object.intertwine.edit", + "Edit", + ["Object", "Intertwine"], + None, + "{} choose the Intertwine tool for the selected Intertwine group (Esc or Enter goes back to the tool before)", + has_intertwine, + edit + ), cmd!( "object.intertwine.release", "Release", @@ -53,7 +61,7 @@ pub fn specs() -> Vec { "Arrange Intertwine Area", [], None, - "{id?: Intertwine group (default: the selected one), at?: [x,y] (the overlap area under this point, as a click picks it) | area?: [[x,y]…] (a closed polygon drawn around overlap areas: every area whose inside it encloses), order?: toggle|front|back (toggle: the object drawn right below the top comes to the front, so two objects swap), member?: id (front and back: the object to move; default: back moves 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|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]}", has_intertwine, arrange ), @@ -77,10 +85,6 @@ fn selected_groups(s: &Session) -> Vec { 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(()) } @@ -91,7 +95,8 @@ fn has_intertwine(s: &Session) -> std::result::Result<(), String> { /// 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) { + let around = d.ancestry(n.id).unwrap_or_default(); + if around.iter().filter(|a| **a != n.id).filter_map(|a| d.node(*a)).any(is_intertwine) { return Some("an object already in an Intertwine group"); } let mut why = None; @@ -134,9 +139,26 @@ fn make(s: &mut Session, p: &Value) -> Result { sel.set([id]); Ok(id) })?; + choose_tool(s)?; Ok(json!({ "id": id.0 })) } +/// Choose the Intertwine tool, which goes back to the tool chosen now. +fn choose_tool(s: &mut Session) -> Result<()> { + let prev = Some(s.tool_id()).filter(|t| *t != "intertwine"); + let view = s.last_view; + s.select_tool("intertwine", view)?; + s.set_tool_option("prev", &json!(prev)); + Ok(()) +} + +fn edit(s: &mut Session, _: &Value) -> Result { + let [g] = selected_groups(s)[..] else { return Err(bad("object.intertwine.edit", "select one Intertwine group")) }; + s.select(|_, sel| sel.set([g]))?; + choose_tool(s)?; + Ok(json!({ "id": g.0 })) +} + fn release(s: &mut Session, _: &Value) -> Result { let groups = selected_groups(s); let ids = s.edit("Release Intertwine", |d, sel| { @@ -173,118 +195,19 @@ enum Order { 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) -} +/// The most points a drawn `area` may have (a long freehand loop has a few thousand). +const MAX_AREA_POINTS: usize = 100_000; -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). +/// A closed polygon from `[[x,y]…]` (three to [`MAX_AREA_POINTS`] points, 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::>()?; + let pts = v.as_array().filter(|a| a.len() <= MAX_AREA_POINTS)?; + let pts: Vec = pts.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(); @@ -341,7 +264,7 @@ fn arrange(s: &mut Session, p: &Value) -> Result { 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"))?), + Some(v) => Some(polygon(v).ok_or_else(|| bad(C, "area must be a closed polygon of 3 to 100000 [x, y] points"))?), None => None, }; if at.is_some() == lasso.is_some() { @@ -365,7 +288,12 @@ fn arrange(s: &mut Session, p: &Value) -> Result { 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()); + let all = faces(&children); + let found = match (at, &lasso) { + (Some(p), _) => face_at(&all, p).into_iter().collect(), + (None, Some(l)) => faces_in(&all, l), + (None, None) => vec![], + }; if found.is_empty() { return Err(bad( C, @@ -389,7 +317,7 @@ fn arrange(s: &mut Session, p: &Value) -> Result { 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::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 }; diff --git a/crates/engine/src/tests_intertwine.rs b/crates/engine/src/tests_intertwine.rs index bdf7a4751..734027d74 100644 --- a/crates/engine/src/tests_intertwine.rs +++ b/crates/engine/src/tests_intertwine.rs @@ -59,7 +59,7 @@ fn make_puts_the_objects_in_an_intertwine_group_drawn_as_before() { 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]); + assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [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"); } @@ -187,7 +187,7 @@ fn what_cannot_be_intertwined_is_refused_with_a_reason() { 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}"); + assert!(e.to_string().contains("an Intertwine group can't") && !e.to_string().contains("already"), "{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]); @@ -202,3 +202,69 @@ fn what_cannot_be_intertwined_is_refused_with_a_reason() { 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}"); } + +// ---------- on the canvas ---------- + +fn press(s: &mut Session, pts: &[(f64, f64)], shift: bool) -> Vec { + use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + let view = ViewInfo::default(); + let ev = |kind, (x, y): (f64, f64)| { + let mut e = PointerEvent::new(kind, x, y); + e.mods = Mods { shift, ..Mods::default() }; + e + }; + let (first, last) = (pts[0], pts[pts.len() - 1]); + s.pointer(&ev(PointerKind::Move, first), view).unwrap(); + s.pointer(&ev(PointerKind::Down, first), view).unwrap(); + for p in &pts[1..] { + s.pointer(&ev(PointerKind::Drag, *p), view).unwrap(); + } + s.pointer(&ev(PointerKind::Up, last), view).unwrap() +} + +#[test] +fn make_chooses_the_intertwine_tool_and_a_click_on_an_overlap_arranges_it() { + let mut s = session(); + let view = ViewInfo::default(); + s.select_tool("ellipse", view).unwrap(); + intertwined(&mut s); + assert_eq!(s.tool_id(), "intertwine"); + // Hovering the overlap highlights it. + s.pointer(&vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::Move, 50.0, 30.0), view).unwrap(); + assert_eq!(s.overlays(view).len(), 1); + press(&mut s, &[(50.0, 30.0)], false); + let r = render(&s); + assert!(red(&r, 50, 30) && blue(&r, 50, 70)); + // A click off any overlap changes nothing (and reports no error). + press(&mut s, &[(5.0, 5.0)], false); + assert!(red(&render(&s), 50, 30)); + // Esc goes back to the tool chosen before Make. + let back = s.tool_key(vectorcraft_tools::ToolKey::Escape, Default::default(), view).unwrap(); + assert_eq!(back, [UiRequest::SwitchTool("ellipse".into())]); +} + +#[test] +fn a_drag_around_overlaps_arranges_them_and_shift_drags_a_rectangle() { + let mut s = session(); + intertwined(&mut s); + // A loop around the bottom crossing. + press(&mut s, &[(40.0, 60.0), (60.0, 60.0), (60.0, 80.0), (40.0, 80.0), (40.0, 61.0)], false); + let r = render(&s); + assert!(blue(&r, 50, 30) && red(&r, 50, 70)); + // A rectangle around both. + press(&mut s, &[(40.0, 20.0), (50.0, 50.0), (60.0, 80.0)], true); + let r = render(&s); + assert!(red(&r, 50, 30) && blue(&r, 50, 70)); +} + +#[test] +fn edit_chooses_the_tool_for_the_group_of_a_selected_member() { + let mut s = session(); + let (g, a, _) = intertwined(&mut s); + s.select_tool("selection", ViewInfo::default()).unwrap(); + select(&mut s, &[a]); + let v = s.execute("object.intertwine.edit", &json!({})).unwrap(); + assert_eq!(v["id"], g.0); + assert_eq!(s.tool_id(), "intertwine"); + assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [g]); +} diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index b835974a4..1a0f8e633 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -13,6 +13,7 @@ vectorcraft-geom = { workspace = true } vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } vectorcraft-pathops = { workspace = true } +vectorcraft-effects = { workspace = true } vectorcraft-text = { workspace = true } kurbo = { workspace = true } serde = { workspace = true } diff --git a/crates/tools/src/intertwine.rs b/crates/tools/src/intertwine.rs new file mode 100644 index 000000000..984b272e6 --- /dev/null +++ b/crates/tools/src/intertwine.rs @@ -0,0 +1,343 @@ +//! Object › Intertwine (#1763): the overlap areas of an Intertwine group, and the tool that +//! arranges them on the canvas. +//! +//! An overlap area is a face of the planar arrangement of what the group's members paint (their +//! fills and strokes, type outlined; other objects by their bounds): a part where two or more of +//! them overlap, split wherever a member's edge crosses it. `object.intertwine.arrange` changes +//! 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 +//! 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: +//! . + +use std::sync::Arc; + +use kurbo::BezPath; +use serde_json::{Value, json}; +use vectorcraft_doc::intertwine::over_area; +use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind}; +use vectorcraft_effects as effects; +use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath, shapes}; +use vectorcraft_pathops as po; + +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +/// 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 (clip groups inside their +/// clipping path); other objects by their bounds. Effects are left out. `None` when it paints +/// nothing. +pub fn painted_area(n: &Node) -> Option { + if !n.visible { + return None; + } + let shape = match &n.kind { + NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => return None, + NodeKind::Path { path, rule, .. } => 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::Group { children, clip: true } => { + 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, + }; + let mut parts: Vec = vec![]; + match shape { + Some((path, rule)) => { + if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !f.paint.is_none())) { + 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. +pub const SLIVER: f64 = 1e-3; + +/// `p`, unless it is empty or a sliver. +pub fn nonempty(p: PathData) -> Option { + (!p.is_empty() && po::area(&p, FillRule::NonZero).abs() > SLIVER).then_some(p) +} + +/// Does area `a` (filled non-zero) contain `p`? +pub fn contains(a: &PathData, p: Point) -> bool { + vectorcraft_geom::hit::fill_contains(&a.to_bezpath(), FillRule::NonZero, p) +} + +/// One overlap area: its outline, a point inside it, and the members (child indices, bottom +/// first) that paint there. +#[derive(Clone, Debug)] +pub struct Face { + pub path: PathData, + pub inside: Point, + pub members: Vec, +} + +/// Every overlap area of `children`. +pub fn faces(children: &[Arc]) -> 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 = po::interior_point(&r.path)?; + Some(Face { members: r.sources.iter().filter_map(|k| key(*k)).collect(), path: r.path, inside }) + }) + .collect() +} + +/// The overlap area of `faces` under `p`, with `p` as its inside point (a click picks it). +pub fn face_at(faces: &[Face], p: Point) -> Option { + faces.iter().find(|f| contains(&f.path, p)).map(|f| Face { inside: p, ..f.clone() }) +} + +/// The overlap areas of `faces` whose inside `lasso` (a closed path) encloses. +pub fn faces_in(faces: &[Face], lasso: &PathData) -> Vec { + faces.iter().filter(|f| contains(lasso, f.inside)).cloned().collect() +} + +/// The members of `face` in the order they are drawn there, bottom first: those drawn in their +/// own order, then those drawn over the others there, each run in the group's order. +pub fn drawn_order(children: &[Arc], face: &Face) -> Vec { + let over = |i: &usize| children.get(*i).and_then(|c| over_area(c)).is_some_and(|a| contains(a, face.inside)); + let mut order = face.members.clone(); + order.sort_by_key(|i| (over(i), *i)); + order +} + +/// 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 { + selected.iter().find_map(|id| doc.ancestry(*id)?.into_iter().rev().find(|a| doc.node(*a).is_some_and(is_intertwine))) +} + +pub fn is_intertwine(n: &Node) -> bool { + matches!(n.kind, NodeKind::Intertwine { .. }) +} + +// ---------- the tool ---------- + +/// The overlap areas of an Intertwine group, kept for the document revision they were found in. +struct Found { + revision: (u64, u64), + group: NodeId, + faces: Arc>, +} + +/// The Intertwine tool (not in the toolbar: Make and Edit choose it). +#[derive(Default)] +pub struct IntertwineTool { + /// The tool to go back to (Esc, Enter). + prev: Option, + /// The overlap areas of the group, for the document revision they were found in. + cache: Option, + /// The overlap area under the pointer. + hover: Option, + /// A drag's points (document space); the first is where it started. + points: Vec, + /// The drag draws a rectangle (Shift at its start). + rect: bool, + dragging: bool, +} + +impl IntertwineTool { + fn faces(&mut self, cx: &ToolContext) -> Option<(NodeId, Arc>)> { + 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())); + } + 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)) + } + + /// The closed path the drag drew. + fn lasso(&self) -> Option { + let (first, last) = (*self.points.first()?, *self.points.last()?); + let pts = if self.rect { + let r = Rect::from_points(first, last); + vec![Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] + } else { + self.points.clone() + }; + (pts.len() >= 3).then(|| PathData::single(SubPath::polyline(&pts, true))) + } + + fn back(&self) -> Vec { + vec![Action::SwitchTool(self.prev.clone().unwrap_or_else(|| "selection".into()))] + } +} + +impl Tool for IntertwineTool { + fn id(&self) -> &'static str { + "intertwine" + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + 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()); + vec![] + } + PointerKind::Down => { + self.dragging = true; + self.rect = ev.mods.shift; + self.points = vec![p]; + vec![] + } + PointerKind::Drag if self.dragging => { + if self.points.last().is_none_or(|q| q.distance(p) >= cx.tol(2.0)) { + self.points.push(p); + } + vec![] + } + PointerKind::Up if self.dragging => { + self.dragging = false; + let start = self.points.first().copied().unwrap_or(p); + self.points.push(p); + let Some((g, faces)) = self.faces(cx) else { return vec![] }; + // A drag that never left the press is a click (a closed loop ends where it began). + let click = self.points.iter().all(|q| q.distance(start) < cx.tol(3.0)); + let lasso = if click { None } else { self.lasso() }; + self.points.clear(); + let params = match lasso { + // Only where there is something to arrange: a miss changes nothing. + Some(l) if !faces_in(&faces, &l).is_empty() => { + let area: Vec = l.subpaths.iter().flat_map(|s| s.anchors.iter()).map(|a| json!([a.p.x, a.p.y])).collect(); + json!({ "id": g.0, "area": area }) + } + None if face_at(&faces, p).is_some() => json!({ "id": g.0, "at": [p.x, p.y] }), + _ => return vec![], + }; + self.hover = None; + vec![Action::Exec("object.intertwine.arrange".into(), params)] + } + _ => vec![], + } + } + + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + match key { + ToolKey::Escape | ToolKey::Enter if !self.dragging => self.back(), + ToolKey::Escape => { + self.dragging = false; + self.points.clear(); + vec![] + } + _ => vec![], + } + } + + fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { + matches!(key, ToolKey::Escape | ToolKey::Enter) + } + + fn overlays(&self, _cx: &ToolContext) -> Vec { + let color = crate::builder::HIGHLIGHT_RED; + let mut out = vec![]; + if self.dragging && self.points.len() > 1 { + if let Some(l) = self.lasso() { + out.push(Overlay::Path { path: l.to_bezpath(), color, width: 1.0, dashed: true }); + } + } else if let Some(h) = &self.hover { + out.push(Overlay::Path { path: h.clone(), color, width: 2.0, dashed: false }); + } + out + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { + Cursor::Arrow + } + + fn options(&self) -> Value { + json!({ "prev": self.prev }) + } + + fn set_option(&mut self, key: &str, value: &Value) { + if key == "prev" { + self.prev = value.as_str().filter(|t| *t != "intertwine").map(str::to_string); + } + } + + fn busy(&self) -> bool { + self.dragging + } +} + +pub fn create(id: &str) -> Option> { + (id == "intertwine").then(|| Box::new(IntertwineTool::default()) as Box) +} + +#[cfg(test)] +mod tests { + use vectorcraft_doc::{Appearance, NodeId}; + + 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)), Appearance::default_art())) + } + + #[test] + fn two_overlapping_squares_have_one_overlap_area() { + let kids = vec![square(1, 0.0), square(2, 5.0), square(3, 40.0)]; + let f = faces(&kids); + assert_eq!(f.len(), 1); + assert_eq!(f[0].members, [0, 1]); + let at = face_at(&f, Point::new(7.0, 5.0)).unwrap(); + assert_eq!(at.inside, Point::new(7.0, 5.0)); + assert!(face_at(&f, Point::new(2.0, 5.0)).is_none(), "one square alone is no overlap"); + assert_eq!(drawn_order(&kids, &at), [0, 1]); + } + + #[test] + fn a_member_with_an_area_there_is_drawn_last() { + 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 f = faces(&kids); + assert_eq!(drawn_order(&kids, &f[0]), [1, 0]); + } + + #[test] + fn a_lasso_takes_the_areas_whose_inside_it_encloses() { + let kids = vec![square(1, 0.0), square(2, 5.0)]; + let f = faces(&kids); + let around = |r: Rect| shapes::rectangle(r); + assert_eq!(faces_in(&f, &around(Rect::new(4.0, -1.0, 11.0, 11.0))).len(), 1); + assert!(faces_in(&f, &around(Rect::new(20.0, 0.0, 30.0, 10.0))).is_empty()); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 4bd12017c..8d1651136 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -19,6 +19,7 @@ pub mod distort; pub mod draw2; pub mod extra; pub mod guides; +pub mod intertwine; pub mod meshblend; pub mod meshedit; pub mod params; @@ -622,6 +623,7 @@ pub fn create(id: &str) -> Box { .or_else(|| printtiling::create(other)) .or_else(|| cropimage::create(other)) .or_else(|| cut::create(other)) + .or_else(|| intertwine::create(other)) .unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))), } } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a48c8d825..db1e557fd 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -425,6 +425,8 @@ Add Přidat @id pathfinder.add Sjednotit Intersect Protnout + Intertwine Proplést + Arrange Intertwine Area Uspořádat oblast propletení Exclude Vyloučit Subtract Odečíst Minus Back Odečíst zadní @@ -4197,6 +4199,7 @@ imageTrace Outline Obrys @msg select a gradient mesh vyberte přechodovou síť @msg select a linked image: an embedded one has no file vyberte propojený obrázek: vložený nemá soubor @msg select a repeat vyberte opakování +@msg select an Intertwine group vyberte skupinu propletení @msg select a stroke with a variable width vyberte tah s proměnnou šířkou @msg select a symbol instance vyberte instanci symbolu @msg select an Image Trace object vyberte objekt vektorizace obrazu diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6519dfd68..fab4e7659 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1357,6 +1357,8 @@ Internal CSS Internes CSS Internet Standard RGB (sRGB) Internet-Standard-RGB (sRGB) Intersect Schnittmenge bilden + Intertwine Verflechten + Arrange Intertwine Area Überlappungsbereich verflechten Inverse Auswahl umkehren Invert Umkehren Invert Colors Farben umkehren @@ -3806,6 +3808,7 @@ theme Appearance Erscheinungsbild @msg select a gradient mesh wählen Sie ein Verlaufsgitter aus @msg select a linked image: an embedded one has no file wählen Sie ein verknüpftes Bild aus: Ein eingebettetes hat keine Datei @msg select a repeat wählen Sie eine Wiederholung aus +@msg select an Intertwine group wählen Sie eine Verflechtungsgruppe aus @msg select a stroke with a variable width wählen Sie eine Kontur mit variabler Breite aus @msg select a symbol instance wählen Sie eine Symbolinstanz aus @msg select an Image Trace object wählen Sie ein Bildnachzeichner-Objekt aus diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9e26c6eb7..fe33bd84c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1375,6 +1375,8 @@ Internal CSS CSS interno Internet Standard RGB (sRGB) RGB estándar de Internet (sRGB) Intersect Intersecar + Intertwine Entrelazar + Arrange Intertwine Area Organizar área entrelazada Inverse Inversa Invert Invertir Invert Colors Invertir colores @@ -3902,6 +3904,7 @@ axis Both Ambos @msg select a gradient mesh selecciona una malla de degradado @msg select a linked image: an embedded one has no file selecciona una imagen vinculada: una incrustada no tiene archivo @msg select a repeat selecciona una repetición +@msg select an Intertwine group selecciona un grupo entrelazado @msg select a stroke with a variable width selecciona un trazo con anchura variable @msg select a symbol instance selecciona una instancia de símbolo @msg select an Image Trace object selecciona un objeto de calco de imagen diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 9033ac224..53e4b0c44 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1374,6 +1374,8 @@ Internal CSS CSS interne Internet Standard RGB (sRGB) RVB standard Internet (sRGB) Intersect Intersection + Intertwine Entrelacer + Arrange Intertwine Area Disposer la zone entrelacée Inverse Inverser la sélection Invert Inverser Invert Colors Inverser les couleurs @@ -3899,6 +3901,7 @@ axis Both Les deux @msg select a gradient mesh sélectionnez un filet de dégradé @msg select a linked image: an embedded one has no file sélectionnez une image liée : une image incorporée n’a pas de fichier @msg select a repeat sélectionnez une répétition +@msg select an Intertwine group sélectionnez un groupe entrelacé @msg select a stroke with a variable width sélectionnez un contour de largeur variable @msg select a symbol instance sélectionnez une instance de symbole @msg select an Image Trace object sélectionnez un objet de vectorisation de l’image diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 64345726f..5b6bc4443 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1746,6 +1746,8 @@ Internet Standard RGB (sRGB) Internetes szabványos RGB (sRGB) Interpolation Interpoláció Intersect Metszet + Intertwine Összefonás + Arrange Intertwine Area Összefonási terület elrendezése Inverse Kijelölés megfordítása Invert Invertálás Invert Colors Színek invertálása @@ -4549,6 +4551,7 @@ imageTrace Outline Körvonal @msg select a linked image: an embedded one has no file jelölj ki egy csatolt képet: a beágyazottnak nincs fájlja @msg select a Live Paint group jelölj ki egy élőfestés-csoportot @msg select a repeat jelölj ki egy ismétlést +@msg select an Intertwine group jelölj ki egy összefonási csoportot @msg select a stroke with a variable width jelölj ki egy változó szélességű körvonalat @msg select a symbol instance jelölj ki egy szimbólumpéldányt @msg select an envelope jelölj ki egy burkolót diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 2c494658e..9e826d931 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1375,6 +1375,8 @@ Internal CSS CSS interno Internet Standard RGB (sRGB) RGB standard Internet (sRGB) Intersect Intersezione + Intertwine Intreccia + Arrange Intertwine Area Disponi area intrecciata Inverse Inversa Invert Inverti Invert Colors Inverti colori @@ -3905,6 +3907,7 @@ axis Both Entrambi @msg select a gradient mesh seleziona una trama sfumatura @msg select a linked image: an embedded one has no file seleziona un'immagine collegata: un'immagine incorporata non ha un file @msg select a repeat seleziona una ripetizione +@msg select an Intertwine group seleziona un gruppo intrecciato @msg select a stroke with a variable width seleziona una traccia con larghezza variabile @msg select a symbol instance seleziona un'istanza di simbolo @msg select an Image Trace object seleziona un oggetto di ricalco immagine diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8f3a0f9a2..04864c5b4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -426,6 +426,8 @@ Pathfinder パスファインダー Add 追加 Intersect 交差 + Intertwine 織り交ぜ + Arrange Intertwine Area 織り交ぜ領域の重ね順 Exclude 中マド Subtract 前面オブジェクトで型抜き Minus Back 背面オブジェクトで型抜き @@ -3750,6 +3752,7 @@ theme Appearance 外観 @msg not in pattern editing mode パターン編集モードではありません @msg pattern missing パターンが見つかりません @msg select a repeat リピートを選択してください +@msg select an Intertwine group 織り交ぜグループを選択してください @msg select the art to repeat リピートするアートを選択してください @msg the document has no patterns ドキュメントにパターンがありません @msg no artboard アートボードがありません diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c83d9f868..baf360047 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -422,6 +422,8 @@ Pathfinder 패스파인더 Add 더하기 Intersect 교차 + Intertwine 교차 + Arrange Intertwine Area 교차 영역 정렬 Exclude 제외 Subtract 빼기 Minus Back 뒤 개체 빼기 @@ -3728,6 +3730,7 @@ theme Appearance 외관 @msg not in pattern editing mode 패턴 편집 모드가 아닙니다 @msg pattern missing 패턴이 없습니다 @msg select a repeat 반복을 선택하세요 +@msg select an Intertwine group 교차 그룹을 선택하세요 @msg select the art to repeat 반복할 아트웍을 선택하세요 @msg the document has no patterns 문서에 패턴이 없습니다 @msg no artboard 아트보드가 없습니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5708c70a8..4d1d2fdf1 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -385,6 +385,8 @@ Add Dodaj @id pathfinder.add Połącz Intersect Przetnij + Intertwine Przeplatanie + Arrange Intertwine Area Ułóż obszar przeplotu Exclude Wyklucz Subtract Odejmij Minus Back Odejmij tył @@ -4014,6 +4016,7 @@ imageTrace Outline Kontur @msg select a gradient mesh zaznacz siatkę gradientową @msg select a linked image: an embedded one has no file zaznacz obraz połączony: osadzony nie ma pliku @msg select a repeat zaznacz powtórzenie +@msg select an Intertwine group zaznacz grupę przeplotu @msg select a stroke with a variable width zaznacz obrys o zmiennej szerokości @msg select a symbol instance zaznacz wystąpienie symbolu @msg select an Image Trace object zaznacz obiekt obrysu obrazu diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 80e75ece1..b8ed4f1cf 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1175,6 +1175,8 @@ Interlaced Entrelaçado Internet Standard RGB (sRGB) RGB Padrão da Internet (sRGB) Intersect Interseção + Intertwine Entrelaçar + Arrange Intertwine Area Organizar área entrelaçada Inverse Inverso Invert Inverter Invert Colors Inverter Cores @@ -3496,6 +3498,7 @@ axis Both Ambos @msg select a gradient mesh selecione uma malha de gradiente @msg select a linked image: an embedded one has no file selecione uma imagem vinculada: uma incorporada não tem arquivo @msg select a repeat selecione uma repetição +@msg select an Intertwine group selecione um grupo entrelaçado @msg select a stroke with a variable width selecione um traço com largura variável @msg select a symbol instance selecione uma instância de símbolo @msg select an Image Trace object selecione um objeto de Vetorização de imagem diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 016b18152..832607f8e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1373,6 +1373,8 @@ Internal CSS Внутренний CSS Internet Standard RGB (sRGB) Стандартный интернет-RGB (sRGB) Intersect Пересечение + Intertwine Переплетение + Arrange Intertwine Area Упорядочить область переплетения Inverse Обратный Invert Инвертировать Invert Colors Инвертировать цвета @@ -3901,6 +3903,7 @@ axis Both Оба @msg select a gradient mesh выделите градиентную сетку @msg select a linked image: an embedded one has no file выделите связанное изображение: у внедрённого нет файла @msg select a repeat выделите повтор +@msg select an Intertwine group выделите группу переплетения @msg select a stroke with a variable width выделите обводку с переменной шириной @msg select a symbol instance выделите экземпляр символа @msg select an Image Trace object выделите объект трассировки изображения diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9c19ef65d..8beba6473 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1369,6 +1369,8 @@ Internal CSS Внутрішній CSS Internet Standard RGB (sRGB) Стандартний інтернет-RGB (sRGB) Intersect Перетин + Intertwine Переплетення + Arrange Intertwine Area Упорядкувати ділянку переплетення Inverse Обернене Invert Інвертувати Invert Colors Інвертувати кольори @@ -3889,6 +3891,7 @@ axis Both Обидві @msg select a gradient mesh виділіть градієнтну сітку @msg select a linked image: an embedded one has no file виділіть пов’язане зображення: у вбудованого немає файлу @msg select a repeat виділіть повтор +@msg select an Intertwine group виділіть групу переплетення @msg select a stroke with a variable width виділіть обведення зі змінною шириною @msg select a symbol instance виділіть екземпляр символу @msg select an Image Trace object виділіть об’єкт трасування зображення diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 914d41644..e7139df4b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1608,6 +1608,8 @@ Internet Standard RGB (sRGB) RGB chuẩn Internet (sRGB) Interpolation Nội suy Intersect Giao nhau + Intertwine Đan xen + Arrange Intertwine Area Sắp xếp vùng đan xen Inverse Đảo ngược Invert Đảo ngược Invert Colors Đảo màu @@ -4182,6 +4184,7 @@ theme Appearance Giao diện @msg select a linked image: an embedded one has no file hãy chọn ảnh liên kết: ảnh nhúng không có tệp @msg select a Live Paint group hãy chọn nhóm tô trực tiếp @msg select a repeat hãy chọn một đối tượng lặp +@msg select an Intertwine group hãy chọn một nhóm đan xen @msg select a stroke with a variable width hãy chọn nét có chiều rộng biến đổi @msg select a symbol instance hãy chọn đối tượng ký hiệu @msg select an envelope hãy chọn một Envelope diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 61f53348e..3d28d1e97 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1316,6 +1316,8 @@ Internal CSS 内部 CSS Internet Standard RGB (sRGB) 互联网标准 RGB (sRGB) Intersect 交集 + Intertwine 交织 + Arrange Intertwine Area 排列交织区域 Inverse 反向 Invert 反相 Invert Colors 反相颜色 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2d572a38a..8cf5dbd58 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1346,6 +1346,8 @@ Internal CSS 內部 CSS Internet Standard RGB (sRGB) 網際網路標準 RGB (sRGB) Intersect 交集 + Intertwine 交織 + Arrange Intertwine Area 排列交織區域 Inverse 反轉 Invert 反轉 Invert Colors 反轉顏色 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 310b00437..8b897e42a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2377,6 +2377,10 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Options…", "object.repeat.options"), ], ), + sub( + "Intertwine", + vec![c("Make", "object.intertwine.make"), c("Edit", "object.intertwine.edit"), c("Release", "object.intertwine.release")], + ), sub( "Blend", vec![ diff --git a/docs/gaps.md b/docs/gaps.md index 56a443b1e..f493d5911 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (G13: Object › Intertwine landed, #1763; no entry closed, estimates unchanged; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged.; G1: Curvature curve geometry, #1236 in part; Width-point finite-arithmetic follow-up validated; scores and hours unchanged; Image Trace adjustment discovery; G9: legacy `.ai` of version 7 saved with type as text and hidden objects kept, legacy type read, #868; estimate unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make @@ -207,7 +207,13 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** TIFF choices and format settings in Export for Screens and Asset Export (30.2), using the existing encoder; UI-to-file regressions cover selection, scaling, options and persistence. -- **Missing (asked for by users, from earlier versions):** Object › Intertwine (#1763); Object › Create Gradient +- **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 + dragging them in the Layers panel is untested. +- **Missing (asked for by users, from earlier versions):** Object › Create Gradient Mesh on an embedded image, which turns it into a mesh of its colours (#1676); radial gradients in Image Trace's Gradients option, which traces linear ones only (draft #1849). Inside the estimate below. - **Missing:** gradient dither and perceptual blending, gradient presets (30.0); tangent and perpendicular @@ -304,6 +310,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | G13: Object › Intertwine (#1763): Make, Edit, Release, the Intertwine tool (hover, click, drawn path) and `object.intertwine.arrange` (toggle, front, back). Bring Forward / Send Backward stay missing; adding objects through the Layers panel is untested. No entry closed, estimates unchanged | | 2026-10-14 | minor | MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged. | | 2026-10-14 | minor | Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. | | 2026-10-14 | minor | G1: Curvature curve geometry (the Curvature tool's curve: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), and the ends of an open path curve in, so uneven spacing neither overshoots nor loops; #1236, in part); neighbour re-curving on existing paths remains open; estimates unchanged | From 5e75d26d29f28708a3248065f70f2d5ec795866f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 04:26:43 +0700 Subject: [PATCH 3/4] i18n: Bulgarian rows for Intertwine (#2095) --- crates/ui-egui/src/i18n/bg.tsv | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 245619256..667b56567 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1351,6 +1351,8 @@ Internal CSS Вътрешен CSS Internet Standard RGB (sRGB) Интернет стандартен RGB (sRGB) Intersect Пресечение + Intertwine Преплитане + Arrange Intertwine Area Подреждане на зоната на преплитане Inverse Инвертиране Invert Инвертиране Invert Colors Инвертиране на цветовете @@ -3829,6 +3831,7 @@ axis Both И двете @msg select a gradient mesh изберете мрежа на градиент (gradient mesh) @msg select a linked image: an embedded one has no file изберете свързано изображение: вграденото няма файл @msg select a repeat изберете повторение (repeat) +@msg select an Intertwine group изберете група за преплитане (Intertwine) @msg select a stroke with a variable width изберете щрих с променлива дебелина @msg select a symbol instance изберете екземпляр на символ @msg select an Image Trace object изберете обект за трасиране на изображение (Image Trace) From 9eddeeb9529e06aaff63b038cd88fda6615c1d8b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 04:29:34 +0700 Subject: [PATCH 4/4] Legacy .ai export writes Intertwine groups as their art (#2095) --- crates/eps/src/legacy_ai.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 9e62d0f7d..9440b5e86 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -91,7 +91,7 @@ const FREEFORM: &str = "freeform gradients are written as their average colour"; const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, gradients, brushes, arrowheads) are written as filled outlines"; const IMAGE_ALPHA: &str = "transparent image pixels are written over white"; -const LIVE: &str = "symbols, blends, envelopes, meshes, repeats and placed documents are written as their art"; +const LIVE: &str = "symbols, blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art"; const UNREADABLE_IMAGE: &str = "an image that can't be decoded or is too large was left out"; const ELLIPTICAL: &str = "elliptical radial gradients are written circular"; @@ -605,7 +605,12 @@ impl<'a> Writer<'a> { self.nest -= 1; } } - NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => { + NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => { self.warn(LIVE); if self.nest < MAX_NEST { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);