Merge pull request #5694 from K1nsmark/contour-tool

Contour: live offset tool with a floating bar and its own icon
This commit is contained in:
BFlat
2026-11-05 12:58:08 +07:00
committed by GitHub
46 changed files with 1180 additions and 8 deletions
+1
View File
@@ -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 | |
+3 -1
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="10" height="10" rx="1.5"/><rect x="3.5" y="3.5" width="17" height="17" rx="5" stroke-dasharray="7.854 7" stroke-dashoffset="7.854"/></svg>

After

Width:  |  Height:  |  Size: 347 B

+2
View File
@@ -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<CommandSpec> {
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());
+85
View File
@@ -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<CommandSpec> {
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<Value> {
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::<Vec<_>>();
Ok(json!({ "ids": list(&[updated.as_slice(), created.as_slice()].concat()), "created": list(&created), "updated": list(&updated) }))
}
@@ -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<NodeId> {
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::<Vec<_>>()})).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", &params).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<NodeId> = 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);
}
}
+8 -2
View File
@@ -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<Box<dyn Tool>> {
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());
+254
View File
@@ -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<Base> {
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<f64> {
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<Value> {
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<f64> {
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<Base>,
/// 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<f64>,
/// Where the pointer is, for the distance label.
pos: Point,
}
#[derive(Default)]
pub struct ContourTool {
drag: Option<Drag>,
corners: Corners,
miter_limit: Option<f64>,
}
/// 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<Self> {
[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<Action> {
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<Base> = 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<Action> {
match key {
ToolKey::Escape if self.drag.take().is_some() => vec![Action::Cancel],
_ => vec![],
}
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
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()
}
}
+2
View File
@@ -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> {
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()),
+12
View File
@@ -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<Item> {
vec![Item::Cmd("Contour Tool", "tool.select", json!({"tool": contour::ID}))]
}
+4
View File
@@ -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);
}
+133
View File
@@ -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<egui::Rect> {
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<Standing> {
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::<f64>(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::<f64>(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;
}
}
+74
View File
@@ -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<egui::Event>) -> 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<String> = 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");
}
+15 -2
View File
@@ -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}");
}
+7
View File
@@ -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 Нечетни опорни точки
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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 Μονά σημεία αγκύρωσης
Can't render this file because it contains an unexpected character in line 163 and column 5.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2235 and column 5.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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 奇数番目のアンカー
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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 კენტი საყრდენი წერტილები
Can't render this file because it is too large.
+7
View File
@@ -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 홀수 번째 앵커
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -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 Нечётные опорные точки
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+7
View File
@@ -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 Непарні опорні точки
Can't render this file because it contains an unexpected character in line 177 and column 5.
+7
View File
@@ -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ẻ
Can't render this file because it contains an unexpected character in line 199 and column 5.
+7
View File
@@ -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 奇数锚点
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -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 奇數錨點
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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")),
+1
View File
@@ -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",
+3 -1
View File
@@ -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. |
+21
View File
@@ -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
+4 -1
View File
@@ -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. |
+3 -1
View File
@@ -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 |