mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 21:14:18 +00:00
Merge upstream/main into fix/2414-svg-classes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> # Conflicts: # docs/target-app-parity.md
This commit is contained in:
@@ -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 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
|
||||
|
||||
@@ -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 |
@@ -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());
|
||||
|
||||
@@ -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", ¶ms).is_err(), "{params}");
|
||||
}
|
||||
// Nothing was added by the refused calls.
|
||||
assert_eq!(node_count(&s), count);
|
||||
// Type is not a path to offset.
|
||||
s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Hi"})).unwrap();
|
||||
let err = s.execute("addon.contour.set", &json!({"offset": 5})).unwrap_err().to_string();
|
||||
assert!(err.contains("select paths or shapes"), "{err}");
|
||||
}
|
||||
|
||||
/// The border-with-a-gap recipe: two Contours of one shape (the gap, then the border), baked, then
|
||||
/// the gap taken out of the border with Minus Front, leaves a ring between the two.
|
||||
#[test]
|
||||
fn border_with_a_gap_from_two_contours_and_minus_front() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
|
||||
// Strip the default stroke so the areas are plain.
|
||||
s.execute("paint.setStroke", &json!({"none": true})).ok();
|
||||
// The gap first (+8), then the border (+20) from the same shape: both land directly above it,
|
||||
// the later one lower, so the gap ends up in front of the border.
|
||||
select(&mut s, &[a]);
|
||||
let gap = ids_of(&s.execute("addon.contour.set", &json!({"offset": 8})).unwrap(), "created")[0];
|
||||
select(&mut s, &[a]);
|
||||
let border = ids_of(&s.execute("addon.contour.set", &json!({"offset": 20})).unwrap(), "created")[0];
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.index_path(gap).unwrap() > d.index_path(border).unwrap(), "the gap is in front of the border");
|
||||
select(&mut s, &[border, gap]);
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
let both: Vec<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);
|
||||
}
|
||||
}
|
||||
@@ -262,7 +262,59 @@ impl Form {
|
||||
/// type, linked files, objects with an opacity mask, clipping paths or guides (the help page lists
|
||||
/// images, text, masks and links as unsupported).
|
||||
fn supported(n: &Node) -> bool {
|
||||
n.extras().mask.is_none() && Form::of(n).is_some()
|
||||
n.extras().mask.is_none() && has_form(n)
|
||||
}
|
||||
|
||||
/// Does [`Form::of`] give `n` a form? The same walk and the same box, without collecting the
|
||||
/// points or placing them in a unit box.
|
||||
fn has_form(n: &Node) -> bool {
|
||||
let mut b = None;
|
||||
form_box(n, 0, &mut b).is_some()
|
||||
&& b.is_some_and(|b: Rect| {
|
||||
let side = b.width().max(b.height());
|
||||
side.is_finite() && side > 1e-9
|
||||
})
|
||||
}
|
||||
|
||||
/// [`Form::add`] without the form: `b` grows by each point it would add; `None` when `n` isn't
|
||||
/// supported.
|
||||
fn form_box(n: &Node, depth: usize, b: &mut Option<Rect>) -> Option<()> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, clipping, guide: false, .. } if !*clipping || depth > 0 => grow_by_path(b, path),
|
||||
NodeKind::Compound { children, .. } => {
|
||||
for c in children {
|
||||
grow_by_path(b, c.path_data()?);
|
||||
}
|
||||
}
|
||||
NodeKind::SymbolInstance { xf, .. } => {
|
||||
for p in [*xf * Point::ZERO, *xf * Point::new(1.0, 0.0), *xf * Point::new(0.0, 1.0)] {
|
||||
grow(b, p);
|
||||
}
|
||||
}
|
||||
NodeKind::Group { children, .. } if plain_group(n) && depth < vectorcraft_doc::MAX_DEPTH => {
|
||||
for c in children {
|
||||
if c.extras().mask.is_some() {
|
||||
return None;
|
||||
}
|
||||
form_box(c, depth + 1, b)?;
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn grow_by_path(b: &mut Option<Rect>, path: &PathData) {
|
||||
for a in path.subpaths.iter().flat_map(|sp| &sp.anchors) {
|
||||
for p in [a.p, a.h_in, a.h_out] {
|
||||
grow(b, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `b` grown to take in `p`, as [`Form::of`] measures its points.
|
||||
fn grow(b: &mut Option<Rect>, p: Point) {
|
||||
*b = Some(b.unwrap_or(Rect::from_points(p, p)).union_pt(p));
|
||||
}
|
||||
|
||||
/// A plain group (a clip group too), not one that is live art (a graph, Shaper group, dimension,
|
||||
@@ -374,8 +426,9 @@ fn can_toggle(s: &Session) -> std::result::Result<(), String> {
|
||||
let [id] = st.selection.objects.as_slice() else {
|
||||
return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into());
|
||||
};
|
||||
// A group's contents are checked when Global Edit starts: building its shape here would walk
|
||||
// every object in it each time the menu or the Properties panel asks.
|
||||
// A group's contents are checked when Global Edit starts: measuring them here would walk
|
||||
// every object in it each time the menu or the Properties panel asks. Other objects are
|
||||
// measured ([`supported`]), not reduced to their form: the Control bar asks every frame.
|
||||
match st.doc.node(*id) {
|
||||
Some(n) if n.extras().mask.is_none() && plain_group(n) => Ok(()),
|
||||
Some(n) if supported(n) => Ok(()),
|
||||
@@ -750,3 +803,195 @@ pub fn highlights(s: &Session) -> Outlines {
|
||||
*ge.outlines.borrow_mut() = Some((st.revision, out.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::OpacityMask;
|
||||
use vectorcraft_geom::{Anchor, SubPath};
|
||||
|
||||
/// Global Edit's support as it was decided by building the form.
|
||||
fn by_form(n: &Node) -> bool {
|
||||
n.extras().mask.is_none() && Form::of(n).is_some()
|
||||
}
|
||||
|
||||
/// A small seeded generator (xorshift64*), so that a failure repeats.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 >> 12;
|
||||
self.0 ^= self.0 << 25;
|
||||
self.0 ^= self.0 >> 27;
|
||||
self.0.wrapping_mul(0x2545_f491_4f6c_dd1d)
|
||||
}
|
||||
fn below(&mut self, n: u64) -> u64 {
|
||||
self.next() % n
|
||||
}
|
||||
fn unit(&mut self) -> f64 {
|
||||
(self.next() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
/// A spread for the points of one object: none, below and around the smallest size a form
|
||||
/// takes, ordinary, or past every bound.
|
||||
fn spread(&mut self) -> f64 {
|
||||
[0.0, 1e-10, 5e-10, 1e-9, 2e-9, 1.0, 1.0, 100.0, 100.0, 100.0, f64::INFINITY, f64::NAN][self.below(12) as usize]
|
||||
}
|
||||
fn point(&mut self, base: Point, spread: f64) -> Point {
|
||||
let mut c = |b: f64| match self.below(400) {
|
||||
0 => f64::NAN,
|
||||
1 => f64::INFINITY,
|
||||
2 => f64::NEG_INFINITY,
|
||||
_ => b + (self.unit() - 0.5) * spread,
|
||||
};
|
||||
Point::new(c(base.x), c(base.y))
|
||||
}
|
||||
}
|
||||
|
||||
fn path_data(r: &mut Rng) -> PathData {
|
||||
let (base, spread) = (Point::new(r.unit() * 200.0, r.unit() * 200.0), r.spread());
|
||||
let subpaths = (0..r.below(3))
|
||||
.map(|_| {
|
||||
let anchors = (0..r.below(4))
|
||||
.map(|_| Anchor { p: r.point(base, spread), h_in: r.point(base, spread), h_out: r.point(base, spread), ..Anchor::corner(base) })
|
||||
.collect();
|
||||
SubPath::new(anchors, r.below(2) == 0)
|
||||
})
|
||||
.collect();
|
||||
PathData::new(subpaths)
|
||||
}
|
||||
|
||||
/// A random object: paths (clipping paths and guides too), compound paths, symbol instances,
|
||||
/// groups (clip groups, live art, members with an opacity mask), type, blends and intertwined
|
||||
/// objects, any of them with an opacity mask.
|
||||
fn object(r: &mut Rng, d: &mut Document, text: &Node, depth: usize) -> Node {
|
||||
let leaf = depth >= 3;
|
||||
let mut n = match r.below(if leaf { 4 } else { 8 }) {
|
||||
0 | 1 => {
|
||||
let mut n = Node::path(d.alloc_id(), path_data(r), Appearance::default());
|
||||
if let NodeKind::Path { clipping, guide, .. } = &mut n.kind {
|
||||
*clipping = r.below(6) == 0;
|
||||
*guide = r.below(8) == 0;
|
||||
}
|
||||
n
|
||||
}
|
||||
2 => {
|
||||
let xf = match r.below(4) {
|
||||
0 => Affine::scale(0.0),
|
||||
1 => Affine::scale(1e-10),
|
||||
_ => Affine::translate((r.unit() * 100.0, r.unit() * 100.0)) * Affine::scale(r.spread()),
|
||||
};
|
||||
Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf })
|
||||
}
|
||||
3 => {
|
||||
let mut t = text.clone();
|
||||
t.id = d.alloc_id();
|
||||
t
|
||||
}
|
||||
4 => {
|
||||
let children = (0..r.below(4))
|
||||
.map(|_| match r.below(5) {
|
||||
0 => Arc::new(object(r, d, text, depth + 1)),
|
||||
_ => Arc::new(Node::path(d.alloc_id(), path_data(r), Appearance::default())),
|
||||
})
|
||||
.collect();
|
||||
Node::new(d.alloc_id(), NodeKind::Compound { children, rule: vectorcraft_geom::FillRule::NonZero })
|
||||
}
|
||||
5 => {
|
||||
let children = (0..r.below(3)).map(|_| Arc::new(object(r, d, text, depth + 1))).collect();
|
||||
Node::new(d.alloc_id(), NodeKind::Blend { children, spec: Box::default() })
|
||||
}
|
||||
_ => {
|
||||
let children = (0..r.below(4)).map(|_| Arc::new(object(r, d, text, depth + 1))).collect();
|
||||
let mut g = Node::new(d.alloc_id(), NodeKind::Group { children, clip: r.below(3) == 0 });
|
||||
if r.below(8) == 0 {
|
||||
g.extras_mut().trace = Some(Box::new(Value::Null));
|
||||
}
|
||||
g
|
||||
}
|
||||
};
|
||||
if r.below(10) == 0 {
|
||||
n.extras_mut().mask = Some(Box::new(OpacityMask::new(Node::path(d.alloc_id(), path_data(r), Appearance::default()), true)));
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// `n` and every object in it.
|
||||
fn every(n: &Node) -> Vec<Node> {
|
||||
let mut out = vec![];
|
||||
n.walk(&mut |c| out.push(c.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn support_is_decided_as_building_the_form_decides() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": 50, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
|
||||
let text = s.doc().unwrap().doc.node(t).unwrap().clone();
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let (mut checked, mut taken, mut asked) = (0, 0, 0);
|
||||
for seed in 1..=400u64 {
|
||||
let mut r = Rng(seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) | 1);
|
||||
let n = object(&mut r, Arc::make_mut(&mut s.doc_mut().unwrap().doc), &text, 0);
|
||||
for c in every(&n) {
|
||||
assert_eq!(supported(&c), by_form(&c), "seed {seed}: {c:?}");
|
||||
checked += 1;
|
||||
taken += usize::from(by_form(&c));
|
||||
}
|
||||
// The enabled check of Start Global Edit on it, and Start Global Edit itself.
|
||||
let id = Arc::make_mut(&mut s.doc_mut().unwrap().doc).insert(Some(layer), usize::MAX, n.clone()).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
if s.doc().unwrap().selection.objects != [id] {
|
||||
continue;
|
||||
}
|
||||
asked += 1;
|
||||
let today = n.extras().mask.is_none() && plain_group(&n) || by_form(&n);
|
||||
assert_eq!(can_toggle(&s).is_ok(), today, "seed {seed}");
|
||||
match s.execute(C, &json!({})) {
|
||||
Ok(_) => {
|
||||
assert!(by_form(&n), "seed {seed}");
|
||||
s.execute(C, &json!({})).unwrap();
|
||||
}
|
||||
Err(e) => assert_eq!(e.to_string().contains(UNSUPPORTED), !by_form(&n), "seed {seed}: {e}"),
|
||||
}
|
||||
}
|
||||
// Both answers came up often, and most objects could be selected alone.
|
||||
assert!(taken > 100 && checked - taken > 100 && asked > 200, "{taken} of {checked}, {asked} selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn support_of_empty_degenerate_and_deep_objects_is_decided_as_building_the_form_decides() {
|
||||
let mut d = Document::new(800.0, 600.0);
|
||||
let corner = |x: f64, y: f64| Anchor::corner(Point::new(x, y));
|
||||
let path = |d: &mut Document, subpaths: Vec<SubPath>| Node::path(d.alloc_id(), PathData::new(subpaths), Appearance::default());
|
||||
let mut cases = vec![
|
||||
path(&mut d, vec![]),
|
||||
path(&mut d, vec![SubPath::new(vec![], true)]),
|
||||
path(&mut d, vec![SubPath::new(vec![corner(5.0, 5.0)], false)]),
|
||||
path(&mut d, vec![SubPath::new(vec![corner(0.0, 0.0), corner(0.0, 1e-9)], false)]),
|
||||
path(&mut d, vec![SubPath::new(vec![corner(0.0, 0.0), corner(0.0, 2e-9)], false)]),
|
||||
path(&mut d, vec![SubPath::new(vec![corner(0.0, 0.0), corner(f64::INFINITY, 0.0)], false)]),
|
||||
path(&mut d, vec![SubPath::new(vec![corner(f64::NAN, 0.0), corner(10.0, 0.0), corner(0.0, 0.0)], false)]),
|
||||
path(&mut d, vec![SubPath::new(vec![corner(f64::NAN, f64::NAN)], false)]),
|
||||
Node::new(d.alloc_id(), NodeKind::Compound { children: vec![], rule: Default::default() }),
|
||||
Node::group(d.alloc_id(), vec![]),
|
||||
Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::scale(0.0) }),
|
||||
Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY }),
|
||||
];
|
||||
// A group as deep as the walk goes, and one level deeper.
|
||||
let mut deep = path(&mut d, vec![SubPath::new(vec![corner(0.0, 0.0), corner(10.0, 10.0)], false)]);
|
||||
for _ in 0..=vectorcraft_doc::MAX_DEPTH {
|
||||
deep = Node::group(d.alloc_id(), vec![Arc::new(deep)]);
|
||||
cases.push(deep.clone());
|
||||
}
|
||||
let expected = [false, false, false, false, true, false, true, false, false, false, false, true];
|
||||
for (i, n) in cases.iter().enumerate() {
|
||||
assert_eq!(supported(n), by_form(n), "case {i}: {n:?}");
|
||||
if let Some(e) = expected.get(i) {
|
||||
assert_eq!(supported(n), *e, "case {i}");
|
||||
}
|
||||
}
|
||||
assert!(supported(&cases[cases.len() - 2]) && !supported(&cases[cases.len() - 1]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -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}))]
|
||||
}
|
||||
@@ -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 `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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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",
|
||||
|
||||
+3
-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 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
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-1-05 · **Change:** minor (SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; document tabs are reordered by dragging them along the tab strip; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Contour add-on, beyond Illustrator; Optical kerning spaces pairs by the font's own stem spacing instead of a fixed tight gap; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; document tabs are reordered by dragging them along the tab strip; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -482,6 +482,7 @@ since 2026-10-01.
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel; synthetic-input counts, pending events and per-frame control requests are bounded (#1709); what an agent reads and writes on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root`, as in PhotoCraft, #832) |
|
||||
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
|
||||
| **Align Stroke on live type** | beyond Illustrator | The Stroke panel's Align Stroke works on type's character stroke: inside or outside the glyphs without Create Outlines, the type staying editable (as InDesign does; users have asked Illustrator for it since 2010, e.g. [redd.it/1dwhos6](https://redd.it/1dwhos6)). Canvas, PDF and EPS clip it to its side of the glyphs; SVG keeps the text live and draws the stroke on the glyph outlines (#2294) |
|
||||
| **Contour add-on** | beyond Illustrator | Drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it (Shift: whole points); the copy carries a live Offset Path effect so it stays editable, with its own fill and stroke, until Bake (Expand Appearance). A second drag on a selected Contour changes it instead of nesting another; Escape cancels. A floating bar above the selection sets the distance and the corners (Sharp, Round, Square); `addon.contour.set` makes or changes one over MCP and the control channel. The tool is in Object › Plug-ins › Contour and under its own heading in All Tools, with no default shortcut or toolbar slot. Two Contours baked and combined with Minus Front give a border with a gap. Paths, shapes and compound paths only; type isn't offset. Tests: `crates/engine/src/cmd/addons/contour/tests.rs`, `crates/ui-egui/src/canvas/tests.rs` |
|
||||
|
||||
## Evidence: what ships today, by area
|
||||
|
||||
@@ -512,6 +513,7 @@ since 2026-10-01.
|
||||
- **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`.
|
||||
- **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`.
|
||||
- **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`.
|
||||
- **Global Edit's enabled check on large paths:** the check that decides whether Select › Start Global Edit can run on the selected path, compound path or symbol instance (which the Control bar makes every frame) measures the box of its anchors and handles instead of building and normalizing a copy of every anchor, with the same answer. On a 200,000-anchor path the check takes 0.7 ms instead of 2.2 to 3.0 ms. Regression tests: `support_*` in `crates/engine/src/cmd/globaledit.rs`.
|
||||
- **Control bar's inline X, Y, W and H fields fit the bar:** the Control bar shows the inline X, Y, W and H fields only when all four, their labels as drawn in the current language and the W/H link fit inside the bar's margin; otherwise it shows the Transform link alone. This is VectorCraft's own layout rule. Regression test: `the_control_bar_shows_the_inline_transform_fields_only_when_they_fit` in `crates/ui-egui/src/chrome/tests.rs`.
|
||||
- **Pattern-only backquote drags:** With Selection move, bounding-box scale or rotate, or the Rotate, Reflect, Scale or Shear tool, backquote held at mouse release applies the drag matrix to selected pattern paint while object geometry stays in place. The key may be pressed after mouse press; if released before the mouse button, the drag commits as an ordinary transform. Objects without pattern paint stay still; stroke-only patterns and patterned objects in mixed selections take the same matrix. Regression tests: `backquote_move_uses_release_state_and_one_commit` and `backquote_box_scale_and_rotate_use_pattern_matrix_at_release` in `select/tests.rs`, `backquote_drag_previews_and_commits_pattern_only_for_each_transform_tool` in `xform/transform.rs`, `selection_backquote_drag_changes_pattern_fill_and_stroke_without_moving_mixed_art` and `selection_backquote_drag_changes_stroke_only_pattern` in `tests/pattern.rs`, `egui_backtick_drag_uses_key_state_at_release` in `canvas/tests.rs`, and `synthetic_pointer_backquote_keeps_solid_object_in_place` in `control/tests.rs`. Scores and hours unchanged.
|
||||
- **Existing-guide Escape cancellation (G1):** Escape clears a pending guide press or drag through Selection and Direct/Group Selection, cancelling a begun preview. Eight app-input regressions cover both axes, move/copy, temporary selection-tool borrowing from idle Pen, preselected-guide document/interaction-start selection and Undo/Redo/saved-state preservation, later on-canvas motion/release, Shift-press cancellation, and ordinary-release/Enter/idle controls. Historical focused checks on `8ab574b7` recorded five expected RED failures with three passing controls, then all eight GREEN tests and the Direct Selection Escape control passed. All nine also passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. The original 1280×960 headless app capture from `b076025a` was reviewed for one vertical Selection guide after Escape; source review found its scene unchanged on `a0671777`. Scores and hours unchanged.
|
||||
@@ -1648,6 +1650,8 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-04 | minor | Contour add-on (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `addon.contour.set` is the command behind it. It is a built-in add-on: Object › Plug-ins › Contour, and its own heading in All Tools, with no default shortcut or toolbar slot. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. |
|
||||
| 2026-11-04 | minor | Contour tool (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `object.contour` is the command behind it. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. |
|
||||
| 2026-11-05 | minor | View & navigation: document tabs are reordered by dragging them along the tab strip (`document.move`, #5663); evidence added; scores unchanged. |
|
||||
| 2026-10-30 | minor | Effects: Color Halftone's Max Radius is 4–127 px of the Document Raster Effects resolution, shown in px whatever the units; older documents open converted, and files saved for older app versions hold points (#3260); scores unchanged. |
|
||||
| 2026-11-01 | minor | Batch Trace Folder (`imageTrace.batchFolder`, #4921); evidence added; scores unchanged. |
|
||||
|
||||
+3
-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 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 |
|
||||
|
||||
Reference in New Issue
Block a user