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