diff --git a/ASSETS.md b/ASSETS.md index e4a17c764..b6f5fa007 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -115,6 +115,7 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/dc-center-show.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-clear.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-color-guide.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-contour.svg` | VectorCraft contributors | Original work, drawn for VectorCraft (a solid rounded square inside four corner arcs, its offset outline); not derived from any other application's icon | MIT OR Apache-2.0 | | | `assets/icons/dc-crystallize.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-cube.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-dash-align.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | diff --git a/ROADMAP.md b/ROADMAP.md index e055747f1..4115c31c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # VectorCraft Roadmap +- Contour add-on (beyond Illustrator): drag a selected shape's edge outward or inward for a live, editable offset copy, with a floating bar for distance and Sharp, Round or Square corners; Bake turns it into paths. Scores and estimates unchanged. + - Paint: new gradients use the document's color model for default stops, including Gradient tool creation. Scores and estimates unchanged. - PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document. @@ -11,7 +13,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Type Tools add-on: Release Type on a Path, #5532; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON diff --git a/assets/icons/dc-contour.svg b/assets/icons/dc-contour.svg new file mode 100644 index 000000000..61c1f2ed8 --- /dev/null +++ b/assets/icons/dc-contour.svg @@ -0,0 +1 @@ + diff --git a/crates/engine/src/cmd/addons.rs b/crates/engine/src/cmd/addons.rs index ac9597cbd..15ba1dad4 100644 --- a/crates/engine/src/cmd/addons.rs +++ b/crates/engine/src/cmd/addons.rs @@ -4,6 +4,7 @@ mod arrange; mod cleanup; mod color; +mod contour; mod document; mod pathtools; mod pen; @@ -17,6 +18,7 @@ pub fn specs() -> Vec { let mut v = arrange::specs(); v.extend(cleanup::specs()); v.extend(color::specs()); + v.extend(contour::specs()); v.extend(document::specs()); v.extend(pathtools::specs()); v.extend(pen::specs()); diff --git a/crates/engine/src/cmd/addons/contour.rs b/crates/engine/src/cmd/addons/contour.rs new file mode 100644 index 000000000..46df381ed --- /dev/null +++ b/crates/engine/src/cmd/addons/contour.rs @@ -0,0 +1,85 @@ +//! Contour (`addon.contour.*`), a built-in add-on: the Contour tool's command. A Contour is a copy of a selected shape that +//! carries a live Offset Path effect, so it grows or shrinks the shape without baking anything: its +//! fill, stroke and effect stay editable, and Expand Appearance bakes it into plain paths. The +//! original stays where it is. Running the command on an object that already carries an Offset +//! Path effect (a Contour made earlier) changes that effect instead of nesting another one. + +#[cfg(test)] +mod tests; + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; +use vectorcraft_render::effects; + +use super::super::edit::{copy_above, selected_roots}; +use super::super::pathops::{len_param, num_param}; +use super::super::{CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, has_selection, str_param}; + +const C: &str = "addon.contour.set"; +/// The live effect that does the offsetting (the one Effect › Path › Offset Path applies). +const EFFECT: &str = "path.offsetPath"; +/// The largest offset, in points; Offset Path takes the same. +const MAX_OFFSET: f64 = 1e5; + +pub fn specs() -> Vec { + vec![cmd!( + "addon.contour.set", + "Contour", + [], + None, + "{offset: pt (negative insets), corners?: \"sharp\"|\"round\"|\"square\" (default sharp; square cuts each corner off flat), miterLimit?: 1..500 (4; sharp corners only)} make a copy of each selected path or shape directly above it with a live Offset Path effect, or, for a selected object that already has one (a Contour), change that effect: nothing is baked (Expand Appearance bakes it) and the original is kept → {ids, created, updated}", + has_selection, + contour + )] +} + +/// The Offset Path join a Contour corner style stands for. +fn joins_for(corners: Option<&str>) -> Result<&'static str> { + match corners.map(str::to_ascii_lowercase).as_deref() { + None | Some("sharp") => Ok("miter"), + Some("round") => Ok("round"), + Some("square") => Ok("bevel"), + Some(other) => Err(bad(C, format!("`corners` must be sharp, round or square, not `{other}`"))), + } +} + +fn contour(s: &mut Session, p: &Value) -> Result { + let offset = len_param(p, "offset").ok_or_else(|| bad(C, "missing `offset` (a distance in points)"))?; + if offset.abs() > MAX_OFFSET { + return Err(bad(C, "offset out of range")); + } + let joins = joins_for(str_param(p, "corners"))?; + let miter = num_param(p, "miterLimit").unwrap_or(4.0).clamp(1.0, 500.0); + let effect = effects::new_effect(EFFECT, &json!({"offset": offset, "joins": joins, "miterLimit": miter})) + .ok_or_else(|| EngineError::Other("Contour: the Offset Path effect is not available".into()))?; + let roots = selected_roots(s)?; + let (updated, created) = s.edit("Contour", |d, sel| { + // A shape that already carries Offset Path is a Contour: change it. Any other path or + // shape gets a Contour copy above it. Type, images and the rest are left alone. + let (mut again, mut fresh) = (vec![], vec![]); + for id in roots { + let Some(n) = d.node(id) else { continue }; + if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) { + continue; + } + if n.appearance.effects.iter().any(|e| e.id == EFFECT) { + again.push(id); + } else { + fresh.push(id); + } + } + if again.is_empty() && fresh.is_empty() { + return Err(EngineError::Other("Contour: select paths or shapes".into())); + } + for id in &again { + if let Some(e) = d.node_mut(*id).and_then(|n| n.appearance.effects.iter_mut().rev().find(|e| e.id == EFFECT)) { + e.params = effect.params.clone(); + } + } + let made = copy_above(d, &fresh, |_, _, copy| copy.appearance.effects.push(effect.clone()))?; + sel.set(again.iter().chain(made.iter()).copied()); + Ok((again, made)) + })?; + let list = |ids: &[NodeId]| ids.iter().map(|i| i.0).collect::>(); + Ok(json!({ "ids": list(&[updated.as_slice(), created.as_slice()].concat()), "created": list(&created), "updated": list(&updated) })) +} diff --git a/crates/engine/src/cmd/addons/contour/tests.rs b/crates/engine/src/cmd/addons/contour/tests.rs new file mode 100644 index 000000000..900d110d5 --- /dev/null +++ b/crates/engine/src/cmd/addons/contour/tests.rs @@ -0,0 +1,371 @@ +//! Tests for `addon.contour.set`: a live Offset Path copy of a shape that stays editable until baked. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; +use vectorcraft_geom::Rect; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn ids_of(v: &Value, key: &str) -> Vec { + v[key].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect() +} + +fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +fn effects_of(s: &Session, id: NodeId) -> Vec<(String, Value)> { + node(s, id).appearance.effects.iter().map(|e| (e.id.clone(), e.params.clone())).collect() +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + node(s, id).geometric_bounds().unwrap() +} + +fn node_count(s: &Session) -> usize { + s.doc().unwrap().doc.node_count() +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +#[test] +fn contour_is_a_live_copy_above_the_original() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let before = bounds(&s, a); + let r = s.execute("addon.contour.set", &json!({"offset": 10})).unwrap(); + let (made, updated) = (ids_of(&r, "created"), ids_of(&r, "updated")); + assert_eq!((made.len(), updated.len()), (1, 0)); + // The original is untouched, the copy is above it and selected, and nothing is baked: the + // copy's own path is still the original's, with the effect on top. + assert!(effects_of(&s, a).is_empty()); + assert_eq!(bounds(&s, a), before); + assert_eq!(s.doc().unwrap().selection.objects, made); + let d = &s.doc().unwrap().doc; + assert!(d.index_path(made[0]).unwrap() > d.index_path(a).unwrap()); + assert_eq!(bounds(&s, made[0]), before); + let fx = effects_of(&s, made[0]); + assert_eq!(fx.len(), 1); + assert_eq!(fx[0].0, "path.offsetPath"); + assert_eq!(fx[0].1["offset"], json!(10.0)); + assert_eq!(fx[0].1["joins"], json!("miter")); +} + +#[test] +fn baking_a_contour_gives_the_grown_and_the_shrunk_shape() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + for (offset, want) in [(10.0, Rect::new(90.0, 90.0, 160.0, 150.0)), (-5.0, Rect::new(105.0, 105.0, 145.0, 135.0))] { + select(&mut s, &[a]); + s.execute("addon.contour.set", &json!({"offset": offset})).unwrap(); + let baked = ids_of(&s.execute("effect.expandAppearance", &json!({})).unwrap(), "ids"); + let b = bounds(&s, baked[0]); + // The default stroke widens the baked art by its weight; compare the fill's edges. + let fill = baked_fill_bounds(&s, baked[0]); + assert!((fill.x0 - want.x0).abs() < 1e-2 && (fill.y1 - want.y1).abs() < 1e-2, "{offset}: {fill:?} vs {want:?} ({b:?})"); + } +} + +/// The bounds of the fill path inside a baked object (a group of its fill and stroke), or of the +/// object itself when it is one path. +fn baked_fill_bounds(s: &Session, id: NodeId) -> Rect { + match &node(s, id).kind { + NodeKind::Group { children, .. } => children.iter().filter_map(|c| c.geometric_bounds()).next().unwrap(), + _ => bounds(s, id), + } +} + +#[test] +fn a_second_drag_changes_the_contour_instead_of_nesting_one() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let made = ids_of(&s.execute("addon.contour.set", &json!({"offset": 10})).unwrap(), "created"); + let count = node_count(&s); + // The Contour is selected: the next call edits it. + let r = s.execute("addon.contour.set", &json!({"offset": -4, "corners": "round"})).unwrap(); + assert_eq!(ids_of(&r, "updated"), made); + assert!(ids_of(&r, "created").is_empty()); + assert_eq!(node_count(&s), count, "no new object"); + let fx = effects_of(&s, made[0]); + assert_eq!(fx.len(), 1, "one Offset Path, not two"); + assert_eq!((fx[0].1["offset"].clone(), fx[0].1["joins"].clone()), (json!(-4.0), json!("round"))); + assert!(effects_of(&s, a).is_empty()); +} + +#[test] +fn corner_styles_choose_the_join() { + for (corners, joins) in [("sharp", "miter"), ("round", "round"), ("square", "bevel"), ("Round", "round")] { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 30.0, 30.0); + let made = ids_of(&s.execute("addon.contour.set", &json!({"offset": 3, "corners": corners})).unwrap(), "created"); + assert_eq!(effects_of(&s, made[0])[0].1["joins"], json!(joins), "{corners}"); + } +} + +#[test] +fn contour_keeps_the_color_and_stroke_editable_on_the_copy() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 30.0, 30.0); + let made = ids_of(&s.execute("addon.contour.set", &json!({"offset": 6})).unwrap(), "created"); + let original = node(&s, a).appearance.items.clone(); + assert_eq!(node(&s, made[0]).appearance.items, original, "the copy starts with the shape's own paint"); + // Recolouring the Contour (it is the selection) changes its fill only: the live effect stays + // and the original keeps its paint. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_ne!(node(&s, made[0]).appearance.items, original, "the copy took the new fill"); + assert_eq!(node(&s, a).appearance.items, original, "the original is untouched"); + assert_eq!(effects_of(&s, made[0]).len(), 1); +} + +#[test] +fn contour_is_one_undo_step() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 30.0, 30.0); + let count = node_count(&s); + s.execute("addon.contour.set", &json!({"offset": 6})).unwrap(); + assert_eq!(node_count(&s), count + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node_count(&s), count); + assert!(effects_of(&s, a).is_empty()); +} + +#[test] +fn contour_refuses_what_it_cannot_offset() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 30.0, 30.0); + let count = node_count(&s); + for params in [json!({}), json!({"offset": "abc"}), json!({"offset": 1e300}), json!({"offset": 5, "corners": "pointy"})] { + assert!(s.execute("addon.contour.set", ¶ms).is_err(), "{params}"); + } + // Nothing was added by the refused calls. + assert_eq!(node_count(&s), count); + // Type is not a path to offset. + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Hi"})).unwrap(); + let err = s.execute("addon.contour.set", &json!({"offset": 5})).unwrap_err().to_string(); + assert!(err.contains("select paths or shapes"), "{err}"); +} + +/// The border-with-a-gap recipe: two Contours of one shape (the gap, then the border), baked, then +/// the gap taken out of the border with Minus Front, leaves a ring between the two. +#[test] +fn border_with_a_gap_from_two_contours_and_minus_front() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + // Strip the default stroke so the areas are plain. + s.execute("paint.setStroke", &json!({"none": true})).ok(); + // The gap first (+8), then the border (+20) from the same shape: both land directly above it, + // the later one lower, so the gap ends up in front of the border. + select(&mut s, &[a]); + let gap = ids_of(&s.execute("addon.contour.set", &json!({"offset": 8})).unwrap(), "created")[0]; + select(&mut s, &[a]); + let border = ids_of(&s.execute("addon.contour.set", &json!({"offset": 20})).unwrap(), "created")[0]; + let d = &s.doc().unwrap().doc; + assert!(d.index_path(gap).unwrap() > d.index_path(border).unwrap(), "the gap is in front of the border"); + select(&mut s, &[border, gap]); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let both: Vec = s.doc().unwrap().selection.objects.to_vec(); + assert_eq!(both.len(), 2); + let ring = ids_of(&s.execute("object.pathfinder.minusFront", &json!({})).unwrap(), "ids"); + assert_eq!(ring.len(), 1); + // 90 x 80 outer minus 66 x 56 inner, with sharp corners. + let area = vectorcraft_pathops::area(&ring_path(&s, ring[0]).0, ring_path(&s, ring[0]).1); + assert!((area - (90.0 * 80.0 - 66.0 * 56.0)).abs() < 1.0, "{area}"); +} + +fn ring_path(s: &Session, id: NodeId) -> (vectorcraft_geom::PathData, vectorcraft_geom::FillRule) { + match &node(s, id).kind { + NodeKind::Path { path, rule, .. } => (path.clone(), *rule), + NodeKind::Compound { children, rule } => { + (vectorcraft_geom::PathData::new(children.iter().flat_map(|c| c.path_data().unwrap().subpaths.clone()).collect()), *rule) + } + NodeKind::Group { children, .. } => children.first().map(|c| ring_path(s, c.id)).unwrap(), + _ => panic!("not a path"), + } +} + +mod tool { + use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + + use super::*; + use crate::tooling::ViewInfo; + + fn view() -> ViewInfo { + ViewInfo { smart_guides: false, ..Default::default() } + } + + fn at(s: &mut Session, kind: PointerKind, x: f64, y: f64, mods: Mods) { + s.pointer(&PointerEvent::new(kind, x, y).with_mods(mods), view()).unwrap(); + } + + fn undo_depth(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() + } + + /// A 50 x 40 rectangle at (100, 100), selected, with the Contour tool active. + fn ready() -> (Session, NodeId) { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + select(&mut s, &[a]); + s.select_tool("contour", view()).unwrap(); + (s, a) + } + + fn offset_of(s: &Session, id: NodeId) -> f64 { + effects_of(s, id)[0].1["offset"].as_f64().unwrap() + } + + #[test] + fn dragging_out_previews_live_and_commits_one_undo_step() { + let (mut s, a) = ready(); + let (before, nodes) = (undo_depth(&s), node_count(&s)); + // Press on the right edge and pull 10 pt away from the shape. + at(&mut s, PointerKind::Down, 150.0, 120.0, Mods::default()); + at(&mut s, PointerKind::Drag, 160.0, 120.0, Mods::default()); + assert!(s.in_interaction(), "the Contour is previewed while the button is down"); + at(&mut s, PointerKind::Up, 160.0, 120.0, Mods::default()); + assert!(!s.in_interaction()); + assert_eq!(undo_depth(&s), before + 1); + assert_eq!(node_count(&s), nodes + 1); + assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("addon.contour.set")); + let made = s.doc().unwrap().selection.objects.clone(); + assert_eq!(made.len(), 1); + assert_ne!(made[0], a); + assert!((offset_of(&s, made[0]) - 10.0).abs() < 1e-6, "{}", offset_of(&s, made[0])); + assert!(effects_of(&s, a).is_empty(), "the original is kept as it was"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node_count(&s), nodes); + } + + #[test] + fn dragging_in_shrinks_and_a_second_drag_continues_from_where_the_contour_stands() { + let (mut s, _) = ready(); + // From inside, 5 pt toward the middle of the right edge's neighbourhood. + at(&mut s, PointerKind::Down, 145.0, 120.0, Mods::default()); + at(&mut s, PointerKind::Drag, 140.0, 120.0, Mods::default()); + at(&mut s, PointerKind::Up, 140.0, 120.0, Mods::default()); + let contour = s.doc().unwrap().selection.objects[0]; + assert!((offset_of(&s, contour) + 5.0).abs() < 1e-6); + // The Contour is selected: pressing at the edge and pulling out 12 pt adds to its -5. + let nodes = node_count(&s); + at(&mut s, PointerKind::Down, 150.0, 120.0, Mods::default()); + at(&mut s, PointerKind::Drag, 162.0, 120.0, Mods::default()); + at(&mut s, PointerKind::Up, 162.0, 120.0, Mods::default()); + assert_eq!(node_count(&s), nodes, "the same Contour changed, none was nested"); + assert_eq!(s.doc().unwrap().selection.objects, vec![contour]); + assert_eq!(effects_of(&s, contour).len(), 1); + assert!((offset_of(&s, contour) - 7.0).abs() < 1e-6, "{}", offset_of(&s, contour)); + } + + #[test] + fn escape_and_a_plain_click_change_nothing() { + let (mut s, _) = ready(); + let (before, nodes) = (undo_depth(&s), node_count(&s)); + at(&mut s, PointerKind::Down, 150.0, 120.0, Mods::default()); + at(&mut s, PointerKind::Drag, 170.0, 120.0, Mods::default()); + assert!(s.in_interaction()); + s.tool_key(ToolKey::Escape, Mods::default(), view()).unwrap(); + at(&mut s, PointerKind::Up, 170.0, 120.0, Mods::default()); + assert!(!s.in_interaction()); + assert_eq!((undo_depth(&s), node_count(&s)), (before, nodes)); + // A click with no movement. + at(&mut s, PointerKind::Down, 150.0, 120.0, Mods::default()); + at(&mut s, PointerKind::Up, 150.0, 120.0, Mods::default()); + assert_eq!((undo_depth(&s), node_count(&s)), (before, nodes)); + } + + #[test] + fn shift_rounds_to_whole_points_and_the_corner_option_is_used() { + let (mut s, _) = ready(); + s.set_tool_option("corners", &json!("round")); + let shift = Mods { shift: true, ..Mods::default() }; + at(&mut s, PointerKind::Down, 150.0, 120.0, shift); + at(&mut s, PointerKind::Drag, 160.4, 120.0, shift); + at(&mut s, PointerKind::Up, 160.4, 120.0, shift); + let contour = s.doc().unwrap().selection.objects[0]; + assert_eq!(offset_of(&s, contour), 10.0); + assert_eq!(effects_of(&s, contour)[0].1["joins"], json!("round")); + } + + /// A Contour keeps its own corner style and miter limit when it is dragged again, whatever the + /// tool's options are by then (they said Sharp when the Contour was made Square). + #[test] + fn dragging_a_contour_again_keeps_its_corners_and_miter_limit() { + let (mut s, _) = ready(); + s.execute("addon.contour.set", &json!({"offset": 5, "corners": "square", "miterLimit": 7})).unwrap(); + let contour = s.doc().unwrap().selection.objects[0]; + s.set_tool_option("corners", &json!("sharp")); + at(&mut s, PointerKind::Down, 150.0, 120.0, Mods::default()); + at(&mut s, PointerKind::Drag, 158.0, 120.0, Mods::default()); + at(&mut s, PointerKind::Up, 158.0, 120.0, Mods::default()); + let fx = effects_of(&s, contour); + assert_eq!(fx.len(), 1); + assert!((offset_of(&s, contour) - 13.0).abs() < 1e-6, "{}", offset_of(&s, contour)); + assert_eq!(fx[0].1["joins"], json!("bevel"), "{:?}", fx[0].1); + assert_eq!(fx[0].1["miterLimit"], json!(7.0), "{:?}", fx[0].1); + } + + /// A drag that never moves the distance (along the edge, or rounded back to nothing by Shift) + /// leaves no unchanged copy and no undo step. + #[test] + fn a_drag_along_the_edge_changes_nothing() { + let (mut s, _) = ready(); + let (before, nodes) = (undo_depth(&s), node_count(&s)); + let shift = Mods { shift: true, ..Mods::default() }; + for (mods, to) in [(Mods::default(), (150.0, 135.0)), (shift, (150.3, 120.0))] { + at(&mut s, PointerKind::Down, 150.0, 120.0, mods); + at(&mut s, PointerKind::Drag, to.0, to.1, mods); + at(&mut s, PointerKind::Up, to.0, to.1, mods); + assert_eq!((undo_depth(&s), node_count(&s)), (before, nodes), "{to:?}"); + assert!(!s.in_interaction()); + } + } + + /// The outline a Contour was made from shows as a dashed guide over a solid halo (so it reads + /// over any fill); a shape that is not a Contour yet has none. + #[test] + fn a_selected_contour_shows_the_outline_it_was_made_from() { + use vectorcraft_tools::Overlay; + let (mut s, _) = ready(); + let paths = |s: &mut Session| -> Vec<(bool, f32)> { + s.overlays(view()) + .into_iter() + .filter_map(|o| match o { + Overlay::Path { dashed, width, .. } => Some((dashed, width)), + _ => None, + }) + .collect() + }; + assert!(paths(&mut s).is_empty(), "a plain shape has no guide"); + at(&mut s, PointerKind::Down, 150.0, 120.0, Mods::default()); + at(&mut s, PointerKind::Drag, 160.0, 120.0, Mods::default()); + at(&mut s, PointerKind::Up, 160.0, 120.0, Mods::default()); + // The solid halo first, the dashes on top of it. + assert_eq!(paths(&mut s), [(false, 3.0), (true, 1.0)]); + } + + #[test] + fn nothing_selected_does_nothing() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 50.0, 40.0); + s.execute("select.none", &json!({})).unwrap(); + s.select_tool("contour", view()).unwrap(); + let before = undo_depth(&s); + at(&mut s, PointerKind::Down, 150.0, 120.0, Mods::default()); + at(&mut s, PointerKind::Drag, 170.0, 120.0, Mods::default()); + at(&mut s, PointerKind::Up, 170.0, 120.0, Mods::default()); + assert_eq!(undo_depth(&s), before); + } +} diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index b958f51c1..2b0e214cc 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -262,7 +262,59 @@ impl Form { /// type, linked files, objects with an opacity mask, clipping paths or guides (the help page lists /// images, text, masks and links as unsupported). fn supported(n: &Node) -> bool { - n.extras().mask.is_none() && Form::of(n).is_some() + n.extras().mask.is_none() && has_form(n) +} + +/// Does [`Form::of`] give `n` a form? The same walk and the same box, without collecting the +/// points or placing them in a unit box. +fn has_form(n: &Node) -> bool { + let mut b = None; + form_box(n, 0, &mut b).is_some() + && b.is_some_and(|b: Rect| { + let side = b.width().max(b.height()); + side.is_finite() && side > 1e-9 + }) +} + +/// [`Form::add`] without the form: `b` grows by each point it would add; `None` when `n` isn't +/// supported. +fn form_box(n: &Node, depth: usize, b: &mut Option) -> Option<()> { + match &n.kind { + NodeKind::Path { path, clipping, guide: false, .. } if !*clipping || depth > 0 => grow_by_path(b, path), + NodeKind::Compound { children, .. } => { + for c in children { + grow_by_path(b, c.path_data()?); + } + } + NodeKind::SymbolInstance { xf, .. } => { + for p in [*xf * Point::ZERO, *xf * Point::new(1.0, 0.0), *xf * Point::new(0.0, 1.0)] { + grow(b, p); + } + } + NodeKind::Group { children, .. } if plain_group(n) && depth < vectorcraft_doc::MAX_DEPTH => { + for c in children { + if c.extras().mask.is_some() { + return None; + } + form_box(c, depth + 1, b)?; + } + } + _ => return None, + } + Some(()) +} + +fn grow_by_path(b: &mut Option, path: &PathData) { + for a in path.subpaths.iter().flat_map(|sp| &sp.anchors) { + for p in [a.p, a.h_in, a.h_out] { + grow(b, p); + } + } +} + +/// `b` grown to take in `p`, as [`Form::of`] measures its points. +fn grow(b: &mut Option, p: Point) { + *b = Some(b.unwrap_or(Rect::from_points(p, p)).union_pt(p)); } /// A plain group (a clip group too), not one that is live art (a graph, Shaper group, dimension, @@ -374,8 +426,9 @@ fn can_toggle(s: &Session) -> std::result::Result<(), String> { let [id] = st.selection.objects.as_slice() else { return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into()); }; - // A group's contents are checked when Global Edit starts: building its shape here would walk - // every object in it each time the menu or the Properties panel asks. + // A group's contents are checked when Global Edit starts: measuring them here would walk + // every object in it each time the menu or the Properties panel asks. Other objects are + // measured ([`supported`]), not reduced to their form: the Control bar asks every frame. match st.doc.node(*id) { Some(n) if n.extras().mask.is_none() && plain_group(n) => Ok(()), Some(n) if supported(n) => Ok(()), @@ -750,3 +803,195 @@ pub fn highlights(s: &Session) -> Outlines { *ge.outlines.borrow_mut() = Some((st.revision, out.clone())); out } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use vectorcraft_doc::OpacityMask; + use vectorcraft_geom::{Anchor, SubPath}; + + /// Global Edit's support as it was decided by building the form. + fn by_form(n: &Node) -> bool { + n.extras().mask.is_none() && Form::of(n).is_some() + } + + /// A small seeded generator (xorshift64*), so that a failure repeats. + struct Rng(u64); + + impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) + } + fn below(&mut self, n: u64) -> u64 { + self.next() % n + } + fn unit(&mut self) -> f64 { + (self.next() >> 11) as f64 / (1u64 << 53) as f64 + } + /// A spread for the points of one object: none, below and around the smallest size a form + /// takes, ordinary, or past every bound. + fn spread(&mut self) -> f64 { + [0.0, 1e-10, 5e-10, 1e-9, 2e-9, 1.0, 1.0, 100.0, 100.0, 100.0, f64::INFINITY, f64::NAN][self.below(12) as usize] + } + fn point(&mut self, base: Point, spread: f64) -> Point { + let mut c = |b: f64| match self.below(400) { + 0 => f64::NAN, + 1 => f64::INFINITY, + 2 => f64::NEG_INFINITY, + _ => b + (self.unit() - 0.5) * spread, + }; + Point::new(c(base.x), c(base.y)) + } + } + + fn path_data(r: &mut Rng) -> PathData { + let (base, spread) = (Point::new(r.unit() * 200.0, r.unit() * 200.0), r.spread()); + let subpaths = (0..r.below(3)) + .map(|_| { + let anchors = (0..r.below(4)) + .map(|_| Anchor { p: r.point(base, spread), h_in: r.point(base, spread), h_out: r.point(base, spread), ..Anchor::corner(base) }) + .collect(); + SubPath::new(anchors, r.below(2) == 0) + }) + .collect(); + PathData::new(subpaths) + } + + /// A random object: paths (clipping paths and guides too), compound paths, symbol instances, + /// groups (clip groups, live art, members with an opacity mask), type, blends and intertwined + /// objects, any of them with an opacity mask. + fn object(r: &mut Rng, d: &mut Document, text: &Node, depth: usize) -> Node { + let leaf = depth >= 3; + let mut n = match r.below(if leaf { 4 } else { 8 }) { + 0 | 1 => { + let mut n = Node::path(d.alloc_id(), path_data(r), Appearance::default()); + if let NodeKind::Path { clipping, guide, .. } = &mut n.kind { + *clipping = r.below(6) == 0; + *guide = r.below(8) == 0; + } + n + } + 2 => { + let xf = match r.below(4) { + 0 => Affine::scale(0.0), + 1 => Affine::scale(1e-10), + _ => Affine::translate((r.unit() * 100.0, r.unit() * 100.0)) * Affine::scale(r.spread()), + }; + Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf }) + } + 3 => { + let mut t = text.clone(); + t.id = d.alloc_id(); + t + } + 4 => { + let children = (0..r.below(4)) + .map(|_| match r.below(5) { + 0 => Arc::new(object(r, d, text, depth + 1)), + _ => Arc::new(Node::path(d.alloc_id(), path_data(r), Appearance::default())), + }) + .collect(); + Node::new(d.alloc_id(), NodeKind::Compound { children, rule: vectorcraft_geom::FillRule::NonZero }) + } + 5 => { + let children = (0..r.below(3)).map(|_| Arc::new(object(r, d, text, depth + 1))).collect(); + Node::new(d.alloc_id(), NodeKind::Blend { children, spec: Box::default() }) + } + _ => { + let children = (0..r.below(4)).map(|_| Arc::new(object(r, d, text, depth + 1))).collect(); + let mut g = Node::new(d.alloc_id(), NodeKind::Group { children, clip: r.below(3) == 0 }); + if r.below(8) == 0 { + g.extras_mut().trace = Some(Box::new(Value::Null)); + } + g + } + }; + if r.below(10) == 0 { + n.extras_mut().mask = Some(Box::new(OpacityMask::new(Node::path(d.alloc_id(), path_data(r), Appearance::default()), true))); + } + n + } + + /// `n` and every object in it. + fn every(n: &Node) -> Vec { + let mut out = vec![]; + n.walk(&mut |c| out.push(c.clone())); + out + } + + #[test] + fn support_is_decided_as_building_the_form_decides() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + let t = NodeId(s.execute("text.create", &json!({"x": 50, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap()); + let text = s.doc().unwrap().doc.node(t).unwrap().clone(); + let layer = s.doc().unwrap().doc.layers[0].id; + let (mut checked, mut taken, mut asked) = (0, 0, 0); + for seed in 1..=400u64 { + let mut r = Rng(seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) | 1); + let n = object(&mut r, Arc::make_mut(&mut s.doc_mut().unwrap().doc), &text, 0); + for c in every(&n) { + assert_eq!(supported(&c), by_form(&c), "seed {seed}: {c:?}"); + checked += 1; + taken += usize::from(by_form(&c)); + } + // The enabled check of Start Global Edit on it, and Start Global Edit itself. + let id = Arc::make_mut(&mut s.doc_mut().unwrap().doc).insert(Some(layer), usize::MAX, n.clone()).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + if s.doc().unwrap().selection.objects != [id] { + continue; + } + asked += 1; + let today = n.extras().mask.is_none() && plain_group(&n) || by_form(&n); + assert_eq!(can_toggle(&s).is_ok(), today, "seed {seed}"); + match s.execute(C, &json!({})) { + Ok(_) => { + assert!(by_form(&n), "seed {seed}"); + s.execute(C, &json!({})).unwrap(); + } + Err(e) => assert_eq!(e.to_string().contains(UNSUPPORTED), !by_form(&n), "seed {seed}: {e}"), + } + } + // Both answers came up often, and most objects could be selected alone. + assert!(taken > 100 && checked - taken > 100 && asked > 200, "{taken} of {checked}, {asked} selected"); + } + + #[test] + fn support_of_empty_degenerate_and_deep_objects_is_decided_as_building_the_form_decides() { + let mut d = Document::new(800.0, 600.0); + let corner = |x: f64, y: f64| Anchor::corner(Point::new(x, y)); + let path = |d: &mut Document, subpaths: Vec| Node::path(d.alloc_id(), PathData::new(subpaths), Appearance::default()); + let mut cases = vec![ + path(&mut d, vec![]), + path(&mut d, vec![SubPath::new(vec![], true)]), + path(&mut d, vec![SubPath::new(vec![corner(5.0, 5.0)], false)]), + path(&mut d, vec![SubPath::new(vec![corner(0.0, 0.0), corner(0.0, 1e-9)], false)]), + path(&mut d, vec![SubPath::new(vec![corner(0.0, 0.0), corner(0.0, 2e-9)], false)]), + path(&mut d, vec![SubPath::new(vec![corner(0.0, 0.0), corner(f64::INFINITY, 0.0)], false)]), + path(&mut d, vec![SubPath::new(vec![corner(f64::NAN, 0.0), corner(10.0, 0.0), corner(0.0, 0.0)], false)]), + path(&mut d, vec![SubPath::new(vec![corner(f64::NAN, f64::NAN)], false)]), + Node::new(d.alloc_id(), NodeKind::Compound { children: vec![], rule: Default::default() }), + Node::group(d.alloc_id(), vec![]), + Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::scale(0.0) }), + Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY }), + ]; + // A group as deep as the walk goes, and one level deeper. + let mut deep = path(&mut d, vec![SubPath::new(vec![corner(0.0, 0.0), corner(10.0, 10.0)], false)]); + for _ in 0..=vectorcraft_doc::MAX_DEPTH { + deep = Node::group(d.alloc_id(), vec![Arc::new(deep)]); + cases.push(deep.clone()); + } + let expected = [false, false, false, false, true, false, true, false, false, false, false, true]; + for (i, n) in cases.iter().enumerate() { + assert_eq!(supported(n), by_form(n), "case {i}: {n:?}"); + if let Some(e) = expected.get(i) { + assert_eq!(supported(n), *e, "case {i}"); + } + } + assert!(supported(&cases[cases.len() - 2]) && !supported(&cases[cases.len() - 1])); + } +} diff --git a/crates/tools/src/addons.rs b/crates/tools/src/addons.rs index 42643538e..69bb4858a 100644 --- a/crates/tools/src/addons.rs +++ b/crates/tools/src/addons.rs @@ -3,6 +3,7 @@ //! submenu selects them. Tool gestures emit engine commands. pub mod anchor_eraser; +pub mod contour; pub mod corner_tool; pub mod enclosing_lasso; pub mod extend_path; @@ -28,14 +29,17 @@ pub const PATH_TOOLS: &[ToolInfo] = &[ /// Smart Pen's tool, a flyout group of its own. pub const SMART_PEN: &[ToolInfo] = &[ToolInfo { id: smart_pen::ID, label: "Smart Pen Tool", shortcut: None, icon: "spline-pointer" }]; +pub const CONTOUR: &[ToolInfo] = &[ToolInfo { id: contour::ID, label: "Contour Tool", shortcut: None, icon: "tool-contour" }]; + pub const ENCLOSING_LASSO: &[ToolInfo] = &[ToolInfo { id: enclosing_lasso::ID, label: "Enclosing Lasso Tool", shortcut: None, icon: "tool-lasso" }]; /// The add-ons' tool groups, after the toolbar's own ([`crate::TOOL_GROUPS`]), each with the /// heading the All Tools drawer lists it under (its add-on's name). -const HEADED: &[(&[ToolInfo], &str)] = &[(PATH_TOOLS, "Path Precision Tools"), (SMART_PEN, "Smart Pen"), (ENCLOSING_LASSO, "Enclosing Lasso")]; +const HEADED: &[(&[ToolInfo], &str)] = + &[(PATH_TOOLS, "Path Precision Tools"), (SMART_PEN, "Smart Pen"), (ENCLOSING_LASSO, "Enclosing Lasso"), (CONTOUR, "Contour")]; /// The add-ons' tool groups, after the toolbar's own ([`crate::TOOL_GROUPS`]). -pub const GROUPS: &[&[ToolInfo]] = &[PATH_TOOLS, SMART_PEN, ENCLOSING_LASSO]; +pub const GROUPS: &[&[ToolInfo]] = &[PATH_TOOLS, SMART_PEN, ENCLOSING_LASSO, CONTOUR]; /// The heading the All Tools drawer lists tool `id` under: its add-on's name. pub fn heading(id: &str) -> Option<&'static str> { @@ -54,6 +58,7 @@ pub fn create(id: &str) -> Option> { slide_anchor::ID => Box::new(slide_anchor::SlideAnchorTool::default()), smart_pen::ID => Box::new(smart_pen::SmartPenTool::default()), enclosing_lasso::ID => Box::new(enclosing_lasso::EnclosingLassoTool::default()), + contour::ID => Box::new(contour::ContourTool::default()), _ => return None, }) } @@ -74,6 +79,7 @@ mod tests { assert!(PATH_TOOLS.iter().all(|t| heading(t.id) == Some("Path Precision Tools"))); assert_eq!(heading(smart_pen::ID), Some("Smart Pen")); assert_eq!(heading(enclosing_lasso::ID), Some("Enclosing Lasso")); + assert_eq!(heading(contour::ID), Some("Contour")); assert_eq!(GROUPS.len(), HEADED.len()); assert!(crate::catalog::all_tools().any(|t| t.id == extend_path::ID)); assert!(create("pen").is_none() && heading("pen").is_none()); diff --git a/crates/tools/src/addons/contour.rs b/crates/tools/src/addons/contour.rs new file mode 100644 index 000000000..e77bb29ee --- /dev/null +++ b/crates/tools/src/addons/contour.rs @@ -0,0 +1,254 @@ +//! The Contour tool: drag away from a selected shape's edge to grow a live copy of it, or toward the +//! inside to shrink it (`addon.contour.set`). The copy is the original's outline offset by the distance +//! the pointer has moved from the edge, previewed while the button is down and kept as one undo step +//! on release. Nothing is baked: the copy carries a live Offset Path effect, so it keeps being +//! editable (a second drag on a selected Contour changes its distance instead of nesting another), +//! and Expand Appearance bakes it. While a Contour is selected the tool draws the outline the +//! shape had before as a dashed guide. +//! +//! The distance is how far the pointer has moved from the shape's edge since the press, so a press +//! anywhere changes nothing until the pointer moves, and a Contour that already stands at some +//! distance continues from there. Shift rounds the distance to whole points. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::{BezPath, FillRule, Point, hit}; + +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +/// The tool's id. +pub const ID: &str = "contour"; + +/// The live effect a Contour is made of (the one `addon.contour.set` applies). +const EFFECT: &str = "path.offsetPath"; +/// The largest distance, in points, `addon.contour.set` accepts. +const MAX_OFFSET: f64 = 1e5; +/// The guide showing the shape a Contour was made from. +const GUIDE: [u8; 3] = [0x1e, 0x88, 0xe5]; + +/// A selected shape's outline, in the document. +struct Base { + path: BezPath, + rule: FillRule, +} + +fn base_of(n: &Node) -> Option { + match &n.kind { + NodeKind::Path { path, rule, .. } => Some(Base { path: path.to_bezpath(), rule: *rule }), + NodeKind::Compound { children, rule } => { + let mut path = BezPath::new(); + for c in children { + if let Some(p) = c.path_data() { + path.extend(p.to_bezpath().elements().iter().copied()); + } + } + Some(Base { path, rule: *rule }) + } + _ => None, + } +} + +/// The Offset Path distance of a Contour (a shape that carries the effect). +fn contour_offset(n: &Node) -> Option { + let e = n.appearance.effects.iter().rev().find(|e| e.id == EFFECT)?; + Some(e.params.get("offset").and_then(Value::as_f64).unwrap_or(0.0)) +} + +/// The corner style and miter limit of a Contour's Offset Path effect, as `addon.contour.set` takes +/// them. +fn contour_style(n: &Node) -> Option { + let e = n.appearance.effects.iter().rev().find(|e| e.id == EFFECT)?; + let corners = match e.params.get("joins").and_then(Value::as_str) { + Some("round") => "round", + Some("bevel") => "square", + _ => "sharp", + }; + let mut style = json!({ "corners": corners }); + if let Some(m) = e.params.get("miterLimit").and_then(Value::as_f64) { + style["miterLimit"] = json!(m); + } + Some(style) +} + +/// How far `p` is from the nearest of `bases`' edges: positive outside, negative inside. +fn signed_distance(bases: &[Base], p: Point) -> Option { + let mut nearest = f64::INFINITY; + let mut inside = false; + for b in bases { + nearest = nearest.min(hit::distance_to_outline(&b.path, p)); + inside |= hit::fill_contains(&b.path, b.rule, p); + } + nearest.is_finite().then_some(if inside { -nearest } else { nearest }) +} + +/// A press in progress. +struct Drag { + bases: Vec, + /// Where the pointer stood against the edge at the press. + pressed_at: f64, + /// The Contour's distance at the press (0 for a shape that is not one yet). + start: f64, + /// The corner style and miter limit every preview carries: the pressed Contour's own, else the + /// tool's options. + style: Value, + /// The distance last previewed. + last: Option, + /// Where the pointer is, for the distance label. + pos: Point, +} + +#[derive(Default)] +pub struct ContourTool { + drag: Option, + corners: Corners, + miter_limit: Option, +} + +/// How a Contour treats the corners of the shape. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum Corners { + /// Pointed (mitered) corners. + #[default] + Sharp, + Round, + /// Corners cut off flat. + Square, +} + +impl Corners { + fn id(self) -> &'static str { + match self { + Corners::Sharp => "sharp", + Corners::Round => "round", + Corners::Square => "square", + } + } + + fn parse(s: &str) -> Option { + [Corners::Sharp, Corners::Round, Corners::Square].into_iter().find(|c| c.id().eq_ignore_ascii_case(s)) + } +} + +impl ContourTool { + /// The corner style and miter limit of a new Contour: the tool's options. + fn style(&self) -> Value { + let mut p = json!({ "corners": self.corners.id() }); + if let Some(m) = self.miter_limit { + p["miterLimit"] = json!(m); + } + p + } +} + +/// The selected shapes a Contour can be made of. +fn selected_shapes<'a>(cx: &'a ToolContext) -> Vec<&'a Node> { + cx.selection + .objects + .iter() + .filter_map(|id| cx.doc.node(*id)) + .filter(|n| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })) + .collect() +} + +fn round_distance(v: f64, mods: Mods) -> f64 { + let v = if mods.shift { v.round() } else { (v * 100.0).round() / 100.0 }; + v.clamp(-MAX_OFFSET, MAX_OFFSET) +} + +impl Tool for ContourTool { + fn id(&self) -> &'static str { + ID + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + match ev.kind { + PointerKind::Down => { + let shapes = selected_shapes(cx); + let start = shapes.first().and_then(|n| contour_offset(n)).unwrap_or(0.0); + // A Contour keeps its own corners; a new one takes the tool's. + let style = shapes.first().and_then(|n| contour_style(n)).unwrap_or_else(|| self.style()); + let bases: Vec = shapes.into_iter().filter_map(base_of).collect(); + let Some(pressed_at) = signed_distance(&bases, ev.pos) else { return vec![] }; + self.drag = Some(Drag { bases, pressed_at, start, style, last: None, pos: ev.pos }); + vec![Action::Begin("Contour".into())] + } + PointerKind::Drag => { + let Some(drag) = self.drag.as_mut() else { return vec![] }; + drag.pos = ev.pos; + let Some(now) = signed_distance(&drag.bases, ev.pos) else { return vec![] }; + let offset = round_distance(drag.start + now - drag.pressed_at, ev.mods); + // Nothing is previewed until the distance has left where it started: a drag along + // the edge (or one Shift rounds back) would otherwise leave an unchanged copy. + if !offset.is_finite() || drag.last == Some(offset) || (drag.last.is_none() && offset == drag.start) { + return vec![]; + } + drag.last = Some(offset); + let mut params = drag.style.clone(); + params["offset"] = json!(offset); + vec![Action::Preview("addon.contour.set".into(), params)] + } + PointerKind::Up => match self.drag.take() { + Some(d) if d.last.is_some() => vec![Action::Commit], + Some(_) => vec![Action::Cancel], + None => vec![], + }, + _ => vec![], + } + } + + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + match key { + ToolKey::Escape if self.drag.take().is_some() => vec![Action::Cancel], + _ => vec![], + } + } + + fn overlays(&self, cx: &ToolContext) -> Vec { + let mut out = vec![]; + // The outline each selected Contour was made from. + for n in selected_shapes(cx) { + if contour_offset(n).is_none() { + continue; + } + if let Some(b) = base_of(n) { + // A white halo under the dashes, so the guide shows over any fill. + out.push(Overlay::Path { path: b.path.clone(), color: [255, 255, 255], width: 3.0, dashed: false }); + out.push(Overlay::Path { path: b.path, color: GUIDE, width: 1.0, dashed: true }); + } + } + if let Some(d) = &self.drag + && let Some(offset) = d.last + { + out.push(Overlay::Measure { p: d.pos, text: format!("{offset:+.2} pt") }); + } + out + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { + Cursor::Crosshair + } + + fn options(&self) -> Value { + json!({"corners": self.corners.id(), "miterLimit": self.miter_limit.unwrap_or(4.0)}) + } + + fn set_option(&mut self, key: &str, value: &Value) { + match key { + "corners" => { + if let Some(c) = value.as_str().and_then(Corners::parse) { + self.corners = c; + } + } + "miterLimit" => { + if let Some(m) = value.as_f64().filter(|m| m.is_finite()) { + self.miter_limit = Some(m.clamp(1.0, 500.0)); + } + } + _ => {} + } + } + + fn busy(&self) -> bool { + self.drag.is_some() + } +} diff --git a/crates/ui-egui/src/addons.rs b/crates/ui-egui/src/addons.rs index d9c86240a..f0238aee2 100644 --- a/crates/ui-egui/src/addons.rs +++ b/crates/ui-egui/src/addons.rs @@ -3,6 +3,7 @@ pub mod arrange; pub mod cleanup; pub mod color; +pub mod contour; pub mod document; pub mod enclosing_lasso; pub mod pathtools; @@ -18,6 +19,7 @@ pub fn plugin_menu() -> Vec { Item::Sub("Arrange Tools", arrange::menu()), Item::Sub("Artwork Cleanup", cleanup::menu()), Item::Sub("Color Effects", color::menu()), + Item::Sub("Contour", contour::menu()), Item::Sub("Document Tools", document::menu()), Item::Sub("Enclosing Lasso", enclosing_lasso::menu()), Item::Sub("Path Precision Tools", pathtools::menu()), diff --git a/crates/ui-egui/src/addons/contour.rs b/crates/ui-egui/src/addons/contour.rs new file mode 100644 index 000000000..084a8b98c --- /dev/null +++ b/crates/ui-egui/src/addons/contour.rs @@ -0,0 +1,12 @@ +//! The Contour add-on in the interface: its submenu (Object › Plug-ins › Contour), which selects the +//! Contour tool. The Contour bar (`canvas/contour_bar.rs`) shows over the selection while the tool is +//! active. Everything acts through `addon.contour.set` and Expand Appearance. + +use serde_json::json; +use vectorcraft_tools::addons::contour; + +use crate::menus::Item; + +pub fn menu() -> Vec { + vec![Item::Cmd("Contour Tool", "tool.select", json!({"tool": contour::ID}))] +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0b563c24c..f99c524f1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -17,6 +17,7 @@ use crate::{CacheKey, VectorcraftApp, now_ms, widgets}; mod artboards; mod context; +mod contour_bar; mod cursor; mod drag; mod fill; @@ -360,6 +361,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if app.ui.task_bar && !revolve_edit && !dragging && app.ui.screen_mode < 3 && !strip { task_bar(app, ui, &xf); } + if app.ui.screen_mode < 3 { + contour_bar::contour_bar(app, ui, &xf); + } if app.ui.screen_mode < 3 { crate::free_transform::show(app, ui, xf.rect); } diff --git a/crates/ui-egui/src/canvas/contour_bar.rs b/crates/ui-egui/src/canvas/contour_bar.rs new file mode 100644 index 000000000..4b560eeba --- /dev/null +++ b/crates/ui-egui/src/canvas/contour_bar.rs @@ -0,0 +1,133 @@ +//! The Contour bar: a floating bar above the selection while the Contour tool is active. It has the +//! Contour's distance in a number box, its corner style (Sharp, Round or Square) and Bake, which +//! turns the live Contour into plain paths. It acts through `addon.contour.set` and +//! `effect.expandAppearance`, so everything it does is a command with its own undo step. + +use serde_json::Value; +use vectorcraft_doc::{Node, NodeKind}; + +use super::{CornerRadius, Stroke, Tokens, Ui, VectorcraftApp, Xf, json, pos2, vec2}; + +/// The live effect a Contour is made of. +const EFFECT: &str = "path.offsetPath"; + +fn bar_id() -> egui::Id { + egui::Id::unique("contour-bar") +} + +/// Where the Contour bar was last drawn (none while it isn't shown). +#[cfg(test)] +pub(super) fn bar_rect(ctx: &egui::Context) -> Option { + let id = bar_id(); + ctx.memory(|m| m.areas().is_visible(&egui::LayerId::new(egui::Order::Middle, id)).then(|| m.area_rect(id)).flatten()) +} + +/// A selected Contour's distance and corner style, read from its Offset Path effect. +struct Standing { + offset: f64, + corners: &'static str, +} + +fn standing(n: &Node) -> Option { + let e = n.appearance.effects.iter().rev().find(|e| e.id == EFFECT)?; + let offset = e.params.get("offset").and_then(Value::as_f64).unwrap_or(0.0); + let corners = match e.params.get("joins").and_then(Value::as_str) { + Some("round") => "round", + Some("bevel") => "square", + _ => "sharp", + }; + Some(Standing { offset, corners }) +} + +/// The Contour bar, over the selection's top edge (under it when the top is off the canvas). +pub(super) fn contour_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { + if app.session.tool_id() != "contour" { + return; + } + let Some(b) = app.selection_bounds() else { return }; + let Some(st) = app.session.active() else { return }; + let shapes: Vec<&Node> = st + .selection + .objects + .iter() + .filter_map(|id| st.doc.node(*id)) + .filter(|n| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })) + .collect(); + if shapes.is_empty() { + return; + } + let stand = shapes.first().and_then(|n| standing(n)); + drop(shapes); + let tool_corners = app.session.tool_options().get("corners").and_then(Value::as_str).unwrap_or("sharp").to_string(); + let corners = stand.as_ref().map_or(tool_corners.as_str(), |s| s.corners); + let shown = stand.as_ref().map_or(0.0, |s| s.offset); + // A grown Contour reaches past the outline it was made from: keep the bar clear of it. + let b = b.inflate(shown.max(0.0), shown.max(0.0)); + + let t = Tokens::get(ui.ctx()); + let quad = xf.quad(b); + let top = quad.iter().map(|c| c.y).fold(f32::INFINITY, f32::min); + let bottom = quad.iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); + let size = egui::AreaState::load(ui.ctx(), bar_id()).and_then(|s| s.size).unwrap_or(vec2(330.0, 40.0)); + let area = xf.rect.shrink(8.0); + let above = top - size.y - 12.0; + let y = if above >= area.top() { above } else { bottom + 12.0 }; + let at = pos2(xf.to_screen(b.center()).x - size.x / 2.0, y); + let at = at.clamp(area.min, (area.max - size).max(area.min)); + + let mut run: Option<(&str, Value)> = None; + let mut corner_pick: Option<&str> = None; + egui::Area::new(bar_id()).order(egui::Order::Middle).fixed_pos(at).show(ui.ctx(), |ui| { + egui::Frame::NONE + .fill(t.panel) + .stroke(Stroke::new(1.0, t.tool_active)) + .corner_radius(CornerRadius::same(5)) + .inner_margin(egui::Margin::symmetric(8, 6)) + .shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: egui::Color32::from_black_alpha(70) }) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + ui.label(egui::RichText::new(tl!("Contour")).color(t.text_strong)); + // The distance: a drag or typed value is applied when it ends, as one step. + let key = egui::Id::unique("contour-bar-offset"); + let mut v = ui.data(|d| d.get_temp::(key)).unwrap_or(shown); + let resp = ui.add(egui::DragValue::new(&mut v).speed(0.5).range(-1e5..=1e5).suffix(" pt")); + if resp.dragged() || resp.has_focus() { + ui.data_mut(|d| d.insert_temp(key, v)); + } + if resp.drag_stopped() || resp.lost_focus() { + ui.data_mut(|d| d.remove_temp::(key)); + if (v - shown).abs() > 1e-9 { + run = Some(("addon.contour.set", json!({"offset": v, "corners": corners}))); + } + } + for (label, id) in [(tl!("Sharp"), "sharp"), (tl!("Round"), "round"), (tl!("Square"), "square")] { + if ui.selectable_label(corners == id, label).clicked() { + corner_pick = Some(id); + } + } + let bake = ui.add_enabled(stand.is_some(), egui::Button::new(tl!("Bake"))); + if bake.on_hover_text(tl!("Turn the Contour into plain paths")).clicked() { + run = Some(("effect.expandAppearance", json!({}))); + } + }); + }); + }); + if let Some(id) = corner_pick { + // The tool keeps the style for the next Contour; a selected Contour changes now. + let mut result = app.run("tool.setOption", json!({"key": "corners", "value": id})); + if result.is_ok() + && let Some(s) = &stand + { + result = app.run("addon.contour.set", json!({"offset": s.offset, "corners": id})); + } + if let Err(e) = result { + app.ui.status = e; + } + } + if let Some((cmd, params)) = run + && let Err(e) = app.run(cmd, params) + { + app.ui.status = e; + } +} diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index edfeebca4..3f6f9f0ff 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -8988,3 +8988,77 @@ fn poll_worker_accepts_frames_not_older_than_displayed_and_discards_obsolete_fra assert_eq!(set_count, 1, "settled frame 10 is uploaded to texture"); assert_eq!(app.canvas.key.as_ref().unwrap().revision, 10, "settled frame 10 is uploaded"); } + +/// The labels the Contour bar draws in a headless frame, with their rects; none while it isn't +/// shown. +fn contour_bar_labels(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { + egui_extras::install_image_loaders(ctx); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + let Some(bar) = contour_bar::bar_rect(ctx) else { return vec![] }; + out.shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) if bar.contains(t.visual_bounding_rect().center()) => Some((t.galley.text().to_string(), t.visual_bounding_rect())), + _ => None, + }) + .collect() +} + +/// A click on the centre of the label `name` of the Contour bar, laid out by one frame before it. +fn click_contour_bar(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str) { + let labels = contour_bar_labels(app, ctx, vec![]); + let at = labels.iter().find(|(t, _)| t == name).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{name}` in {labels:?}")); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + contour_bar_labels(app, ctx, vec![egui::Event::PointerMoved(at)]); + contour_bar_labels(app, ctx, vec![button(true), button(false)]); +} + +/// The Contour bar shows with the Contour tool and a shape selected: Sharp, Round and Square set the +/// corner style (of a selected Contour at once, else of the next one), and Bake turns a Contour into +/// plain paths. +#[test] +fn the_contour_bar_sets_corners_and_bakes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 80, "height": 60})).unwrap(); + let ctx = egui::Context::default(); + + // Only the Contour tool shows it. + app.select_tool("selection"); + assert!(contour_bar_labels(&mut app, &ctx, vec![]).is_empty()); + app.select_tool("contour"); + contour_bar_labels(&mut app, &ctx, vec![]); + let names: Vec = contour_bar_labels(&mut app, &ctx, vec![]).into_iter().map(|(t, _)| t).collect(); + for want in ["Contour", "Sharp", "Round", "Square", "Bake"] { + assert!(names.iter().any(|n| n == want), "{want} in {names:?}"); + } + let offsets = |app: &VectorcraftApp| { + let mut found = vec![]; + app.session.active().unwrap().doc.walk(|n| { + if let Some(e) = n.appearance.effects.iter().find(|e| e.id == "path.offsetPath") { + found.push(e.params["joins"].as_str().unwrap_or_default().to_string()); + } + }); + found + }; + + // No Contour yet: Round is kept for the next one, and Bake does nothing. + let steps = app.session.active().unwrap().history.undo.len(); + click_contour_bar(&mut app, &ctx, "Round"); + assert_eq!(app.session.tool_options()["corners"], json!("round")); + click_contour_bar(&mut app, &ctx, "Bake"); + assert_eq!(app.session.active().unwrap().history.undo.len(), steps, "nothing to bake"); + assert!(offsets(&app).is_empty()); + + // A Contour made now has round corners; Square changes the selected Contour in place. + app.session.execute("addon.contour.set", &json!({"offset": 8, "corners": "round"})).unwrap(); + assert_eq!(offsets(&app), ["round"]); + click_contour_bar(&mut app, &ctx, "Square"); + assert_eq!(offsets(&app), ["bevel"], "the selected Contour changed, none was added"); + + // Bake: the live effect is gone and the art stays. + click_contour_bar(&mut app, &ctx, "Bake"); + assert!(offsets(&app).is_empty(), "baked"); +} diff --git a/crates/ui-egui/src/edit_toolbar/tests.rs b/crates/ui-egui/src/edit_toolbar/tests.rs index 85309507f..c474cea58 100644 --- a/crates/ui-egui/src/edit_toolbar/tests.rs +++ b/crates/ui-egui/src/edit_toolbar/tests.rs @@ -529,10 +529,22 @@ fn drop_targets_follow_the_buttons_edges() { fn addon_tools_are_in_the_drawer_not_the_toolbars() { let mut app = app(); let enclosing = vectorcraft_tools::addons::enclosing_lasso::ID; + let contour = vectorcraft_tools::addons::contour::ID; let addon: Vec<&str> = vectorcraft_tools::addons::GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect(); assert_eq!( addon, - ["anchorEraserBrush", "extendPath", "measureInspect", "parametricShape", "cornerTool", "slideAnchor", "sharedEdge", "smartPen", enclosing] + [ + "anchorEraserBrush", + "extendPath", + "measureInspect", + "parametricShape", + "cornerTool", + "slideAnchor", + "sharedEdge", + "smartPen", + enclosing, + contour + ] ); for advanced in [false, true] { if advanced { @@ -546,7 +558,8 @@ fn addon_tools_are_in_the_drawer_not_the_toolbars() { assert_eq!(listed("extendPath"), Some(("Path Precision Tools", addon[..7].to_vec()))); assert_eq!(listed("smartPen"), Some(("Smart Pen", vec!["smartPen"]))); assert_eq!(listed(enclosing), Some(("Enclosing Lasso", vec![enclosing]))); - for tool in ["extendPath", "smartPen", enclosing] { + assert_eq!(listed(contour), Some(("Contour", vec![contour]))); + for tool in ["extendPath", "smartPen", enclosing, contour] { app.run("toolbar.addTool", json!({"tool": tool})).unwrap(); assert!(slot_of(&app, tool).is_some(), "{tool}"); } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ee25d5057..96735ce20 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6201,6 +6201,13 @@ breakText Glyphs Глифи Fully Outside Изцяло извън No artboards match. Няма съвпадащи работни области. Chosen: {n} of {total} Избрани: {n} от {total} + +# Contour add-on + Contour Очертание + Contour Tool Инструмент „Очертание“ + Sharp Заостряне + Bake Разшири + Turn the Contour into plain paths Превръща очертанието в обикновени пътища Set Corners… Задаване на ъгли… All Anchors Всички опорни точки Odd-Numbered Anchors Нечетни опорни точки diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 1f4ea2422..ad78ee84e 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6236,6 +6236,13 @@ shape Open Otvoren Fully Outside Potpuno vani No artboards match. Nijedna radna ploča ne odgovara. Chosen: {n} of {total} Odabrano: {n} od {total} + +# Contour add-on + Contour Kontura + Contour Tool Alat kontura + Sharp Oštro + Bake Proširi + Turn the Contour into plain paths Pretvori konturu u obične putanje Set Corners… Postavi uglove… All Anchors Sva sidra Odd-Numbered Anchors Neparna sidra diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 70c893f32..8ad31d6d0 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6337,6 +6337,13 @@ breakText Glyphs Glifs Fully Outside Totalment fora No artboards match. Cap tauler no coincideix. Chosen: {n} of {total} Triats: {n} de {total} + +# Contour add-on + Contour Contorn + Contour Tool Eina de contorn + Sharp Punxegut + Bake Expandeix + Turn the Contour into plain paths Converteix el contorn en traçats simples Set Corners… Defineix les cantonades… All Anchors Tots els ancoratges Odd-Numbered Anchors Ancoratges senars diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index cac60a648..d801c39fd 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6351,6 +6351,13 @@ breakText Glyphs Glyfy Fully Outside Zcela vně No artboards match. Žádná pracovní plocha neodpovídá. Chosen: {n} of {total} Zvoleno: {n} z {total} + +# Contour add-on + Contour Obrys + Contour Tool Nástroj Obrys + Sharp Ostrý + Bake Rozložit + Turn the Contour into plain paths Převést obrys na běžné cesty Set Corners… Nastavit rohy… All Anchors Všechny kotevní body Odd-Numbered Anchors Liché kotevní body diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 623e70038..e410c8e4a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6217,6 +6217,13 @@ breakText Glyphs Glyffer Fully Outside Helt udenfor No artboards match. Ingen tegneflader passer. Chosen: {n} of {total} Valgt: {n} af {total} + +# Contour add-on + Contour Kontur + Contour Tool Kontur-værktøj + Sharp Spids + Bake Udvid + Turn the Contour into plain paths Gør konturen til almindelige stier Set Corners… Angiv hjørner… All Anchors Alle ankre Odd-Numbered Anchors Ulige ankre diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fea15fbe2..e11cf1bac 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5937,6 +5937,13 @@ breakText Glyphs Glyphen Fully Outside Vollständig außen No artboards match. Keine Zeichenfläche passt. Chosen: {n} of {total} Gewählt: {n} von {total} + +# Contour add-on + Contour Umriss + Contour Tool Umriss-Werkzeug + Sharp Spitz + Bake Umwandeln + Turn the Contour into plain paths Wandelt den Umriss in einfache Pfade um Set Corners… Ecken festlegen… All Anchors Alle Ankerpunkte Odd-Numbered Anchors Ungerade Ankerpunkte diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 72684a9c6..9f3d0c18e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6303,6 +6303,13 @@ breakText Glyphs Γλύφοι Fully Outside Πλήρως εκτός No artboards match. Κανένας πίνακας σχεδίασης δεν ταιριάζει. Chosen: {n} of {total} Επιλεγμένοι: {n} από {total} + +# Contour add-on + Contour Παράλληλο περίγραμμα + Contour Tool Εργαλείο παράλληλου περιγράμματος + Sharp Οξεία + Bake Επέκταση + Turn the Contour into plain paths Μετατρέπει το παράλληλο περίγραμμα σε απλές διαδρομές Set Corners… Ορισμός γωνιών… All Anchors Όλα τα σημεία αγκύρωσης Odd-Numbered Anchors Μονά σημεία αγκύρωσης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index caf2b7b05..b02abb8f1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6293,6 +6293,13 @@ breakText Glyphs Glifos Fully Outside Totalmente fuera No artboards match. Ninguna mesa de trabajo coincide. Chosen: {n} of {total} Elegidas: {n} de {total} + +# Contour add-on + Contour Contorno + Contour Tool Herramienta Contorno + Sharp Puntiaguda + Bake Expandir + Turn the Contour into plain paths Convierte el contorno en trazados simples Set Corners… Definir esquinas… All Anchors Todas las anclas Odd-Numbered Anchors Anclas impares diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 73a7465a8..eebd34d89 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6386,6 +6386,13 @@ breakText Glyphs Merkit Fully Outside Kokonaan ulkona No artboards match. Mikään piirtoalusta ei vastaa hakua. Chosen: {n} of {total} Valittu: {n}/{total} + +# Contour add-on + Contour Ääriviiva + Contour Tool Ääriviivatyökalu + Sharp Terävä + Bake Laajenna + Turn the Contour into plain paths Muuntaa ääriviivan tavallisiksi poluiksi Set Corners… Aseta kulmat… All Anchors Kaikki ankkurit Odd-Numbered Anchors Parittomat ankkurit diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 8aa5fa1e9..a5fbb1ab0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6346,6 +6346,13 @@ breakText Glyphs Glyphes Fully Outside Entièrement à l’extérieur No artboards match. Aucun plan de travail ne correspond. Chosen: {n} of {total} Choisis : {n} sur {total} + +# Contour add-on + Contour Pourtour + Contour Tool Outil Pourtour + Sharp Pointu + Bake Décomposer + Turn the Contour into plain paths Transforme le pourtour en tracés simples Set Corners… Définir les angles… All Anchors Tous les points d’ancrage Odd-Numbered Anchors Points d’ancrage impairs diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2137addf1..3959b3fee 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6335,6 +6335,13 @@ breakText Glyphs Glifák Fully Outside Teljesen kívül No artboards match. Nincs egyező rajztábla. Chosen: {n} of {total} Kiválasztva: {n} / {total} + +# Contour add-on + Contour Kontúr + Contour Tool Kontúr eszköz + Sharp Hegyes + Bake Kibontás + Turn the Contour into plain paths A kontúrt egyszerű útvonalakká alakítja Set Corners… Sarkok beállítása… All Anchors Összes horgonypont Odd-Numbered Anchors Páratlan horgonypontok diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c0c6489bf..fd72bc7e6 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6217,6 +6217,13 @@ breakText Glyphs Glif Fully Outside Sepenuhnya di luar No artboards match. Tidak ada artboard yang cocok. Chosen: {n} of {total} Dipilih: {n} dari {total} + +# Contour add-on + Contour Kontur + Contour Tool Alat kontur + Sharp Tajam + Bake Perluas + Turn the Contour into plain paths Ubah kontur menjadi jalur biasa Set Corners… Atur sudut… All Anchors Semua jangkar Odd-Numbered Anchors Jangkar bernomor ganjil diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d3add5036..4f2035969 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5971,6 +5971,13 @@ breakText Glyphs Glifi Fully Outside Completamente esterno No artboards match. Nessuna tavola da disegno corrisponde. Chosen: {n} of {total} Scelte: {n} di {total} + +# Contour add-on + Contour Contorno + Contour Tool Strumento Contorno + Sharp Appuntito + Bake Espandi + Turn the Contour into plain paths Trasforma il contorno in tracciati semplici Set Corners… Imposta angoli… All Anchors Tutti gli ancoraggi Odd-Numbered Anchors Ancoraggi dispari diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 3533338d7..9f089ecf4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6036,6 +6036,13 @@ breakText Glyphs 字形 Fully Outside 完全に外側 No artboards match. 一致するアートボードはありません。 Chosen: {n} of {total} 選択中: {n} / {total} + +# Contour add-on + Contour 輪郭 + Contour Tool 輪郭ツール + Sharp 鋭角 + Bake 分割 + Turn the Contour into plain paths 輪郭を通常のパスに変換します Set Corners… コーナーを設定… All Anchors すべてのアンカー Odd-Numbered Anchors 奇数番目のアンカー diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index cc96a111c..78da64abc 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6362,6 +6362,13 @@ font-script Japanese იაპონური Fully Outside მთლიანად გარეთ No artboards match. შესაბამისი სამუშაო დაფა არ არის. Chosen: {n} of {total} არჩეულია: {n} / {total} + +# Contour add-on + Contour მოხაზულობა + Contour Tool მოხაზულობის ხელსაწყო + Sharp მახვილი + Bake გაშლა + Turn the Contour into plain paths მოხაზულობას ჩვეულებრივ ტრაექტორიებად აქცევს Set Corners… კუთხეების დაყენება… All Anchors ყველა საყრდენი წერტილი Odd-Numbered Anchors კენტი საყრდენი წერტილები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1ac6fdc75..3e99b509b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5932,6 +5932,13 @@ breakText Glyphs 글리프 Fully Outside 완전히 바깥쪽 No artboards match. 일치하는 아트보드가 없습니다. Chosen: {n} of {total} 선택됨: {n} / {total} + +# Contour add-on + Contour 윤곽 + Contour Tool 윤곽 도구 + Sharp 뾰족하게 + Bake 확장 + Turn the Contour into plain paths 윤곽을 일반 패스로 바꿉니다 Set Corners… 모퉁이 설정… All Anchors 모든 앵커 Odd-Numbered Anchors 홀수 번째 앵커 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 0a5943f82..7f7818c54 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6261,6 +6261,13 @@ breakText Glyphs Glyphs Fully Outside Volledig buiten No artboards match. Geen tekengebied komt overeen. Chosen: {n} of {total} Gekozen: {n} van {total} + +# Contour add-on + Contour Omtrek + Contour Tool Omtrek-gereedschap + Sharp Scherp + Bake Uitbreiden + Turn the Contour into plain paths Zet de omtrek om in gewone paden Set Corners… Hoeken instellen… All Anchors Alle ankerpunten Odd-Numbered Anchors Oneven ankerpunten diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index da7f069e6..56127f052 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6206,6 +6206,13 @@ breakText Glyphs Glify Fully Outside Całkowicie na zewnątrz No artboards match. Żaden obszar kompozycji nie pasuje. Chosen: {n} of {total} Wybrano: {n} z {total} + +# Contour add-on + Contour Kontur + Contour Tool Narzędzie Kontur + Sharp Ostry + Bake Rozszerz + Turn the Contour into plain paths Zamienia kontur w zwykłe ścieżki Set Corners… Ustaw narożniki… All Anchors Wszystkie punkty Odd-Numbered Anchors Punkty nieparzyste diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4e05fc1ee..7f68c5c7f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6424,6 +6424,13 @@ swatch Yellow Green Verde-amarelado Fully Outside Totalmente fora No artboards match. Nenhuma mesa de trabalho corresponde. Chosen: {n} of {total} Escolhidas: {n} de {total} + +# Contour add-on + Contour Contorno + Contour Tool Ferramenta Contorno + Sharp Pontiagudo + Bake Expandir + Turn the Contour into plain paths Transforma o contorno em caminhos simples Set Corners… Definir Cantos… All Anchors Todas as Âncoras Odd-Numbered Anchors Âncoras Ímpares diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 60dcc9e32..10426b2ef 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6440,6 +6440,13 @@ shape Open Открытый Fully Outside Полностью снаружи No artboards match. Нет подходящих монтажных областей. Chosen: {n} of {total} Выбрано: {n} из {total} + +# Contour add-on + Contour Эквидистанта + Contour Tool Инструмент «Эквидистанта» + Sharp Острый + Bake Разобрать + Turn the Contour into plain paths Преобразует эквидистанту в обычные контуры Set Corners… Задать углы… All Anchors Все опорные точки Odd-Numbered Anchors Нечётные опорные точки diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 7a26b4d02..1d2d9b03e 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6407,6 +6407,13 @@ digits Hindi Hindi Fully Outside Helt utanför No artboards match. Ingen rityta matchar. Chosen: {n} of {total} Valda: {n} av {total} + +# Contour add-on + Contour Kantlinje + Contour Tool Kantlinjeverktyg + Sharp Spetsig + Bake Expandera + Turn the Contour into plain paths Gör om kantlinjen till vanliga banor Set Corners… Ange hörn… All Anchors Alla ankare Odd-Numbered Anchors Udda ankare diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f50913c3e..133538fe9 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6308,6 +6308,13 @@ breakText Glyphs Glifler Fully Outside Tamamen dışarıda No artboards match. Eşleşen çalışma yüzeyi yok. Chosen: {n} of {total} Seçilen: {n}/{total} + +# Contour add-on + Contour Dış Hat + Contour Tool Dış Hat Aracı + Sharp Keskin + Bake Genişlet + Turn the Contour into plain paths Dış hattı sade yollara dönüştürür Set Corners… Köşeleri Ayarla… All Anchors Tüm Çapalar Odd-Numbered Anchors Tek Numaralı Çapalar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 35b806a1f..95724a3d7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5951,6 +5951,13 @@ breakText Glyphs Гліфи Fully Outside Повністю зовні No artboards match. Немає відповідних монтажних областей. Chosen: {n} of {total} Вибрано: {n} з {total} + +# Contour add-on + Contour Еквідистанта + Contour Tool Інструмент «Еквідистанта» + Sharp Гострий + Bake Розібрати + Turn the Contour into plain paths Перетворює еквідистанту на звичайні контури Set Corners… Задати кути… All Anchors Усі опорні точки Odd-Numbered Anchors Непарні опорні точки diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bae63ccf8..93113bc39 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5952,6 +5952,13 @@ breakText Glyphs Glyph Fully Outside Hoàn toàn bên ngoài No artboards match. Không có bảng vẽ nào khớp. Chosen: {n} of {total} Đã chọn: {n} trên {total} + +# Contour add-on + Contour Đường viền + Contour Tool Công cụ Đường viền + Sharp Nhọn + Bake Mở rộng + Turn the Contour into plain paths Chuyển đường viền thành các đường dẫn thường Set Corners… Đặt góc… All Anchors Tất cả điểm neo Odd-Numbered Anchors Điểm neo số lẻ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c5004c367..20ec64796 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6192,6 +6192,13 @@ breakText Glyphs 字形 Fully Outside 完全在外 No artboards match. 没有匹配的画板。 Chosen: {n} of {total} 已选:{n} / {total} + +# Contour add-on + Contour 轮廓 + Contour Tool 轮廓工具 + Sharp 尖角 + Bake 扩展 + Turn the Contour into plain paths 将轮廓转为普通路径 Set Corners… 设置转角… All Anchors 所有锚点 Odd-Numbered Anchors 奇数锚点 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e42ab765b..7dbb11507 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5989,6 +5989,13 @@ breakText Glyphs 字符 Fully Outside 完全在外 No artboards match. 沒有符合的工作區域。 Chosen: {n} of {total} 已選:{n} / {total} + +# Contour add-on + Contour 輪廓 + Contour Tool 輪廓工具 + Sharp 尖角 + Bake 展開 + Turn the Contour into plain paths 將輪廓轉為一般路徑 Set Corners… 設定轉角… All Anchors 所有錨點 Odd-Numbered Anchors 奇數錨點 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 579df2187..78eb6149d 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -60,6 +60,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("dc-center-show", include_bytes!("../../../assets/icons/dc-center-show.svg")), ("dc-clear", include_bytes!("../../../assets/icons/dc-clear.svg")), ("dc-color-guide", include_bytes!("../../../assets/icons/dc-color-guide.svg")), + ("dc-contour", include_bytes!("../../../assets/icons/dc-contour.svg")), ("dc-crystallize", include_bytes!("../../../assets/icons/dc-crystallize.svg")), ("dc-cube", include_bytes!("../../../assets/icons/dc-cube.svg")), ("dc-dash-align", include_bytes!("../../../assets/icons/dc-dash-align.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index a3601af18..e35265327 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -85,6 +85,7 @@ pub fn tool_icon(name: &str) -> &'static str { "tool-eyedropper" => "pipette", "tool-measure" => "dc-measure", "tool-dimension" => "dc-dimension", + "tool-contour" => "dc-contour", "tool-blend" => "dc-blend", "tool-objects-on-path" => "dc-objects-on-path", "tool-symbol" => "dc-symbol-sprayer", diff --git a/docs/gaps.md b/docs/gaps.md index 7dc084a0a..68d08df0b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -28,6 +28,8 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | Contour add-on (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `addon.contour.set` is the command behind it. It is a built-in add-on: Object › Plug-ins › Contour, and its own heading in All Tools, with no default shortcut or toolbar slot. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | +| 2026-11-04 | minor | Contour tool (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `object.contour` is the command behind it. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | | 2026-11-05 | minor | G12: Hyphenation › Default Language sets the language of new type unless Document Setup chose one, stored as a tag; each run hyphenates in its own language, English only so far, so 2 Preferences options stay unused (#394) | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | | 2026-11-02 | minor | Font-family fields step selected text with the mouse wheel (#5253); no scored gap or estimate reassessed. | diff --git a/docs/mcp.md b/docs/mcp.md index 797ac023b..8fd7a394a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6780,6 +6780,27 @@ The panel sets the next segment and runs Remove Points Keep Shape (`addon.pathto {"name":"run_command","arguments":{"command":"addon.pen.reshape","params":{"id":12,"segment":0,"t":0.3,"dx":0,"dy":-25}}} ``` +## Contour add-on + +Contour is a built-in add-on: it grows or shrinks a copy of a path or shape by a distance, live. Its command is +`addon.contour.set`; in the desktop app it lives in Object › Plug-ins › Contour and in the Contour Tool (`contour`), which +the toolbars don't show until it is added from the All Tools drawer (or chosen with `tool.select`); it has no shortcut. A +floating bar over the selection (the distance, Sharp / Round / Square and Bake) acts through the same commands. + +`addon.contour.set {offset, corners?, miterLimit?}` puts a copy of each selected path, shape or compound path directly above +it with a live Offset Path effect of `offset` points (negative insets), and selects the copies. `corners` is `"sharp"` +(default), `"round"` or `"square"` (corners cut off flat), `miterLimit` 1 to 500 (4; sharp corners). A selected object that +already has that effect (a Contour made earlier) has its effect changed instead of getting another, so the same call +adjusts a Contour. The original is kept, and nothing is baked: `effect.expandAppearance` turns a Contour into plain paths. +Type, images and groups are left alone; with nothing to offset it says "select paths or shapes". One undo step. Result: +`{ids, created, updated}`. + +The tool (`contour`) drags a distance from the selected shape's edge, outward to grow and inward to shrink: the press +changes nothing until the pointer moves, a Contour that already stands at a distance continues from it, Shift rounds to +whole points and Escape cancels. A drag keeps a Contour's own corners and miter limit; a new Contour takes the tool's +options `corners` and `miterLimit` (`tool.setOption`). A border with a gap is two Contours of one shape (the gap first, so +it ends up in front), baked with `effect.expandAppearance`, then `object.pathfinder.minusFront`. + ## Color Effects add-on Color Effects is a built-in add-on: its effects are colour adjustments in Effect › Color Effects, its command is diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d2732f545..d93304153 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-1-05 · **Change:** minor (SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; document tabs are reordered by dragging them along the tab strip; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Contour add-on, beyond Illustrator; Optical kerning spaces pairs by the font's own stem spacing instead of a fixed tight gap; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; document tabs are reordered by dragging them along the tab strip; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -482,6 +482,7 @@ since 2026-10-01. | **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel; synthetic-input counts, pending events and per-frame control requests are bounded (#1709); what an agent reads and writes on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root`, as in PhotoCraft, #832) | | **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) | | **Align Stroke on live type** | beyond Illustrator | The Stroke panel's Align Stroke works on type's character stroke: inside or outside the glyphs without Create Outlines, the type staying editable (as InDesign does; users have asked Illustrator for it since 2010, e.g. [redd.it/1dwhos6](https://redd.it/1dwhos6)). Canvas, PDF and EPS clip it to its side of the glyphs; SVG keeps the text live and draws the stroke on the glyph outlines (#2294) | +| **Contour add-on** | beyond Illustrator | Drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it (Shift: whole points); the copy carries a live Offset Path effect so it stays editable, with its own fill and stroke, until Bake (Expand Appearance). A second drag on a selected Contour changes it instead of nesting another; Escape cancels. A floating bar above the selection sets the distance and the corners (Sharp, Round, Square); `addon.contour.set` makes or changes one over MCP and the control channel. The tool is in Object › Plug-ins › Contour and under its own heading in All Tools, with no default shortcut or toolbar slot. Two Contours baked and combined with Minus Front give a border with a gap. Paths, shapes and compound paths only; type isn't offset. Tests: `crates/engine/src/cmd/addons/contour/tests.rs`, `crates/ui-egui/src/canvas/tests.rs` | ## Evidence: what ships today, by area @@ -512,6 +513,7 @@ since 2026-10-01. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. +- **Global Edit's enabled check on large paths:** the check that decides whether Select › Start Global Edit can run on the selected path, compound path or symbol instance (which the Control bar makes every frame) measures the box of its anchors and handles instead of building and normalizing a copy of every anchor, with the same answer. On a 200,000-anchor path the check takes 0.7 ms instead of 2.2 to 3.0 ms. Regression tests: `support_*` in `crates/engine/src/cmd/globaledit.rs`. - **Control bar's inline X, Y, W and H fields fit the bar:** the Control bar shows the inline X, Y, W and H fields only when all four, their labels as drawn in the current language and the W/H link fit inside the bar's margin; otherwise it shows the Transform link alone. This is VectorCraft's own layout rule. Regression test: `the_control_bar_shows_the_inline_transform_fields_only_when_they_fit` in `crates/ui-egui/src/chrome/tests.rs`. - **Pattern-only backquote drags:** With Selection move, bounding-box scale or rotate, or the Rotate, Reflect, Scale or Shear tool, backquote held at mouse release applies the drag matrix to selected pattern paint while object geometry stays in place. The key may be pressed after mouse press; if released before the mouse button, the drag commits as an ordinary transform. Objects without pattern paint stay still; stroke-only patterns and patterned objects in mixed selections take the same matrix. Regression tests: `backquote_move_uses_release_state_and_one_commit` and `backquote_box_scale_and_rotate_use_pattern_matrix_at_release` in `select/tests.rs`, `backquote_drag_previews_and_commits_pattern_only_for_each_transform_tool` in `xform/transform.rs`, `selection_backquote_drag_changes_pattern_fill_and_stroke_without_moving_mixed_art` and `selection_backquote_drag_changes_stroke_only_pattern` in `tests/pattern.rs`, `egui_backtick_drag_uses_key_state_at_release` in `canvas/tests.rs`, and `synthetic_pointer_backquote_keeps_solid_object_in_place` in `control/tests.rs`. Scores and hours unchanged. - **Existing-guide Escape cancellation (G1):** Escape clears a pending guide press or drag through Selection and Direct/Group Selection, cancelling a begun preview. Eight app-input regressions cover both axes, move/copy, temporary selection-tool borrowing from idle Pen, preselected-guide document/interaction-start selection and Undo/Redo/saved-state preservation, later on-canvas motion/release, Shift-press cancellation, and ordinary-release/Enter/idle controls. Historical focused checks on `8ab574b7` recorded five expected RED failures with three passing controls, then all eight GREEN tests and the Direct Selection Escape control passed. All nine also passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. The original 1280×960 headless app capture from `b076025a` was reviewed for one vertical Selection guide after Escape; source review found its scene unchanged on `a0671777`. Scores and hours unchanged. @@ -1648,6 +1650,8 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | Contour add-on (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `addon.contour.set` is the command behind it. It is a built-in add-on: Object › Plug-ins › Contour, and its own heading in All Tools, with no default shortcut or toolbar slot. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | +| 2026-11-04 | minor | Contour tool (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `object.contour` is the command behind it. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | | 2026-11-05 | minor | View & navigation: document tabs are reordered by dragging them along the tab strip (`document.move`, #5663); evidence added; scores unchanged. | | 2026-10-30 | minor | Effects: Color Halftone's Max Radius is 4–127 px of the Document Raster Effects resolution, shown in px whatever the units; older documents open converted, and files saved for older app versions hold points (#3260); scores unchanged. | | 2026-11-01 | minor | Batch Trace Folder (`imageTrace.batchFolder`, #4921); evidence added; scores unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7b5011024..ac9cf181b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (document tabs are reordered by dragging them along the tab strip; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Contour add-on, beyond Illustrator; scores and estimates unchanged; Select by Artboards add-on selects by overlap, inside or outside several artboards; Cmd+Space keeps the active drawing cursor during its drag; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence @@ -437,6 +437,8 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | Contour add-on (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `addon.contour.set` is the command behind it. It is a built-in add-on: Object › Plug-ins › Contour, and its own heading in All Tools, with no default shortcut or toolbar slot. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | +| 2026-11-04 | minor | Contour tool (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `object.contour` is the command behind it. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. | | 2026-11-05 | minor | Document tabs are reordered by dragging them along the tab strip (`document.move`, #5663). | | 2026-11-04 | minor | Path Precision Tools add-on (a VectorCraft addition): Set Corners… rounds only the corners in an angle range (#5545); scores and estimates unchanged | | 2026-11-04 | minor | Path Precision Tools add-on (a VectorCraft addition): Set Corners… rounds all, the odd-numbered or the even-numbered anchors (#5546); scores and estimates unchanged |