From 8584bf1ab667c2134242f69b2eda1d98f42a2aef Mon Sep 17 00:00:00 2001 From: Kindmark <39938776+Kindmark@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:52:14 -0600 Subject: [PATCH 1/3] Contour: live offset tool with a floating bar and its own icon Add a Contour tool (Modify group). Dragging away from a selected path or shape's edge makes a copy of it that carries a live Offset Path effect, grown by the drag; dragging toward the inside shrinks it. The result is one undo step and stays editable until baked with Expand Appearance. A second drag on a selected Contour changes its distance instead of nesting another effect. - object.contour: the command behind it (offset, sharp/round/square corners, miter limit), usable over MCP and the control channel too. - Contour tool: live preview, Shift rounds to whole points, Escape cancels, a dashed guide with a white halo shows the outline the shape had, and a pill shows the distance. - Contour bar: a floating bar above the selection with the offset box, Sharp / Round / Square and Bake (Expand Appearance). - Toolbar slot under Modify (Basic and Advanced) and dc-contour.svg, an original icon with its ASSETS.md row. - Tests for the command, the tool's pointer gestures and the bar; the border-with-a-gap recipe (two Contours, bake, Minus Front) is covered end to end. - Docs: beyond-Illustrator row, revision histories and the ROADMAP log. Beyond Illustrator: a VectorCraft design built on Offset Path. Scores and estimates unchanged. Co-Authored-By: Claude Sonnet 5.5 --- ASSETS.md | 1 + ROADMAP.md | 4 +- assets/icons/dc-contour.svg | 1 + crates/engine/src/cmd.rs | 2 + crates/engine/src/cmd/contour.rs | 82 ++++++ crates/engine/src/tests.rs | 1 + crates/engine/src/tests/contour.rs | 337 +++++++++++++++++++++++ crates/tools/src/catalog.rs | 1 + crates/tools/src/contour.rs | 232 ++++++++++++++++ crates/tools/src/lib.rs | 2 + crates/ui-egui/src/canvas.rs | 4 + crates/ui-egui/src/canvas/contour_bar.rs | 133 +++++++++ crates/ui-egui/src/canvas/tests.rs | 74 +++++ crates/ui-egui/src/edit_toolbar.rs | 2 +- crates/ui-egui/src/icon_data.rs | 1 + crates/ui-egui/src/icons.rs | 1 + crates/ui-egui/src/toolbar.rs | 1 + docs/gaps.md | 3 +- docs/target-app-parity.md | 4 +- docs/ui-parity.md | 3 +- 20 files changed, 884 insertions(+), 5 deletions(-) create mode 100644 assets/icons/dc-contour.svg create mode 100644 crates/engine/src/cmd/contour.rs create mode 100644 crates/engine/src/tests/contour.rs create mode 100644 crates/tools/src/contour.rs create mode 100644 crates/ui-egui/src/canvas/contour_bar.rs 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..e7000874f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # VectorCraft Roadmap +- Contour tool (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 tool, 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.rs b/crates/engine/src/cmd.rs index b1b9efd60..e59331691 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -12,6 +12,7 @@ mod colorcmds; pub mod colormgmt; pub mod colortheme; mod compoundshape; +pub(crate) mod contour; pub(crate) mod create; mod css; mod cut; @@ -262,6 +263,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(xform::specs()); v.extend(effectcmd::specs()); v.extend(pathops::specs()); + v.extend(contour::specs()); v.extend(compoundshape::specs()); v.extend(typecmd::specs()); v.extend(menucmds::specs()); diff --git a/crates/engine/src/cmd/contour.rs b/crates/engine/src/cmd/contour.rs new file mode 100644 index 000000000..5bbf5652b --- /dev/null +++ b/crates/engine/src/cmd/contour.rs @@ -0,0 +1,82 @@ +//! `object.contour`: 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. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; +use vectorcraft_render::effects; + +use super::edit::{copy_above, selected_roots}; +use super::pathops::{len_param, num_param}; +use super::{CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, has_selection, str_param}; + +const C: &str = "object.contour"; +/// 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!( + "object.contour", + "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/tests.rs b/crates/engine/src/tests.rs index 9ec3ccaf9..e78d828dc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -36,6 +36,7 @@ mod compoundpaint; mod constrain_new; mod constrain_tools; mod containers; +mod contour; mod css; mod cut; mod dashalign; diff --git a/crates/engine/src/tests/contour.rs b/crates/engine/src/tests/contour.rs new file mode 100644 index 000000000..8ec0d7f45 --- /dev/null +++ b/crates/engine/src/tests/contour.rs @@ -0,0 +1,337 @@ +//! Tests for `object.contour`: 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("object.contour", &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("object.contour", &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("object.contour", &json!({"offset": 10})).unwrap(), "created"); + let count = node_count(&s); + // The Contour is selected: the next call edits it. + let r = s.execute("object.contour", &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("object.contour", &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("object.contour", &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("object.contour", &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("object.contour", ¶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("object.contour", &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("object.contour", &json!({"offset": 8})).unwrap(), "created")[0]; + select(&mut s, &[a]); + let border = ids_of(&s.execute("object.contour", &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("object.contour")); + 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")); + } + + /// 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/tools/src/catalog.rs b/crates/tools/src/catalog.rs index 9bbd7cbdf..49fe557ea 100644 --- a/crates/tools/src/catalog.rs +++ b/crates/tools/src/catalog.rs @@ -94,6 +94,7 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[ t("livePaintBucket", "Live Paint Bucket", Some("K"), "tool-bucket"), t("livePaintSelection", "Live Paint Selection Tool", Some("Shift+L"), "tool-live-select"), ], + &[t("contour", "Contour Tool", None, "tool-contour")], &[ t("perspectiveGrid", "Perspective Grid Tool", Some("Shift+P"), "tool-perspective"), t("perspectiveSelection", "Perspective Selection Tool", Some("Shift+V"), "tool-perspective-select"), diff --git a/crates/tools/src/contour.rs b/crates/tools/src/contour.rs new file mode 100644 index 000000000..a23329b7b --- /dev/null +++ b/crates/tools/src/contour.rs @@ -0,0 +1,232 @@ +//! 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 (`object.contour`). 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 live effect a Contour is made of (the one `object.contour` applies). +const EFFECT: &str = "path.offsetPath"; +/// The largest distance, in points, `object.contour` 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)) +} + +/// 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 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 { + fn params(&self, offset: f64) -> Value { + let mut p = json!({"offset": offset, "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 { + "contour" + } + + 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); + 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, last: None, pos: ev.pos }); + vec![Action::Begin("Contour".into())] + } + PointerKind::Drag => { + let mut params = self.params(0.0); + 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); + if !offset.is_finite() || drag.last == Some(offset) { + return vec![]; + } + drag.last = Some(offset); + params["offset"] = json!(offset); + vec![Action::Preview("object.contour".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() + } +} + +/// The Contour tool, for [`crate::create`]. +pub fn create(id: &str) -> Option> { + (id == "contour").then(|| Box::new(ContourTool::default()) as Box) +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index ee00fdbe9..d14e43c67 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -11,6 +11,7 @@ pub mod addons; pub mod bbox; pub mod builder; pub mod catalog; +pub mod contour; pub mod corners; pub mod cropimage; pub mod cut; @@ -296,6 +297,7 @@ pub fn create(id: &str) -> Box { .or_else(|| printtiling::create(other)) .or_else(|| cropimage::create(other)) .or_else(|| cut::create(other)) + .or_else(|| contour::create(other)) .or_else(|| intertwine::create(other)) .or_else(|| objects_on_path::create(other)) .or_else(|| touchtype::create(other)) 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..443fdc2aa --- /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 `object.contour` 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(("object.contour", 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("object.contour", 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..da52ed353 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("object.contour", &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.rs b/crates/ui-egui/src/edit_toolbar.rs index eb79cf357..dcec1ffc5 100644 --- a/crates/ui-egui/src/edit_toolbar.rs +++ b/crates/ui-egui/src/edit_toolbar.rs @@ -410,7 +410,7 @@ fn heading(id: &str) -> &'static str { _ if id.starts_with("perspective") => "Perspective", "artboard" => "Artboards", "slice" | "sliceSelection" => "Slices", - "objectsOnPath" => "Modify", + "objectsOnPath" | "contour" => "Modify", _ => "More", } } 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/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 94054a0d4..fcb9a1cc9 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -59,6 +59,7 @@ pub const BASIC: &[(&str, &[&[&str]])] = &[ &["rotate", "reflect", "scale", "shear", "reshape", "freeTransform", "puppetWarp"], &["shapeBuilder", "livePaintBucket", "livePaintSelection", "blend"], &["dimension"], + &["contour"], ], ), ("Type", &[&["areaType", "typeOnPath", "verticalAreaType", "verticalTypeOnPath"], &["type", "verticalType", "touchType"]]), diff --git a/docs/gaps.md b/docs/gaps.md index 7dc084a0a..92ee96cb9 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 tool, 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,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 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/target-app-parity.md b/docs/target-app-parity.md index 17db9d3f2..76c4c4ab2 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-05 · **Change:** minor (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 tool, beyond Illustrator; scores and estimates unchanged; Select by Artboards add-on selects by overlap, inside or outside several artboards; 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 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 (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); `object.contour` makes or changes one over MCP and the control channel. 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/tests/contour.rs`, `crates/ui-egui/src/canvas/tests.rs` | ## Evidence: what ships today, by area @@ -1649,6 +1650,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 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..282eea9b6 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 tool, 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,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 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 | From 8ce22e71eba827b1bd3c1ce42dda64b60fa11b96 Mon Sep 17 00:00:00 2001 From: Kindmark <39938776+Kindmark@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:57:11 -0600 Subject: [PATCH 2/3] Contour: keep a Contour's corners when dragged again; ignore a no-distance drag Two fixes from review of #5694, each with a test that fails without it: - Dragging a selected Contour sent the tool's own corner options, so a Round or Square Contour came back Sharp and lost its miter limit, and the Contour bar (which reads the effect) disagreed with the shape. The press now reads the Contour's own corners and miter limit and every preview of that drag carries them; a new Contour still takes the tool's options. - A drag that never moved the distance (along the edge, or rounded to nothing by Shift) previewed offset 0 and committed an unchanged copy and an undo step. Nothing is previewed until the distance leaves where it started. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/tests/contour.rs | 34 ++++++++++++++++++++++++++++++ crates/tools/src/contour.rs | 34 +++++++++++++++++++++++++----- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/tests/contour.rs b/crates/engine/src/tests/contour.rs index 8ec0d7f45..164370c75 100644 --- a/crates/engine/src/tests/contour.rs +++ b/crates/engine/src/tests/contour.rs @@ -299,6 +299,40 @@ mod tool { 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("object.contour", &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] diff --git a/crates/tools/src/contour.rs b/crates/tools/src/contour.rs index a23329b7b..cf450752c 100644 --- a/crates/tools/src/contour.rs +++ b/crates/tools/src/contour.rs @@ -51,6 +51,22 @@ fn contour_offset(n: &Node) -> Option { 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 `object.contour` 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; @@ -69,6 +85,9 @@ struct Drag { 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. @@ -108,8 +127,9 @@ impl Corners { } impl ContourTool { - fn params(&self, offset: f64) -> Value { - let mut p = json!({"offset": offset, "corners": self.corners.id()}); + /// 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); } @@ -142,21 +162,25 @@ impl Tool for ContourTool { 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, last: None, pos: ev.pos }); + self.drag = Some(Drag { bases, pressed_at, start, style, last: None, pos: ev.pos }); vec![Action::Begin("Contour".into())] } PointerKind::Drag => { - let mut params = self.params(0.0); 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); - if !offset.is_finite() || drag.last == Some(offset) { + // 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("object.contour".into(), params)] } From db1ef1bfed8ee401c1ef721d36dfb93529997f82 Mon Sep 17 00:00:00 2001 From: Kindmark <39938776+Kindmark@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:41:32 -0600 Subject: [PATCH 3/3] Contour: make it a built-in add-on and translate its labels Review of #5694 asked for two changes. Add-on, not a core tool, as Enclosing Lasso is: - The command is addon.contour.set, in an add-on module of its own (crates/engine/src/cmd/addons/contour.rs) with its tests beside it. - The tool is listed under Object > Plug-ins > Contour and under its own "Contour" heading in All Tools. It has no Basic or Advanced toolbar slot and no default shortcut. The Contour bar is unchanged. - The add-on drawer test lists Contour with the other add-on tools. - Docs (mcp.md, the parity documents, ROADMAP) say add-on and use the new command name; mcp.md gains a Contour add-on section. Translations: rows for "Contour", "Contour Tool", "Sharp", "Bake" and "Turn the Contour into plain paths" in all 26 catalogs ("Round" and "Square" already had them). Drafted from each catalog's own vocabulary (for Bake, the verb it uses for Expand Appearance; a word other than the catalog's Stroke or Path word for the tool's name where they collide); they are not native-speaker reviewed. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 4 +-- crates/engine/src/cmd.rs | 2 -- crates/engine/src/cmd/addons.rs | 2 ++ crates/engine/src/cmd/{ => addons}/contour.rs | 15 ++++++---- .../addons/contour/tests.rs} | 28 +++++++++---------- crates/engine/src/tests.rs | 1 - crates/tools/src/addons.rs | 10 +++++-- crates/tools/src/{ => addons}/contour.rs | 20 ++++++------- crates/tools/src/catalog.rs | 1 - crates/tools/src/lib.rs | 2 -- crates/ui-egui/src/addons.rs | 2 ++ crates/ui-egui/src/addons/contour.rs | 12 ++++++++ crates/ui-egui/src/canvas/contour_bar.rs | 6 ++-- crates/ui-egui/src/canvas/tests.rs | 2 +- crates/ui-egui/src/edit_toolbar.rs | 2 +- crates/ui-egui/src/edit_toolbar/tests.rs | 17 +++++++++-- crates/ui-egui/src/i18n/bg.tsv | 7 +++++ crates/ui-egui/src/i18n/bs.tsv | 7 +++++ crates/ui-egui/src/i18n/ca.tsv | 7 +++++ crates/ui-egui/src/i18n/cs.tsv | 7 +++++ crates/ui-egui/src/i18n/da.tsv | 7 +++++ crates/ui-egui/src/i18n/de.tsv | 7 +++++ crates/ui-egui/src/i18n/el.tsv | 7 +++++ crates/ui-egui/src/i18n/es.tsv | 7 +++++ crates/ui-egui/src/i18n/fi.tsv | 7 +++++ crates/ui-egui/src/i18n/fr.tsv | 7 +++++ crates/ui-egui/src/i18n/hu.tsv | 7 +++++ crates/ui-egui/src/i18n/id.tsv | 7 +++++ crates/ui-egui/src/i18n/it.tsv | 7 +++++ crates/ui-egui/src/i18n/ja.tsv | 7 +++++ crates/ui-egui/src/i18n/ka.tsv | 7 +++++ crates/ui-egui/src/i18n/ko.tsv | 7 +++++ crates/ui-egui/src/i18n/nl.tsv | 7 +++++ crates/ui-egui/src/i18n/pl.tsv | 7 +++++ crates/ui-egui/src/i18n/pt-br.tsv | 7 +++++ crates/ui-egui/src/i18n/ru.tsv | 7 +++++ crates/ui-egui/src/i18n/sv.tsv | 7 +++++ crates/ui-egui/src/i18n/tr.tsv | 7 +++++ crates/ui-egui/src/i18n/uk.tsv | 7 +++++ crates/ui-egui/src/i18n/vi.tsv | 7 +++++ crates/ui-egui/src/i18n/zh-hans.tsv | 7 +++++ crates/ui-egui/src/i18n/zh-hant.tsv | 7 +++++ crates/ui-egui/src/toolbar.rs | 1 - docs/gaps.md | 3 +- docs/mcp.md | 21 ++++++++++++++ docs/target-app-parity.md | 5 ++-- docs/ui-parity.md | 3 +- 47 files changed, 288 insertions(+), 53 deletions(-) rename crates/engine/src/cmd/{ => addons}/contour.rs (89%) rename crates/engine/src/{tests/contour.rs => cmd/addons/contour/tests.rs} (92%) rename crates/tools/src/{ => addons}/contour.rs (94%) create mode 100644 crates/ui-egui/src/addons/contour.rs diff --git a/ROADMAP.md b/ROADMAP.md index e7000874f..4115c31c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # VectorCraft Roadmap -- Contour tool (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. +- 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. @@ -13,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 (Contour tool, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; 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/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index e59331691..b1b9efd60 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -12,7 +12,6 @@ mod colorcmds; pub mod colormgmt; pub mod colortheme; mod compoundshape; -pub(crate) mod contour; pub(crate) mod create; mod css; mod cut; @@ -263,7 +262,6 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(xform::specs()); v.extend(effectcmd::specs()); v.extend(pathops::specs()); - v.extend(contour::specs()); v.extend(compoundshape::specs()); v.extend(typecmd::specs()); v.extend(menucmds::specs()); 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/contour.rs b/crates/engine/src/cmd/addons/contour.rs similarity index 89% rename from crates/engine/src/cmd/contour.rs rename to crates/engine/src/cmd/addons/contour.rs index 5bbf5652b..46df381ed 100644 --- a/crates/engine/src/cmd/contour.rs +++ b/crates/engine/src/cmd/addons/contour.rs @@ -1,18 +1,21 @@ -//! `object.contour`: the Contour tool's command. A Contour is a copy of a selected shape that +//! 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::edit::{copy_above, selected_roots}; -use super::pathops::{len_param, num_param}; -use super::{CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, has_selection, str_param}; +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 = "object.contour"; +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. @@ -20,7 +23,7 @@ const MAX_OFFSET: f64 = 1e5; pub fn specs() -> Vec { vec![cmd!( - "object.contour", + "addon.contour.set", "Contour", [], None, diff --git a/crates/engine/src/tests/contour.rs b/crates/engine/src/cmd/addons/contour/tests.rs similarity index 92% rename from crates/engine/src/tests/contour.rs rename to crates/engine/src/cmd/addons/contour/tests.rs index 164370c75..900d110d5 100644 --- a/crates/engine/src/tests/contour.rs +++ b/crates/engine/src/cmd/addons/contour/tests.rs @@ -1,4 +1,4 @@ -//! Tests for `object.contour`: a live Offset Path copy of a shape that stays editable until baked. +//! 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; @@ -46,7 +46,7 @@ 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("object.contour", &json!({"offset": 10})).unwrap(); + 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 @@ -70,7 +70,7 @@ fn baking_a_contour_gives_the_grown_and_the_shrunk_shape() { 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("object.contour", &json!({"offset": offset})).unwrap(); + 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. @@ -92,10 +92,10 @@ fn baked_fill_bounds(s: &Session, id: NodeId) -> Rect { 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("object.contour", &json!({"offset": 10})).unwrap(), "created"); + 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("object.contour", &json!({"offset": -4, "corners": "round"})).unwrap(); + 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"); @@ -110,7 +110,7 @@ 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("object.contour", &json!({"offset": 3, "corners": corners})).unwrap(), "created"); + 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}"); } } @@ -119,7 +119,7 @@ fn corner_styles_choose_the_join() { 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("object.contour", &json!({"offset": 6})).unwrap(), "created"); + 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 @@ -135,7 +135,7 @@ 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("object.contour", &json!({"offset": 6})).unwrap(); + 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); @@ -148,13 +148,13 @@ fn contour_refuses_what_it_cannot_offset() { 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("object.contour", ¶ms).is_err(), "{params}"); + 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("object.contour", &json!({"offset": 5})).unwrap_err().to_string(); + let err = s.execute("addon.contour.set", &json!({"offset": 5})).unwrap_err().to_string(); assert!(err.contains("select paths or shapes"), "{err}"); } @@ -169,9 +169,9 @@ fn border_with_a_gap_from_two_contours_and_minus_front() { // 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("object.contour", &json!({"offset": 8})).unwrap(), "created")[0]; + 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("object.contour", &json!({"offset": 20})).unwrap(), "created")[0]; + 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]); @@ -239,7 +239,7 @@ mod tool { 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("object.contour")); + 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); @@ -304,7 +304,7 @@ mod tool { #[test] fn dragging_a_contour_again_keeps_its_corners_and_miter_limit() { let (mut s, _) = ready(); - s.execute("object.contour", &json!({"offset": 5, "corners": "square", "miterLimit": 7})).unwrap(); + 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()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e78d828dc..9ec3ccaf9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -36,7 +36,6 @@ mod compoundpaint; mod constrain_new; mod constrain_tools; mod containers; -mod contour; mod css; mod cut; mod dashalign; 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/contour.rs b/crates/tools/src/addons/contour.rs similarity index 94% rename from crates/tools/src/contour.rs rename to crates/tools/src/addons/contour.rs index cf450752c..e77bb29ee 100644 --- a/crates/tools/src/contour.rs +++ b/crates/tools/src/addons/contour.rs @@ -1,5 +1,5 @@ //! 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 (`object.contour`). The copy is the original's outline offset by the distance +//! 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), @@ -16,9 +16,12 @@ use vectorcraft_geom::{BezPath, FillRule, Point, hit}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; -/// The live effect a Contour is made of (the one `object.contour` applies). +/// 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, `object.contour` accepts. +/// 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]; @@ -51,7 +54,7 @@ fn contour_offset(n: &Node) -> Option { 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 `object.contour` takes +/// 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)?; @@ -154,7 +157,7 @@ fn round_distance(v: f64, mods: Mods) -> f64 { impl Tool for ContourTool { fn id(&self) -> &'static str { - "contour" + ID } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { @@ -182,7 +185,7 @@ impl Tool for ContourTool { drag.last = Some(offset); let mut params = drag.style.clone(); params["offset"] = json!(offset); - vec![Action::Preview("object.contour".into(), params)] + vec![Action::Preview("addon.contour.set".into(), params)] } PointerKind::Up => match self.drag.take() { Some(d) if d.last.is_some() => vec![Action::Commit], @@ -249,8 +252,3 @@ impl Tool for ContourTool { self.drag.is_some() } } - -/// The Contour tool, for [`crate::create`]. -pub fn create(id: &str) -> Option> { - (id == "contour").then(|| Box::new(ContourTool::default()) as Box) -} diff --git a/crates/tools/src/catalog.rs b/crates/tools/src/catalog.rs index 49fe557ea..9bbd7cbdf 100644 --- a/crates/tools/src/catalog.rs +++ b/crates/tools/src/catalog.rs @@ -94,7 +94,6 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[ t("livePaintBucket", "Live Paint Bucket", Some("K"), "tool-bucket"), t("livePaintSelection", "Live Paint Selection Tool", Some("Shift+L"), "tool-live-select"), ], - &[t("contour", "Contour Tool", None, "tool-contour")], &[ t("perspectiveGrid", "Perspective Grid Tool", Some("Shift+P"), "tool-perspective"), t("perspectiveSelection", "Perspective Selection Tool", Some("Shift+V"), "tool-perspective-select"), diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index d14e43c67..ee00fdbe9 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -11,7 +11,6 @@ pub mod addons; pub mod bbox; pub mod builder; pub mod catalog; -pub mod contour; pub mod corners; pub mod cropimage; pub mod cut; @@ -297,7 +296,6 @@ pub fn create(id: &str) -> Box { .or_else(|| printtiling::create(other)) .or_else(|| cropimage::create(other)) .or_else(|| cut::create(other)) - .or_else(|| contour::create(other)) .or_else(|| intertwine::create(other)) .or_else(|| objects_on_path::create(other)) .or_else(|| touchtype::create(other)) 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/contour_bar.rs b/crates/ui-egui/src/canvas/contour_bar.rs index 443fdc2aa..4b560eeba 100644 --- a/crates/ui-egui/src/canvas/contour_bar.rs +++ b/crates/ui-egui/src/canvas/contour_bar.rs @@ -1,6 +1,6 @@ //! 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 `object.contour` and +//! 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; @@ -98,7 +98,7 @@ pub(super) fn contour_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { if resp.drag_stopped() || resp.lost_focus() { ui.data_mut(|d| d.remove_temp::(key)); if (v - shown).abs() > 1e-9 { - run = Some(("object.contour", json!({"offset": v, "corners": corners}))); + run = Some(("addon.contour.set", json!({"offset": v, "corners": corners}))); } } for (label, id) in [(tl!("Sharp"), "sharp"), (tl!("Round"), "round"), (tl!("Square"), "square")] { @@ -119,7 +119,7 @@ pub(super) fn contour_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { if result.is_ok() && let Some(s) = &stand { - result = app.run("object.contour", json!({"offset": s.offset, "corners": id})); + result = app.run("addon.contour.set", json!({"offset": s.offset, "corners": id})); } if let Err(e) = result { app.ui.status = e; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index da52ed353..3f6f9f0ff 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -9053,7 +9053,7 @@ fn the_contour_bar_sets_corners_and_bakes() { assert!(offsets(&app).is_empty()); // A Contour made now has round corners; Square changes the selected Contour in place. - app.session.execute("object.contour", &json!({"offset": 8, "corners": "round"})).unwrap(); + 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"); diff --git a/crates/ui-egui/src/edit_toolbar.rs b/crates/ui-egui/src/edit_toolbar.rs index dcec1ffc5..eb79cf357 100644 --- a/crates/ui-egui/src/edit_toolbar.rs +++ b/crates/ui-egui/src/edit_toolbar.rs @@ -410,7 +410,7 @@ fn heading(id: &str) -> &'static str { _ if id.starts_with("perspective") => "Perspective", "artboard" => "Artboards", "slice" | "sliceSelection" => "Slices", - "objectsOnPath" | "contour" => "Modify", + "objectsOnPath" => "Modify", _ => "More", } } 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/toolbar.rs b/crates/ui-egui/src/toolbar.rs index fcb9a1cc9..94054a0d4 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -59,7 +59,6 @@ pub const BASIC: &[(&str, &[&[&str]])] = &[ &["rotate", "reflect", "scale", "shear", "reshape", "freeTransform", "puppetWarp"], &["shapeBuilder", "livePaintBucket", "livePaintSelection", "blend"], &["dimension"], - &["contour"], ], ), ("Type", &[&["areaType", "typeOnPath", "verticalAreaType", "verticalTypeOnPath"], &["type", "verticalType", "touchType"]]), diff --git a/docs/gaps.md b/docs/gaps.md index 92ee96cb9..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 (Contour tool, 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) +> **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,7 @@ 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. | 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 76c4c4ab2..4b90d9967 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-05 · **Change:** minor (Contour tool, beyond Illustrator; scores and estimates unchanged; Select by Artboards add-on selects by overlap, inside or outside several artboards; 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; scores and estimates unchanged; Optical kerning spaces pairs by the font's own stem spacing instead of a fixed tight gap; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -482,7 +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 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 (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); `object.contour` makes or changes one over MCP and the control channel. 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/tests/contour.rs`, `crates/ui-egui/src/canvas/tests.rs` | +| **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 @@ -1650,6 +1650,7 @@ 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. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 282eea9b6..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 (Contour tool, 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) +> **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,7 @@ 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 |