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/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..a562fa3ff 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,23 @@ 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 (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 /// 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..566cb7b20 --- /dev/null +++ b/crates/engine/src/cmd/intertwine.rs @@ -0,0 +1,333 @@ +//! 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::{Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{FillRule, PathData, Point, SubPath}; +use vectorcraft_pathops as po; +use vectorcraft_tools::intertwine::{Face, drawn_order, face_at, faces, faces_in, is_intertwine, nonempty}; + +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 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", + ["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: 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 + ), + ] +} + +// ---------- 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 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> { + 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; + 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) + })?; + 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| { + 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 most points a drawn `area` may have (a long freehand loop has a few thousand). +const MAX_AREA_POINTS: usize = 100_000; + +/// A closed polygon from `[[x,y]…]` (three to [`MAX_AREA_POINTS`] points, finite). +fn polygon(v: &Value) -> Option { + 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))) +} + +/// `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 3 to 100000 [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 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, + 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.iter().rev().nth(1).copied()), true), + (Order::Back, m) => (m.or(drawn.last().copied()), false), + }; + let Some(x) = x else { continue }; + if let Some(top) = arrange_face(children, face, x, front) + && let Some(c) = children.get(top) + { + tops.push(c.id.0); + } + } + 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..734027d74 --- /dev/null +++ b/crates/engine/src/tests_intertwine.rs @@ -0,0 +1,270 @@ +//! 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.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"); +} + +#[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("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]); + 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}"); +} + +// ---------- 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/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); 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); 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/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) 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 |