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