mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 12:13:19 +00:00
Contour: live offset tool with a floating bar and its own icon
Add a Contour tool (Modify group). Dragging away from a selected path or shape's edge makes a copy of it that carries a live Offset Path effect, grown by the drag; dragging toward the inside shrinks it. The result is one undo step and stays editable until baked with Expand Appearance. A second drag on a selected Contour changes its distance instead of nesting another effect. - object.contour: the command behind it (offset, sharp/round/square corners, miter limit), usable over MCP and the control channel too. - Contour tool: live preview, Shift rounds to whole points, Escape cancels, a dashed guide with a white halo shows the outline the shape had, and a pill shows the distance. - Contour bar: a floating bar above the selection with the offset box, Sharp / Round / Square and Bake (Expand Appearance). - Toolbar slot under Modify (Basic and Advanced) and dc-contour.svg, an original icon with its ASSETS.md row. - Tests for the command, the tool's pointer gestures and the bar; the border-with-a-gap recipe (two Contours, bake, Minus Front) is covered end to end. - Docs: beyond-Illustrator row, revision histories and the ROADMAP log. Beyond Illustrator: a VectorCraft design built on Offset Path. Scores and estimates unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Sonnet 5.5
parent
8e97819769
commit
8584bf1ab6
@@ -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
@@ -1,5 +1,7 @@
|
||||
# VectorCraft Roadmap
|
||||
|
||||
- Contour tool (beyond Illustrator): drag a selected shape's edge outward or inward for a live, editable offset copy, with a floating bar for distance and Sharp, Round or Square corners; Bake turns it into paths. Scores and estimates unchanged.
|
||||
|
||||
- Paint: new gradients use the document's color model for default stops, including Gradient tool creation. Scores and estimates unchanged.
|
||||
|
||||
- PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document.
|
||||
@@ -11,7 +13,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Type Tools add-on: Release Type on a Path, #5532; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Contour tool, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
|
||||
@@ -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 |
@@ -12,6 +12,7 @@ mod colorcmds;
|
||||
pub mod colormgmt;
|
||||
pub mod colortheme;
|
||||
mod compoundshape;
|
||||
pub(crate) mod contour;
|
||||
pub(crate) mod create;
|
||||
mod css;
|
||||
mod cut;
|
||||
@@ -262,6 +263,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(xform::specs());
|
||||
v.extend(effectcmd::specs());
|
||||
v.extend(pathops::specs());
|
||||
v.extend(contour::specs());
|
||||
v.extend(compoundshape::specs());
|
||||
v.extend(typecmd::specs());
|
||||
v.extend(menucmds::specs());
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! `object.contour`: the Contour tool's command. A Contour is a copy of a selected shape that
|
||||
//! carries a live Offset Path effect, so it grows or shrinks the shape without baking anything: its
|
||||
//! fill, stroke and effect stay editable, and Expand Appearance bakes it into plain paths. The
|
||||
//! original stays where it is. Running the command on an object that already carries an Offset
|
||||
//! Path effect (a Contour made earlier) changes that effect instead of nesting another one.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::edit::{copy_above, selected_roots};
|
||||
use super::pathops::{len_param, num_param};
|
||||
use super::{CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, has_selection, str_param};
|
||||
|
||||
const C: &str = "object.contour";
|
||||
/// The live effect that does the offsetting (the one Effect › Path › Offset Path applies).
|
||||
const EFFECT: &str = "path.offsetPath";
|
||||
/// The largest offset, in points; Offset Path takes the same.
|
||||
const MAX_OFFSET: f64 = 1e5;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"object.contour",
|
||||
"Contour",
|
||||
[],
|
||||
None,
|
||||
"{offset: pt (negative insets), corners?: \"sharp\"|\"round\"|\"square\" (default sharp; square cuts each corner off flat), miterLimit?: 1..500 (4; sharp corners only)} make a copy of each selected path or shape directly above it with a live Offset Path effect, or, for a selected object that already has one (a Contour), change that effect: nothing is baked (Expand Appearance bakes it) and the original is kept → {ids, created, updated}",
|
||||
has_selection,
|
||||
contour
|
||||
)]
|
||||
}
|
||||
|
||||
/// The Offset Path join a Contour corner style stands for.
|
||||
fn joins_for(corners: Option<&str>) -> Result<&'static str> {
|
||||
match corners.map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("sharp") => Ok("miter"),
|
||||
Some("round") => Ok("round"),
|
||||
Some("square") => Ok("bevel"),
|
||||
Some(other) => Err(bad(C, format!("`corners` must be sharp, round or square, not `{other}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn contour(s: &mut Session, p: &Value) -> Result<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) }))
|
||||
}
|
||||
@@ -36,6 +36,7 @@ mod compoundpaint;
|
||||
mod constrain_new;
|
||||
mod constrain_tools;
|
||||
mod containers;
|
||||
mod contour;
|
||||
mod css;
|
||||
mod cut;
|
||||
mod dashalign;
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Tests for `object.contour`: a live Offset Path copy of a shape that stays editable until baked.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn ids_of(v: &Value, key: &str) -> Vec<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("object.contour", &json!({"offset": 10})).unwrap();
|
||||
let (made, updated) = (ids_of(&r, "created"), ids_of(&r, "updated"));
|
||||
assert_eq!((made.len(), updated.len()), (1, 0));
|
||||
// The original is untouched, the copy is above it and selected, and nothing is baked: the
|
||||
// copy's own path is still the original's, with the effect on top.
|
||||
assert!(effects_of(&s, a).is_empty());
|
||||
assert_eq!(bounds(&s, a), before);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, made);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.index_path(made[0]).unwrap() > d.index_path(a).unwrap());
|
||||
assert_eq!(bounds(&s, made[0]), before);
|
||||
let fx = effects_of(&s, made[0]);
|
||||
assert_eq!(fx.len(), 1);
|
||||
assert_eq!(fx[0].0, "path.offsetPath");
|
||||
assert_eq!(fx[0].1["offset"], json!(10.0));
|
||||
assert_eq!(fx[0].1["joins"], json!("miter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baking_a_contour_gives_the_grown_and_the_shrunk_shape() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
|
||||
for (offset, want) in [(10.0, Rect::new(90.0, 90.0, 160.0, 150.0)), (-5.0, Rect::new(105.0, 105.0, 145.0, 135.0))] {
|
||||
select(&mut s, &[a]);
|
||||
s.execute("object.contour", &json!({"offset": offset})).unwrap();
|
||||
let baked = ids_of(&s.execute("effect.expandAppearance", &json!({})).unwrap(), "ids");
|
||||
let b = bounds(&s, baked[0]);
|
||||
// The default stroke widens the baked art by its weight; compare the fill's edges.
|
||||
let fill = baked_fill_bounds(&s, baked[0]);
|
||||
assert!((fill.x0 - want.x0).abs() < 1e-2 && (fill.y1 - want.y1).abs() < 1e-2, "{offset}: {fill:?} vs {want:?} ({b:?})");
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounds of the fill path inside a baked object (a group of its fill and stroke), or of the
|
||||
/// object itself when it is one path.
|
||||
fn baked_fill_bounds(s: &Session, id: NodeId) -> Rect {
|
||||
match &node(s, id).kind {
|
||||
NodeKind::Group { children, .. } => children.iter().filter_map(|c| c.geometric_bounds()).next().unwrap(),
|
||||
_ => bounds(s, id),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_drag_changes_the_contour_instead_of_nesting_one() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
|
||||
let made = ids_of(&s.execute("object.contour", &json!({"offset": 10})).unwrap(), "created");
|
||||
let count = node_count(&s);
|
||||
// The Contour is selected: the next call edits it.
|
||||
let r = s.execute("object.contour", &json!({"offset": -4, "corners": "round"})).unwrap();
|
||||
assert_eq!(ids_of(&r, "updated"), made);
|
||||
assert!(ids_of(&r, "created").is_empty());
|
||||
assert_eq!(node_count(&s), count, "no new object");
|
||||
let fx = effects_of(&s, made[0]);
|
||||
assert_eq!(fx.len(), 1, "one Offset Path, not two");
|
||||
assert_eq!((fx[0].1["offset"].clone(), fx[0].1["joins"].clone()), (json!(-4.0), json!("round")));
|
||||
assert!(effects_of(&s, a).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_styles_choose_the_join() {
|
||||
for (corners, joins) in [("sharp", "miter"), ("round", "round"), ("square", "bevel"), ("Round", "round")] {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 30.0, 30.0);
|
||||
let made = ids_of(&s.execute("object.contour", &json!({"offset": 3, "corners": corners})).unwrap(), "created");
|
||||
assert_eq!(effects_of(&s, made[0])[0].1["joins"], json!(joins), "{corners}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contour_keeps_the_color_and_stroke_editable_on_the_copy() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 30.0, 30.0);
|
||||
let made = ids_of(&s.execute("object.contour", &json!({"offset": 6})).unwrap(), "created");
|
||||
let original = node(&s, a).appearance.items.clone();
|
||||
assert_eq!(node(&s, made[0]).appearance.items, original, "the copy starts with the shape's own paint");
|
||||
// Recolouring the Contour (it is the selection) changes its fill only: the live effect stays
|
||||
// and the original keeps its paint.
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
assert_ne!(node(&s, made[0]).appearance.items, original, "the copy took the new fill");
|
||||
assert_eq!(node(&s, a).appearance.items, original, "the original is untouched");
|
||||
assert_eq!(effects_of(&s, made[0]).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contour_is_one_undo_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 30.0, 30.0);
|
||||
let count = node_count(&s);
|
||||
s.execute("object.contour", &json!({"offset": 6})).unwrap();
|
||||
assert_eq!(node_count(&s), count + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node_count(&s), count);
|
||||
assert!(effects_of(&s, a).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contour_refuses_what_it_cannot_offset() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 30.0, 30.0);
|
||||
let count = node_count(&s);
|
||||
for params in [json!({}), json!({"offset": "abc"}), json!({"offset": 1e300}), json!({"offset": 5, "corners": "pointy"})] {
|
||||
assert!(s.execute("object.contour", ¶ms).is_err(), "{params}");
|
||||
}
|
||||
// Nothing was added by the refused calls.
|
||||
assert_eq!(node_count(&s), count);
|
||||
// Type is not a path to offset.
|
||||
s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Hi"})).unwrap();
|
||||
let err = s.execute("object.contour", &json!({"offset": 5})).unwrap_err().to_string();
|
||||
assert!(err.contains("select paths or shapes"), "{err}");
|
||||
}
|
||||
|
||||
/// The border-with-a-gap recipe: two Contours of one shape (the gap, then the border), baked, then
|
||||
/// the gap taken out of the border with Minus Front, leaves a ring between the two.
|
||||
#[test]
|
||||
fn border_with_a_gap_from_two_contours_and_minus_front() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
|
||||
// Strip the default stroke so the areas are plain.
|
||||
s.execute("paint.setStroke", &json!({"none": true})).ok();
|
||||
// The gap first (+8), then the border (+20) from the same shape: both land directly above it,
|
||||
// the later one lower, so the gap ends up in front of the border.
|
||||
select(&mut s, &[a]);
|
||||
let gap = ids_of(&s.execute("object.contour", &json!({"offset": 8})).unwrap(), "created")[0];
|
||||
select(&mut s, &[a]);
|
||||
let border = ids_of(&s.execute("object.contour", &json!({"offset": 20})).unwrap(), "created")[0];
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.index_path(gap).unwrap() > d.index_path(border).unwrap(), "the gap is in front of the border");
|
||||
select(&mut s, &[border, gap]);
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
let both: Vec<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("object.contour"));
|
||||
let made = s.doc().unwrap().selection.objects.clone();
|
||||
assert_eq!(made.len(), 1);
|
||||
assert_ne!(made[0], a);
|
||||
assert!((offset_of(&s, made[0]) - 10.0).abs() < 1e-6, "{}", offset_of(&s, made[0]));
|
||||
assert!(effects_of(&s, a).is_empty(), "the original is kept as it was");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node_count(&s), nodes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_in_shrinks_and_a_second_drag_continues_from_where_the_contour_stands() {
|
||||
let (mut s, _) = ready();
|
||||
// From inside, 5 pt toward the middle of the right edge's neighbourhood.
|
||||
at(&mut s, PointerKind::Down, 145.0, 120.0, Mods::default());
|
||||
at(&mut s, PointerKind::Drag, 140.0, 120.0, Mods::default());
|
||||
at(&mut s, PointerKind::Up, 140.0, 120.0, Mods::default());
|
||||
let contour = s.doc().unwrap().selection.objects[0];
|
||||
assert!((offset_of(&s, contour) + 5.0).abs() < 1e-6);
|
||||
// The Contour is selected: pressing at the edge and pulling out 12 pt adds to its -5.
|
||||
let nodes = node_count(&s);
|
||||
at(&mut s, PointerKind::Down, 150.0, 120.0, Mods::default());
|
||||
at(&mut s, PointerKind::Drag, 162.0, 120.0, Mods::default());
|
||||
at(&mut s, PointerKind::Up, 162.0, 120.0, Mods::default());
|
||||
assert_eq!(node_count(&s), nodes, "the same Contour changed, none was nested");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![contour]);
|
||||
assert_eq!(effects_of(&s, contour).len(), 1);
|
||||
assert!((offset_of(&s, contour) - 7.0).abs() < 1e-6, "{}", offset_of(&s, contour));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_and_a_plain_click_change_nothing() {
|
||||
let (mut s, _) = ready();
|
||||
let (before, nodes) = (undo_depth(&s), node_count(&s));
|
||||
at(&mut s, PointerKind::Down, 150.0, 120.0, Mods::default());
|
||||
at(&mut s, PointerKind::Drag, 170.0, 120.0, Mods::default());
|
||||
assert!(s.in_interaction());
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), view()).unwrap();
|
||||
at(&mut s, PointerKind::Up, 170.0, 120.0, Mods::default());
|
||||
assert!(!s.in_interaction());
|
||||
assert_eq!((undo_depth(&s), node_count(&s)), (before, nodes));
|
||||
// A click with no movement.
|
||||
at(&mut s, PointerKind::Down, 150.0, 120.0, Mods::default());
|
||||
at(&mut s, PointerKind::Up, 150.0, 120.0, Mods::default());
|
||||
assert_eq!((undo_depth(&s), node_count(&s)), (before, nodes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_rounds_to_whole_points_and_the_corner_option_is_used() {
|
||||
let (mut s, _) = ready();
|
||||
s.set_tool_option("corners", &json!("round"));
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
at(&mut s, PointerKind::Down, 150.0, 120.0, shift);
|
||||
at(&mut s, PointerKind::Drag, 160.4, 120.0, shift);
|
||||
at(&mut s, PointerKind::Up, 160.4, 120.0, shift);
|
||||
let contour = s.doc().unwrap().selection.objects[0];
|
||||
assert_eq!(offset_of(&s, contour), 10.0);
|
||||
assert_eq!(effects_of(&s, contour)[0].1["joins"], json!("round"));
|
||||
}
|
||||
|
||||
/// The outline a Contour was made from shows as a dashed guide over a solid halo (so it reads
|
||||
/// over any fill); a shape that is not a Contour yet has none.
|
||||
#[test]
|
||||
fn a_selected_contour_shows_the_outline_it_was_made_from() {
|
||||
use vectorcraft_tools::Overlay;
|
||||
let (mut s, _) = ready();
|
||||
let paths = |s: &mut Session| -> Vec<(bool, f32)> {
|
||||
s.overlays(view())
|
||||
.into_iter()
|
||||
.filter_map(|o| match o {
|
||||
Overlay::Path { dashed, width, .. } => Some((dashed, width)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert!(paths(&mut s).is_empty(), "a plain shape has no guide");
|
||||
at(&mut s, PointerKind::Down, 150.0, 120.0, Mods::default());
|
||||
at(&mut s, PointerKind::Drag, 160.0, 120.0, Mods::default());
|
||||
at(&mut s, PointerKind::Up, 160.0, 120.0, Mods::default());
|
||||
// The solid halo first, the dashes on top of it.
|
||||
assert_eq!(paths(&mut s), [(false, 3.0), (true, 1.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_selected_does_nothing() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 100.0, 100.0, 50.0, 40.0);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.select_tool("contour", view()).unwrap();
|
||||
let before = undo_depth(&s);
|
||||
at(&mut s, PointerKind::Down, 150.0, 120.0, Mods::default());
|
||||
at(&mut s, PointerKind::Drag, 170.0, 120.0, Mods::default());
|
||||
at(&mut s, PointerKind::Up, 170.0, 120.0, Mods::default());
|
||||
assert_eq!(undo_depth(&s), before);
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[
|
||||
t("livePaintBucket", "Live Paint Bucket", Some("K"), "tool-bucket"),
|
||||
t("livePaintSelection", "Live Paint Selection Tool", Some("Shift+L"), "tool-live-select"),
|
||||
],
|
||||
&[t("contour", "Contour Tool", None, "tool-contour")],
|
||||
&[
|
||||
t("perspectiveGrid", "Perspective Grid Tool", Some("Shift+P"), "tool-perspective"),
|
||||
t("perspectiveSelection", "Perspective Selection Tool", Some("Shift+V"), "tool-perspective-select"),
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
//! The Contour tool: drag away from a selected shape's edge to grow a live copy of it, or toward the
|
||||
//! inside to shrink it (`object.contour`). The copy is the original's outline offset by the distance
|
||||
//! the pointer has moved from the edge, previewed while the button is down and kept as one undo step
|
||||
//! on release. Nothing is baked: the copy carries a live Offset Path effect, so it keeps being
|
||||
//! editable (a second drag on a selected Contour changes its distance instead of nesting another),
|
||||
//! and Expand Appearance bakes it. While a Contour is selected the tool draws the outline the
|
||||
//! shape had before as a dashed guide.
|
||||
//!
|
||||
//! The distance is how far the pointer has moved from the shape's edge since the press, so a press
|
||||
//! anywhere changes nothing until the pointer moves, and a Contour that already stands at some
|
||||
//! distance continues from there. Shift rounds the distance to whole points.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::{BezPath, FillRule, Point, hit};
|
||||
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
/// The live effect a Contour is made of (the one `object.contour` applies).
|
||||
const EFFECT: &str = "path.offsetPath";
|
||||
/// The largest distance, in points, `object.contour` accepts.
|
||||
const MAX_OFFSET: f64 = 1e5;
|
||||
/// The guide showing the shape a Contour was made from.
|
||||
const GUIDE: [u8; 3] = [0x1e, 0x88, 0xe5];
|
||||
|
||||
/// A selected shape's outline, in the document.
|
||||
struct Base {
|
||||
path: BezPath,
|
||||
rule: FillRule,
|
||||
}
|
||||
|
||||
fn base_of(n: &Node) -> Option<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))
|
||||
}
|
||||
|
||||
/// 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 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 {
|
||||
fn params(&self, offset: f64) -> Value {
|
||||
let mut p = json!({"offset": offset, "corners": self.corners.id()});
|
||||
if let Some(m) = self.miter_limit {
|
||||
p["miterLimit"] = json!(m);
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected shapes a Contour can be made of.
|
||||
fn selected_shapes<'a>(cx: &'a ToolContext) -> Vec<&'a Node> {
|
||||
cx.selection
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|id| cx.doc.node(*id))
|
||||
.filter(|n| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn round_distance(v: f64, mods: Mods) -> f64 {
|
||||
let v = if mods.shift { v.round() } else { (v * 100.0).round() / 100.0 };
|
||||
v.clamp(-MAX_OFFSET, MAX_OFFSET)
|
||||
}
|
||||
|
||||
impl Tool for ContourTool {
|
||||
fn id(&self) -> &'static str {
|
||||
"contour"
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<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);
|
||||
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, last: None, pos: ev.pos });
|
||||
vec![Action::Begin("Contour".into())]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
let mut params = self.params(0.0);
|
||||
let Some(drag) = self.drag.as_mut() else { return vec![] };
|
||||
drag.pos = ev.pos;
|
||||
let Some(now) = signed_distance(&drag.bases, ev.pos) else { return vec![] };
|
||||
let offset = round_distance(drag.start + now - drag.pressed_at, ev.mods);
|
||||
if !offset.is_finite() || drag.last == Some(offset) {
|
||||
return vec![];
|
||||
}
|
||||
drag.last = Some(offset);
|
||||
params["offset"] = json!(offset);
|
||||
vec![Action::Preview("object.contour".into(), params)]
|
||||
}
|
||||
PointerKind::Up => match self.drag.take() {
|
||||
Some(d) if d.last.is_some() => vec![Action::Commit],
|
||||
Some(_) => vec![Action::Cancel],
|
||||
None => vec![],
|
||||
},
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<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()
|
||||
}
|
||||
}
|
||||
|
||||
/// The Contour tool, for [`crate::create`].
|
||||
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||
(id == "contour").then(|| Box::new(ContourTool::default()) as Box<dyn Tool>)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ pub mod addons;
|
||||
pub mod bbox;
|
||||
pub mod builder;
|
||||
pub mod catalog;
|
||||
pub mod contour;
|
||||
pub mod corners;
|
||||
pub mod cropimage;
|
||||
pub mod cut;
|
||||
@@ -296,6 +297,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
|
||||
.or_else(|| printtiling::create(other))
|
||||
.or_else(|| cropimage::create(other))
|
||||
.or_else(|| cut::create(other))
|
||||
.or_else(|| contour::create(other))
|
||||
.or_else(|| intertwine::create(other))
|
||||
.or_else(|| objects_on_path::create(other))
|
||||
.or_else(|| touchtype::create(other))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! The Contour bar: a floating bar above the selection while the Contour tool is active. It has the
|
||||
//! Contour's distance in a number box, its corner style (Sharp, Round or Square) and Bake, which
|
||||
//! turns the live Contour into plain paths. It acts through `object.contour` and
|
||||
//! `effect.expandAppearance`, so everything it does is a command with its own undo step.
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
|
||||
use super::{CornerRadius, Stroke, Tokens, Ui, VectorcraftApp, Xf, json, pos2, vec2};
|
||||
|
||||
/// The live effect a Contour is made of.
|
||||
const EFFECT: &str = "path.offsetPath";
|
||||
|
||||
fn bar_id() -> egui::Id {
|
||||
egui::Id::unique("contour-bar")
|
||||
}
|
||||
|
||||
/// Where the Contour bar was last drawn (none while it isn't shown).
|
||||
#[cfg(test)]
|
||||
pub(super) fn bar_rect(ctx: &egui::Context) -> Option<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(("object.contour", json!({"offset": v, "corners": corners})));
|
||||
}
|
||||
}
|
||||
for (label, id) in [(tl!("Sharp"), "sharp"), (tl!("Round"), "round"), (tl!("Square"), "square")] {
|
||||
if ui.selectable_label(corners == id, label).clicked() {
|
||||
corner_pick = Some(id);
|
||||
}
|
||||
}
|
||||
let bake = ui.add_enabled(stand.is_some(), egui::Button::new(tl!("Bake")));
|
||||
if bake.on_hover_text(tl!("Turn the Contour into plain paths")).clicked() {
|
||||
run = Some(("effect.expandAppearance", json!({})));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(id) = corner_pick {
|
||||
// The tool keeps the style for the next Contour; a selected Contour changes now.
|
||||
let mut result = app.run("tool.setOption", json!({"key": "corners", "value": id}));
|
||||
if result.is_ok()
|
||||
&& let Some(s) = &stand
|
||||
{
|
||||
result = app.run("object.contour", json!({"offset": s.offset, "corners": id}));
|
||||
}
|
||||
if let Err(e) = result {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
if let Some((cmd, params)) = run
|
||||
&& let Err(e) = app.run(cmd, params)
|
||||
{
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
@@ -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("object.contour", &json!({"offset": 8, "corners": "round"})).unwrap();
|
||||
assert_eq!(offsets(&app), ["round"]);
|
||||
click_contour_bar(&mut app, &ctx, "Square");
|
||||
assert_eq!(offsets(&app), ["bevel"], "the selected Contour changed, none was added");
|
||||
|
||||
// Bake: the live effect is gone and the art stays.
|
||||
click_contour_bar(&mut app, &ctx, "Bake");
|
||||
assert!(offsets(&app).is_empty(), "baked");
|
||||
}
|
||||
|
||||
@@ -410,7 +410,7 @@ fn heading(id: &str) -> &'static str {
|
||||
_ if id.starts_with("perspective") => "Perspective",
|
||||
"artboard" => "Artboards",
|
||||
"slice" | "sliceSelection" => "Slices",
|
||||
"objectsOnPath" => "Modify",
|
||||
"objectsOnPath" | "contour" => "Modify",
|
||||
_ => "More",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -59,6 +59,7 @@ pub const BASIC: &[(&str, &[&[&str]])] = &[
|
||||
&["rotate", "reflect", "scale", "shear", "reshape", "freeTransform", "puppetWarp"],
|
||||
&["shapeBuilder", "livePaintBucket", "livePaintSelection", "blend"],
|
||||
&["dimension"],
|
||||
&["contour"],
|
||||
],
|
||||
),
|
||||
("Type", &[&["areaType", "typeOnPath", "verticalAreaType", "verticalTypeOnPath"], &["type", "verticalType", "touchType"]]),
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Contour tool, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -28,6 +28,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-04 | minor | Contour tool (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `object.contour` is the command behind it. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. |
|
||||
| 2026-11-05 | minor | G12: Hyphenation › Default Language sets the language of new type unless Document Setup chose one, stored as a tag; each run hyphenates in its own language, English only so far, so 2 Preferences options stay unused (#394) |
|
||||
| 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. |
|
||||
| 2026-11-02 | minor | Font-family fields step selected text with the mouse wheel (#5253); no scored gap or estimate reassessed. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (document tabs are reordered by dragging them along the tab strip; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Contour tool, beyond Illustrator; scores and estimates unchanged; Select by Artboards add-on selects by overlap, inside or outside several artboards; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -482,6 +482,7 @@ since 2026-10-01.
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel; synthetic-input counts, pending events and per-frame control requests are bounded (#1709); what an agent reads and writes on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root`, as in PhotoCraft, #832) |
|
||||
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
|
||||
| **Align Stroke on live type** | beyond Illustrator | The Stroke panel's Align Stroke works on type's character stroke: inside or outside the glyphs without Create Outlines, the type staying editable (as InDesign does; users have asked Illustrator for it since 2010, e.g. [redd.it/1dwhos6](https://redd.it/1dwhos6)). Canvas, PDF and EPS clip it to its side of the glyphs; SVG keeps the text live and draws the stroke on the glyph outlines (#2294) |
|
||||
| **Contour tool** | beyond Illustrator | Drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it (Shift: whole points); the copy carries a live Offset Path effect so it stays editable, with its own fill and stroke, until Bake (Expand Appearance). A second drag on a selected Contour changes it instead of nesting another; Escape cancels. A floating bar above the selection sets the distance and the corners (Sharp, Round, Square); `object.contour` makes or changes one over MCP and the control channel. Two Contours baked and combined with Minus Front give a border with a gap. Paths, shapes and compound paths only; type isn't offset. Tests: `crates/engine/src/tests/contour.rs`, `crates/ui-egui/src/canvas/tests.rs` |
|
||||
|
||||
## Evidence: what ships today, by area
|
||||
|
||||
@@ -1649,6 +1650,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-04 | minor | Contour tool (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `object.contour` is the command behind it. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. |
|
||||
| 2026-11-05 | minor | View & navigation: document tabs are reordered by dragging them along the tab strip (`document.move`, #5663); evidence added; scores unchanged. |
|
||||
| 2026-10-30 | minor | Effects: Color Halftone's Max Radius is 4–127 px of the Document Raster Effects resolution, shown in px whatever the units; older documents open converted, and files saved for older app versions hold points (#3260); scores unchanged. |
|
||||
| 2026-11-01 | minor | Batch Trace Folder (`imageTrace.batchFolder`, #4921); evidence added; scores unchanged. |
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (document tabs are reordered by dragging them along the tab strip; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Contour tool, beyond Illustrator; scores and estimates unchanged; Select by Artboards add-on selects by overlap, inside or outside several artboards; Cmd+Space keeps the active drawing cursor during its drag; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -437,6 +437,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-04 | minor | Contour tool (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `object.contour` is the command behind it. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. |
|
||||
| 2026-11-05 | minor | Document tabs are reordered by dragging them along the tab strip (`document.move`, #5663). |
|
||||
| 2026-11-04 | minor | Path Precision Tools add-on (a VectorCraft addition): Set Corners… rounds only the corners in an angle range (#5545); scores and estimates unchanged |
|
||||
| 2026-11-04 | minor | Path Precision Tools add-on (a VectorCraft addition): Set Corners… rounds all, the odd-numbered or the even-numbered anchors (#5546); scores and estimates unchanged |
|
||||
|
||||
Reference in New Issue
Block a user