Merge main into feat/psd-import-options-import-slices-after-7649

This commit is contained in:
dotCooCoo
2026-11-14 20:36:40 -08:00
92 changed files with 1891 additions and 201 deletions
+3 -1
View File
@@ -2,7 +2,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-10 · **Change:** minor (docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; 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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Tech Circuit brush library, G5; previously: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; 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
@@ -125,6 +125,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 2026-11-14: Built-in symbol library Weather Icons: adds a sixth procedural symbol library (Window › Symbol Libraries, the Symbols panel's Symbol Libraries Menu) containing six original vector symbols (Sun, Moon, Cloud, Rain, Thunder, Snow) generated in code without default 9-slice guides (part of Gap 5). Headline numbers unchanged.
- 2026-11-14: Built-in Tech Circuit brush library: Circuit Trace (chamfered art with 45-degree elbows and terminal pads), Bus Lines (parallel conductor pattern) and Terminal Nodes (repeating solder pads, test points and leads), all original geometry generated in code. Ten built-in brush libraries; G5 remaining estimate 3–13 h (estimated), headline numbers unchanged.
- 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged.
- 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged.
@@ -224,6 +225,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
| Date | Change | Summary |
|---|---|---|
| 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 3–13 h (estimated); parity scores and headline totals unchanged. |
| 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. |
| 2026-11-10 | minor | Tidy-up, no content change: the unheaded change bullets at the top of the file and a revision row inside the headline-numbers table move to this table under their commit dates. Scores and estimates unchanged. |
| 2026-11-10 | minor | Raster effects: Effect Gallery thumbnails and an in-dialog preview (an original drawing through each filter). Features-table Raster effects 85% → 88%, hours 1–2 → none. Headline numbers unchanged, so the progress log is unchanged. |
+1
View File
@@ -120,6 +120,7 @@ pub use style_libs::StyleLibrary;
pub use text::FontHeightReference;
pub use text::GlyphChoice;
pub use text::GlyphForm;
pub use text::PATH_LEAD_MAX;
pub use text::WarichuAlign;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
+1
View File
@@ -29,6 +29,7 @@ pub use para_style::{
Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, KinsokuShoriType, LeadingModel,
Mojikumi, ParaDirection, ParaStyle, SingleWordJustify,
};
pub use runs::PATH_LEAD_MAX;
pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef, TypeAntiAlias};
pub use tabs_lists::{
Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, LIST_LEVEL_INDENT, LIST_LEVELS, ListAlign, ListCounts, NumberStyle, ParaList, TabAlign,
+4 -1
View File
@@ -352,7 +352,10 @@ impl TextObject {
}
/// Flip type on a path to the other side of its path (Type on a Path Options › Flip, or its
/// centre bracket dragged across the path): the path runs the other way and the brackets swap
/// ends, so the type keeps its stretch of the path. False, changing nothing, for other type.
/// ends, so the type keeps its stretch of the path. Type that starts before an open path's start
/// ([`TextKind::path_text_start`]) starts at its start bracket after the flip: the part of its
/// span before the path's start would come after the path's end. False, changing nothing, for
/// other type.
pub fn flip_on_path(&mut self) -> bool {
let Some((s, e)) = self.kind.path_span() else { return false };
let TextKind::OnPath { path, start, end } = &mut self.kind else { return false };
+18 -2
View File
@@ -93,8 +93,10 @@ pub enum TextKind {
/// Area type flowed inside `frame` (document coordinates, untransformed by `xf`).
Area { frame: PathData },
/// Type on a path, flowing from its start bracket `start` to its end bracket `end` (0..1 of the
/// path length; no `end`: the end of the path, or once round a closed path). See
/// [`TextKind::path_span`].
/// path length; no `end`: the end of the path, or once round a closed path). On an open path
/// `start` may be below 0: the type starts that far before the path's start, and the characters
/// whose middle falls before the path's start are not drawn ([`TextKind::path_text_start`]).
/// See [`TextKind::path_span`].
OnPath {
path: PathData,
start: f64,
@@ -117,8 +119,22 @@ impl TextKind {
(s, end.map_or(1.0, |e| fraction(e).max(s)))
})
}
/// Where type on a path's text starts, as a fraction of its path's length: its start bracket
/// ([`Self::path_span`]), or on an open path a start before the path's start (below 0, at most
/// [`PATH_LEAD_MAX`] path lengths before it). None for other type.
pub fn path_text_start(&self) -> Option<f64> {
let TextKind::OnPath { path, start, .. } = self else { return None };
if !path.is_closed() && start.is_finite() && *start < 0.0 { Some(start.max(-PATH_LEAD_MAX)) } else { self.path_span().map(|(s, _)| s) }
}
/// Does type on an open path start before its path's start ([`Self::path_text_start`])?
pub fn starts_before_path(&self) -> bool {
self.path_text_start().is_some_and(|s| s < 0.0)
}
}
/// How far before its path's start type on an open path may start, in lengths of the path.
pub const PATH_LEAD_MAX: f64 = 1e6;
/// Move anchors `refs` of `path`, in text space, by `d` in document space (`to_text`: the
/// document → text map). False when an anchor doesn't exist.
pub(super) fn move_anchors(path: &mut PathData, to_text: Affine, refs: &[(usize, usize)], d: Vec2) -> bool {
+27
View File
@@ -563,6 +563,33 @@ fn area_anchors_move_in_document_space() {
assert_eq!(t, before);
}
/// Type on an open path may start before the path's start: the start is kept, saved and read back,
/// while the start bracket stays at the path's start. On a closed path, and for a start that is not
/// finite, the start is the start bracket as before.
#[test]
fn type_on_an_open_path_starts_before_the_path() {
let pts = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)];
let on = |closed: bool, start: f64| TextKind::OnPath {
path: PathData::new(vec![vectorcraft_geom::SubPath::polyline(&pts, closed)]),
start,
end: None,
};
let open = on(false, -0.25);
assert_eq!(open.path_text_start(), Some(-0.25));
assert!(open.starts_before_path());
assert_eq!(open.path_span(), Some((0.0, 1.0)));
let saved = serde_json::to_value(&open).unwrap();
assert_eq!(saved["start"], -0.25);
assert_eq!(serde_json::from_value::<TextKind>(saved).unwrap(), open);
assert_eq!(on(false, -1e9).path_text_start(), Some(-PATH_LEAD_MAX));
assert_eq!(on(false, f64::NEG_INFINITY).path_text_start(), Some(0.0));
assert_eq!(on(false, 0.25).path_text_start(), Some(0.25));
assert!(!on(false, 0.0).starts_before_path());
assert_eq!(on(true, -0.25).path_text_start(), Some(0.0));
assert!(!on(true, -0.25).starts_before_path());
assert_eq!(TextKind::Point.path_text_start(), None);
}
/// Builds from before [`Justify::Auto`] read new type's alignment as Left or Right (and ignore
/// `justify_auto`); this build reads Auto back.
#[test]
+32 -8
View File
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Release Type on a Path…",
["Object", "Plug-ins", "Type Tools"],
None,
"{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}",
"{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path (when the type starts before an open path's start, the first character drawn on the path stays where it was drawn and the characters that were not drawn come before it); Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}",
has_selection,
release_path
),
@@ -229,6 +229,28 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option<Point> {
lay.glyphs.iter().filter(|g| g.origin.x.is_finite() && g.origin.y.is_finite()).min_by_key(|g| (g.byte, g.len != 0)).map(|g| g.origin)
}
/// A glyph of a character: its byte, whether it stands for characters (a list marker does not),
/// and its place among the glyphs of that byte and kind (an All Caps ß is two glyphs).
type GlyphKey = (usize, bool, usize);
/// The key and pen position of the first glyph drawn in `lay`, in text order: characters before an
/// open path's start are not drawn ([`vectorcraft_text::PositionedGlyph::off_path`]). None without
/// a drawn glyph.
fn first_drawn_pen(lay: &vectorcraft_text::TextLayout) -> Option<(GlyphKey, Point)> {
let (gi, g) = (lay.glyphs.iter().enumerate())
.filter(|(_, g)| !g.off_path && g.origin.x.is_finite() && g.origin.y.is_finite())
.min_by_key(|(gi, g)| (g.byte, g.len != 0, *gi))?;
let kind = g.len != 0;
let nth = lay.glyphs.iter().take(gi).filter(|h| h.byte == g.byte && (h.len != 0) == kind).count();
Some(((g.byte, kind, nth), g.origin))
}
/// The pen position of the glyph `key` names in `lay`. None without one.
fn pen_at(lay: &vectorcraft_text::TextLayout, (byte, kind, nth): GlyphKey) -> Option<Point> {
let g = lay.glyphs.iter().filter(|g| g.byte == byte && (g.len != 0) == kind).nth(nth)?;
(g.origin.x.is_finite() && g.origin.y.is_finite()).then_some(g.origin)
}
/// `t`, type on a path, as point type on one straight line: the same runs and character and
/// paragraph attributes, its baseline starting where its first character sat on the path (in the
/// object's own text space, so its transform stays). Type on a Path Options are dropped, and
@@ -237,13 +259,14 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option<Point> {
fn released_text(t: &TextObject) -> Option<TextObject> {
let TextKind::OnPath { path, .. } = &t.kind else { return None };
let db = vectorcraft_text::FontDb::global();
// Where the first character sat. Text that didn't fit on the path has no glyph there: it
// starts at the start bracket.
let at = first_pen(&vectorcraft_text::layout(db, t)).or_else(|| {
// Where the first drawn character sat; characters before an open path's start come before it
// on the line. Text that didn't fit on the path has no glyph there: it starts at the start
// bracket.
let (key, at) = first_drawn_pen(&vectorcraft_text::layout(db, t)).map(|(b, p)| (Some(b), p)).or_else(|| {
let ap = ArcPath::new(path);
let (from, _) = t.kind.path_span()?;
let (p, _) = ap.at(from * ap.len());
(p.x.is_finite() && p.y.is_finite()).then_some(p)
(p.x.is_finite() && p.y.is_finite()).then_some((None, p))
})?;
let mut out = t.clone();
// Point type has no brackets to fit: it keeps the sizes the path drew it at.
@@ -254,9 +277,10 @@ fn released_text(t: &TextObject) -> Option<TextObject> {
out.path_spacing = 0.0;
// Point type is in no thread: its first paragraph starts in it.
out.first_para_continues = false;
// Where the point type puts its own first character; the origin moves so it lands at `at`,
// whatever the paragraph's alignment and direction.
let own = first_pen(&vectorcraft_text::layout(db, &out)).unwrap_or(Point::ZERO);
// Where the point type puts that glyph; the origin moves so it lands at `at`, whatever the
// paragraph's alignment and direction.
let own_lay = vectorcraft_text::layout(db, &out);
let own = key.and_then(|k| pen_at(&own_lay, k)).or_else(|| first_pen(&own_lay)).unwrap_or(Point::ZERO);
let shift = at - own;
if !(shift.x.is_finite() && shift.y.is_finite()) {
return None;
@@ -482,6 +482,44 @@ mod release {
}
}
/// Type that starts before its open path: the first drawn character stays where it was drawn,
/// and the characters that were not drawn come before it on the line.
#[test]
fn type_starting_before_its_open_path_keeps_its_drawn_characters_in_place() {
let mut s = session();
let id = path_type(&mut s, "M100 300 L500 300", [100.0, 300.0], "Hidden start");
s.execute("type.pathOptions", &json!({"start": -0.05})).unwrap();
let before = text(&s, id);
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &before);
let first = lay.glyphs.iter().filter(|g| !g.off_path).min_by_key(|g| g.byte).unwrap();
let (byte, drawn) = (first.byte, before.xf * first.origin);
assert!(byte > 0, "some characters are before the path");
s.execute(C, &json!({"mode": "text"})).unwrap();
let after = text(&s, id);
assert_eq!(after.kind, TextKind::Point);
assert_eq!(after.plain_text(), before.plain_text());
let back = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &after);
let same = back.glyphs.iter().find(|g| g.byte == byte).unwrap();
assert!(close(after.xf * same.origin, drawn), "{:?} vs {drawn:?}", after.xf * same.origin);
let lead = back.glyphs.iter().find(|g| g.byte == 0).unwrap();
assert!((after.xf * lead.origin).x < drawn.x, "the hidden characters come before it");
// An All Caps ß is two glyphs: with the first before the path's start, the second stays
// where it was drawn.
let id = path_type(&mut s, "M100 400 L500 400", [100.0, 400.0], "ße");
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "allCaps": true})).unwrap();
let adv = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id)).glyphs[0].advance;
s.execute("type.pathOptions", &json!({"start": -1.2 * adv / 400.0})).unwrap();
let before = text(&s, id);
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &before);
assert!(lay.glyphs[0].byte == 0 && lay.glyphs[1].byte == 0 && lay.glyphs[0].off_path && !lay.glyphs[1].off_path);
let drawn = before.xf * lay.glyphs[1].origin;
s.execute(C, &json!({"mode": "text"})).unwrap();
let after = text(&s, id);
let back = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &after);
let second = back.glyphs.iter().filter(|g| g.byte == 0).nth(1).unwrap();
assert!(close(after.xf * second.origin, drawn), "{:?} vs {drawn:?}", after.xf * second.origin);
}
#[test]
fn flipped_type_releases_its_path_as_it_runs() {
let mut s = session();
+122 -35
View File
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add New Fill",
["Window", "Appearance"],
Some("Cmd+/"),
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
has_doc,
|s, p| add_item(s, p, true)
),
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add New Stroke",
["Window", "Appearance"],
Some("Cmd+Alt+/"),
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
has_doc,
|s, p| add_item(s, p, false)
),
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Clear Appearance",
["Window", "Appearance"],
None,
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
has_doc,
clear_appearance
),
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reduce to Basic Appearance",
["Window", "Appearance"],
None,
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
has_doc,
reduce_basic
),
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Appearance Item",
[],
None,
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
has_doc,
set_item
),
@@ -93,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove Item",
[],
None,
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
has_doc,
remove_item
),
@@ -102,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Effect",
[],
None,
"{…as effect.apply} same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?} → {ids, index, item}",
"{…as effect.apply} same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?, aligned?} → {ids, index, item}",
has_doc,
super::effectcmd::apply
),
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Duplicate Item",
["Window", "Appearance"],
None,
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind",
has_doc,
duplicate_item
),
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reorder Appearance Item",
[],
None,
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}",
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ, or differ from that of a graph design's art given first)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}",
has_doc,
move_item
),
@@ -129,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Eyedropper",
[],
None,
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores, for every label of its kind, the character attributes the pick gives all of its text, its object opacity and blend mode, and with `append` its topmost visible fill and stroke rows → {ids}",
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores, for every label of its kind, the character attributes the pick gives all of its text, its object opacity and blend mode, and with `append` its topmost visible fill and stroke rows → {ids}",
has_doc,
copy_from
),
@@ -147,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Show All Hidden Attributes",
["Window", "Appearance"],
None,
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {ids}",
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {ids}",
has_doc,
show_all_hidden
),
@@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Appearance",
[],
None,
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {source, target}",
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {source, target}",
has_doc,
transfer
),
@@ -183,14 +183,21 @@ pub(crate) struct ActiveItem {
impl Session {
/// The method returns explicit target parameters and the selected appearance effect list.
pub fn appearance_effect_target(&self, params: &Value) -> Option<(Value, &[vectorcraft_doc::Effect])> {
let mut ids = appearance_targets(self, params).ok()?;
let mut ids = drop_design_art(&self.active()?.doc, appearance_targets(self, params).ok()?);
let item = item_target(self, params, "effect.apply").ok()?;
let anchor = item.anchor(self, &ids);
let node = self.active()?.doc.node(*ids.get(anchor)?)?;
// Aligned effects given with a graph design's art first are effects of the art's stack, which stays first; the
// Appearance panel's active row is a row of the first selected object's.
let of = match item {
ItemTarget::Item { explicit: false, .. } => None,
_ => design_reference(self, params, "effect.apply").ok()?,
};
let own = *ids.get(anchor)?;
let node = self.active()?.doc.node(of.unwrap_or(own))?;
let at = item.effects_item(&node.appearance, "effect.apply").ok()?;
let effects = node.appearance.effects_at(at)?;
ids.swap(0, anchor);
let ids: Vec<u64> = ids.into_iter().map(|id| id.0).collect();
let ids: Vec<u64> = with_reference(of, &ids).into_iter().map(|id| id.0).collect();
let aligned = match item {
ItemTarget::Item { aligned, .. } => aligned,
ItemTarget::Top => aligned(params, "effect.apply").ok()?,
@@ -253,6 +260,10 @@ pub(crate) fn item_target_at(s: &Session, p: &Value, key: &str, cmd: &str) -> Re
.ok_or_else(|| bad(cmd, format!("`{key}` must be an appearance item index (paint order) or null"))),
// The active item is a row of the first selected object's own stack.
None if p.get("ids").is_some() || p.get("id").is_some() || targets_contents(p)? => Ok(ItemTarget::Top),
// A row of a graph design's art in a series stands for nothing: the commands leave that art out.
None if s.active().and_then(|st| st.selection.subjects().first().map(|id| super::graph::is_design_art(&st.doc, *id))) == Some(true) => {
Ok(ItemTarget::Top)
}
None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false, aligned: true })),
}
}
@@ -300,6 +311,21 @@ impl ItemTarget {
.collect()
}
/// [`ItemTarget::per_target`], with an explicit aligned item a row of the stack of `of` (see [`design_reference`]),
/// which is not among `ids`, when there is one: each target edits the item equal to it there, and a target without
/// an equal item is left out (`None`).
pub(crate) fn per_target_of(self, s: &Session, of: Option<NodeId>, ids: &[NodeId]) -> Vec<Option<ItemTarget>> {
let ItemTarget::Item { index, explicit: true, aligned: true } = self else { return self.per_target(s, ids) };
let Some(d) = s.active().map(|st| &st.doc) else { return self.per_target(s, ids) };
let Some(r) = of.and_then(|of| d.node(of)) else { return self.per_target(s, ids) };
ids.iter()
.map(|id| {
let index = SharedStack::of(&[&*r.appearance, &*d.node(*id)?.appearance]).item_in(index, 1)?;
Some(ItemTarget::Item { index, explicit: true, aligned: true })
})
.collect()
}
/// Where among `ids` (the targets, in paint order) the object whose stack this item is a row of
/// is: the first selected object (or the compound path holding it) for the active item, which
/// need not paint first; the first target otherwise, or when that object is not among them.
@@ -384,6 +410,32 @@ pub(crate) fn items_per_target(d: &vectorcraft_doc::Document, ids: &[NodeId], id
}
}
/// The art of a graph design in a graph series whose stack the aligned rows of `p` (`aligned: true`) are rows of: the
/// first of the [`appearance_targets`] when it is such art, which the edit leaves out ([`without_design_art`]). A
/// command reads it after its own target checks.
pub(crate) fn design_reference(s: &Session, p: &Value, cmd: &str) -> Result<Option<NodeId>> {
if !aligned(p, cmd)? {
return Ok(None);
}
let ids = appearance_targets(s, p)?;
let d = &s.doc()?.doc;
Ok(ids.first().copied().filter(|id| super::graph::is_design_art(d, *id)))
}
/// `ids` after `of` (see [`design_reference`]), when there is one.
pub(crate) fn with_reference(of: Option<NodeId>, ids: &[NodeId]) -> Vec<NodeId> {
of.into_iter().chain(ids.iter().copied()).collect()
}
/// [`items_per_target`] of `ids`, with the aligned rows `idx` taken from the stack of `of` (see [`design_reference`])
/// when there is one: a row a target has no equal of is left out of that target.
pub(crate) fn rows_per_target(d: &Document, of: Option<NodeId>, ids: &[NodeId], idx: &[usize], aligned: bool) -> Vec<Vec<usize>> {
match of {
Some(of) => items_per_target(d, &with_reference(Some(of), ids), idx, true).into_iter().skip(1).collect(),
None => items_per_target(d, ids, idx, aligned),
}
}
/// Whether the `target` param asks for the contents of groups and layers (see the module docs).
fn targets_contents(p: &Value) -> Result<bool> {
match p.get("target") {
@@ -424,22 +476,44 @@ const DESIGN_ART: &str = "the art of a graph design in a graph series is drawn a
/// The [`appearance_targets`] whose rows and effects a command edits: the art of graph designs drawn in graph series
/// ([`super::graph::is_design_art`]) is left out, as the graph draws it again from the design. A command whose targets
/// are all such art is refused.
pub(crate) fn edit_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
/// are all such art is refused as an error of `cmd`.
pub(crate) fn edit_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
let ids = appearance_targets(s, p)?;
without_design_art(&s.doc()?.doc, ids, "appearance")
without_design_art(&s.doc()?.doc, ids, cmd)
}
/// `ids` without the art of graph designs drawn in graph series; `cmd` is refused when `ids` held nothing else.
pub(crate) fn without_design_art(d: &Document, mut ids: Vec<NodeId>, cmd: &str) -> Result<Vec<NodeId>> {
pub(crate) fn without_design_art(d: &Document, ids: Vec<NodeId>, cmd: &str) -> Result<Vec<NodeId>> {
let given = ids.len();
ids.retain(|id| !super::graph::is_design_art(d, *id));
let ids = drop_design_art(d, ids);
if given > 0 && ids.is_empty() {
return Err(bad(cmd, DESIGN_ART));
}
Ok(ids)
}
/// `ids` without the art of graph designs drawn in graph series, for the paint commands, which leave it out without an
/// error.
pub(crate) fn drop_design_art(d: &Document, mut ids: Vec<NodeId>) -> Vec<NodeId> {
ids.retain(|id| !super::graph::is_design_art(d, *id));
ids
}
/// The outermost art of graph designs drawn in graph series inside `roots`: the objects directly in a series group that
/// [`super::graph::is_design_art`] accepts.
pub(crate) fn design_art_in(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
let mut out = vec![];
for id in roots {
let Some(n) = d.node(*id) else { continue };
n.walk(&mut |m| {
if m.extras().series_index.is_some() {
out.extend(m.children().into_iter().flatten().map(|c| c.id).filter(|c| super::graph::is_design_art(d, *c)));
}
});
}
out
}
/// `ids` with each graph series group that has no fill or stroke row of its own replaced by the paths directly inside
/// it ([`super::graph::series_group_marks`]). A series group with rows, as a document saved earlier can hold, stays:
/// the Appearance panel lists those rows and edits them by index.
@@ -476,15 +550,15 @@ pub(crate) fn look_holder<'a>(d: &'a Document, n: &'a Node) -> &'a Node {
/// Objects whose fill and stroke rows a command edits: the [`edit_targets`], with a graph series group standing for the
/// paths directly inside it ([`rowless_series_marks`]), so the graph can store the edit.
pub(crate) fn row_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let ids = edit_targets(s, p)?;
pub(crate) fn row_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
let ids = edit_targets(s, p, cmd)?;
Ok(rowless_series_marks(&s.doc()?.doc, &ids))
}
/// Objects whose whole appearance a command edits: the [`row_targets`], then each graph series group they stand for,
/// which can hold effects and a graphic style link of its own.
pub(crate) fn whole_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let own = edit_targets(s, p)?;
pub(crate) fn whole_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
let own = edit_targets(s, p, cmd)?;
let mut ids = rowless_series_marks(&s.doc()?.doc, &own);
let groups = stood_for(&own, &ids);
ids.extend(groups);
@@ -513,7 +587,7 @@ pub(crate) fn clear_stood_for(d: &mut Document, groups: &[NodeId]) -> bool {
/// [`whole_targets`] of `cmd`, which needs at least one.
fn require_whole_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
let ids = whole_targets(s, p)?;
let ids = whole_targets(s, p, cmd)?;
if ids.is_empty() {
return Err(bad(cmd, "select objects or give ids"));
}
@@ -522,7 +596,7 @@ fn require_whole_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId
/// [`row_targets`] of `cmd`, which needs at least one.
fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
let ids = row_targets(s, p)?;
let ids = row_targets(s, p, cmd)?;
if ids.is_empty() {
return Err(bad(cmd, "select objects or give ids"));
}
@@ -608,8 +682,8 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo
return Ok(b);
}
let ItemTarget::Item { index, .. } = item else { return Ok(default) };
let ids = appearance_targets(s, p)?;
let d = &s.doc()?.doc;
let ids = drop_design_art(d, appearance_targets(s, p)?);
Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill()))
}
@@ -1032,7 +1106,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["weight", "opacity"], "appearance.setItem")?;
check_switches(p, &["visible"], "appearance.setItem")?;
let paint = paint_from(s, p)?;
let ids = row_targets(s, p)?;
let ids = row_targets(s, p, "appearance.setItem")?;
if ids.is_empty() {
return Err(bad("appearance.setItem", "select objects or give ids"));
}
@@ -1040,7 +1114,8 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
// When every object comes out as it was (for example, when none has item `index`), the document
// and its undo history stay as they are.
let before = s.doc()?.doc.clone();
let per = items_per_target(&before, &ids, &[idx], aligned(p, "appearance.setItem")?);
let of = design_reference(s, p, "appearance.setItem")?;
let per = rows_per_target(&before, of, &ids, &[idx], aligned(p, "appearance.setItem")?);
super::overprint::edit_counted(s, "Appearance", |d| {
let mut edited = Vec::new();
for (id, at) in ids.iter().zip(&per) {
@@ -1127,7 +1202,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
// When no object has an item at any of the indices, the document and its undo history stay as
// they are.
let before = s.doc()?.doc.clone();
let per = items_per_target(&before, &ids, &idx, aligned(p, "appearance.removeItem")?);
let of = design_reference(s, p, "appearance.removeItem")?;
let per = rows_per_target(&before, of, &ids, &idx, aligned(p, "appearance.removeItem")?);
super::overprint::edit_counted(s, "Remove Item", |d| {
for (id, idx) in ids.iter().zip(&per) {
if let Some(n) = d.node_mut(*id) {
@@ -1168,12 +1244,19 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
let mut first_at = None;
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
let mut active_at = None;
// Aligned rows of a mixed selection are copied right above themselves in each object.
if aligned(p, C)? && to.is_some() && shared_stack(&s.doc()?.doc, &ids).is_some() {
// Aligned rows of a mixed selection are copied right above themselves in each object. Rows of a graph design's art,
// which is left out, go to `to` in an object whose stack is the same as the art's.
let of = design_reference(s, p, C)?;
if aligned(p, C)?
&& to.is_some()
&& (shared_stack(&s.doc()?.doc, &ids).is_some() || (of.is_some() && appearances_differ(&s.doc()?.doc, &with_reference(of, &ids))))
{
return Err(bad(C, "`to` takes the rows of one object's stack, not aligned rows"));
}
let before = s.doc()?.doc.clone();
let per = items_per_target(&before, &ids, &idx, aligned(p, C)?);
// With `to`, the art's stack is the same as the object's, so its rows are the object's, each of which must exist.
let per =
if to.is_some() { items_per_target(&before, &ids, &idx, aligned(p, C)?) } else { rows_per_target(&before, of, &ids, &idx, aligned(p, C)?) };
s.edit("Duplicate Item", |d, _| {
let mut any = false;
// Copies that became the topmost visible row of their kind: the series stores their paint even when the object
@@ -1280,15 +1363,19 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
let aligned = aligned(p, C)?;
if p.get("from").and_then(Value::as_str) == Some("contents") {
// A graph series group's own Contents row, not its paths, which have none.
let ids = edit_targets(s, p)?;
let ids = edit_targets(s, p, C)?;
if ids.is_empty() {
return Err(bad(C, "select objects or give ids"));
}
// The Contents row's place counts the rows below it, which isn't one place in several different stacks either.
if aligned && appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &ids)) {
return Err(bad(C, "rows of objects whose appearances differ can't be reordered together"));
}
return move_contents(s, &ids, to);
}
let ids = require_targets(s, p, C)?;
// Where an aligned row would go in each of several different stacks isn't one place.
if aligned && appearances_differ(&s.doc()?.doc, &ids) {
if aligned && appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &ids)) {
return Err(bad(C, "rows of objects whose appearances differ can't be reordered together"));
}
if p.get("indices").is_some() {
+54
View File
@@ -178,6 +178,7 @@ pub const BUILTIN: &[BuiltinBrushLibrary] = &[
BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails },
BuiltinBrushLibrary { id: "energy-beams", name: "Energy Beams", make: energy_beams },
BuiltinBrushLibrary { id: "dry-media", name: "Dry Media", make: dry_media },
BuiltinBrushLibrary { id: "tech-circuit", name: "Tech Circuit", make: tech_circuit },
];
/// Built-in library `id`, made once.
@@ -397,6 +398,59 @@ fn border(name: &str, side: Node, corner: Option<Node>, fit: PatternFit) -> Brus
Brush { name: name.into(), kind }
}
/// Tech Circuit: original conductor geometry, drawn as filled paths so widths and chamfers
/// survive brush warping. Pattern tiles meet at x = 0 and 24 with identical lead cross-sections.
fn tech_circuit() -> BrushLibrary {
let pad = |x: f64| {
black(polygon(&[
(x - 4.0, -2.0),
(x - 2.0, -4.0),
(x + 2.0, -4.0),
(x + 4.0, -2.0),
(x + 4.0, 2.0),
(x + 2.0, 4.0),
(x - 2.0, 4.0),
(x - 4.0, 2.0),
]))
};
// 45-degree jogs lift the conductor to a parallel run and bring it back.
let trace = group(vec![
black(polygon(&[
(4.0, -1.0),
(23.0, -1.0),
(33.0, -11.0),
(61.0, -11.0),
(71.0, -1.0),
(96.0, -1.0),
(96.0, 1.0),
(70.0, 1.0),
(60.0, -9.0),
(34.0, -9.0),
(24.0, 1.0),
(4.0, 1.0),
])),
pad(4.0),
pad(96.0),
]);
let bus = group([-4.0, 0.0, 4.0].into_iter().map(|y| black(shapes::rectangle(Rect::new(0.0, y - 0.75, 24.0, y + 0.75)))).collect());
let terminals = group(vec![
// Overlap the ring's rim without filling the test point's hole.
black(shapes::rectangle(Rect::new(0.0, -0.6, 15.5, 0.6))),
black(shapes::rectangle(Rect::new(18.5, -0.6, 24.0, 0.6))),
pad(6.0),
ring(Point::new(17.0, 0.0), 3.0, 1.4),
]);
built(
"Tech Circuit",
Document::new(100.0, 100.0),
vec![
art("Circuit Trace", trace, Colorization::Tints),
border("Bus Lines", bus, None, PatternFit::Stretch),
border("Terminal Nodes", terminals, None, PatternFit::Stretch),
],
)
}
/// An invisible rectangle that fixes a tile's width.
fn spacer(r: Rect) -> Node {
Node::path(NodeId(0), shapes::rectangle(r), Appearance::default())
+101 -26
View File
@@ -13,7 +13,9 @@ use vectorcraft_doc::{Document, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_render::effects;
use super::appearance::{ItemTarget, aligned, appearance_targets, edit_targets, index_param, item_indices, item_target, item_target_at};
use super::appearance::{
ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, with_reference,
};
use super::expand::has_seeded;
use super::rasterfx;
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, check_switches, cmd, fileio, has_doc, str_param, switch_or};
@@ -49,7 +51,7 @@ fn effect_specs() -> Vec<CommandSpec> {
"Apply Effect",
[],
None,
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step); the art of a graph design in a graph series is left out, and objects that are all such art are an error (docs/mcp.md) → {ids, index, item, grouped?}",
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers), aligned?: bool (item is a row of the first target's stack: each other target gets the effect on its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, a row of the art's stack: docs/mcp.md)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step); the art of a graph design in a graph series is left out, and objects that are all such art are an error (docs/mcp.md) → {ids, index, item, grouped?}",
has_doc,
apply
),
@@ -67,7 +69,7 @@ fn effect_specs() -> Vec<CommandSpec> {
"Remove Effect",
[],
None,
"{index: int (position in the effect list), indices?: [int] (several positions in the same list instead of index, removed in one undo step; every one must exist), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}",
"{index: int (position in the effect list), indices?: [int] (several positions in the same list instead of index, removed in one undo step; every one must exist), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} → {ids}",
has_doc,
remove
),
@@ -76,7 +78,7 @@ fn effect_specs() -> Vec<CommandSpec> {
"Effect Options",
[],
None,
"{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place; params are checked against the new style), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool (any other value but null is refused, when a step plays too), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}",
"{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place; params are checked against the new style), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool (any other value but null is refused, when a step plays too), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} → {ids}",
has_doc,
set_params
),
@@ -85,7 +87,7 @@ fn effect_specs() -> Vec<CommandSpec> {
"3D Lighting Preset",
[],
None,
"{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), Revolve (Classic) or Rotate (Classic), which have no lighting presets; one undo step → {ids}",
"{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), Revolve (Classic) or Rotate (Classic), which have no lighting presets; one undo step → {ids}",
has_doc,
lighting_preset
),
@@ -103,7 +105,7 @@ fn effect_specs() -> Vec<CommandSpec> {
"Duplicate Effect",
[],
None,
"{index: int, indices?: [int] (several positions in the same list instead of index; every one must exist), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} insert a copy of each effect right after it, in one undo step → {ids}",
"{index: int, indices?: [int] (several positions in the same list instead of index; every one must exist), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} insert a copy of each effect right after it, in one undo step → {ids}",
has_doc,
duplicate
),
@@ -112,7 +114,7 @@ fn effect_specs() -> Vec<CommandSpec> {
"Move Effect",
[],
None,
"{from: int|[int] (position in the source list; a list moves several effects of that list, which keep their order and land together), to: int (the position of the (first) moved effect in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (refused when the targets' appearances differ)} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}",
"{from: int|[int] (position in the source list; a list moves several effects of that list, which keep their order and land together), to: int (the position of the (first) moved effect in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (refused when the targets' appearances differ, or differ from that of a graph design's art given first)} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}",
has_doc,
move_effect
),
@@ -296,14 +298,20 @@ fn ids_json(ids: &[NodeId]) -> Value {
/// effects it edits (`None`: the object's own) and the effect's index there. With `aligned` (see
/// [`ItemTarget::per_target`]) `item` and `index` are the first target's, and each other target
/// edits its equal item, or its equal object-level effect, each target matched on its own whatever
/// the other targets have; in a target without them, `item` and `index` stand for themselves.
/// the other targets have; in a target without them, `item` and `index` stand for themselves. When the first object
/// given is the art of a graph design in a series ([`design_reference`]), which is left out, they are rows of its stack,
/// and a target without them is left out.
pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: usize, cmd: &str) -> Result<Vec<(NodeId, Option<usize>, usize)>> {
let roots = edit_targets(s, p)?;
let roots = edit_targets(s, p, cmd)?;
let of = reference_of(s, p, item, cmd)?;
let d = &s.doc()?.doc;
let per = item.per_target(s, &roots);
let first = roots.first().and_then(|f| d.node(*f));
let per = item.per_target_of(s, of, &roots);
let first = of.or_else(|| roots.first().copied()).and_then(|f| d.node(f));
// Whether `item` names an item in the stack it is a row of (else the edit is object-level there).
let anchor = roots.get(item.anchor(s, &roots)).and_then(|a| d.node(*a));
let anchor = match of {
Some(of) => d.node(of),
None => roots.get(item.anchor(s, &roots)).and_then(|a| d.node(*a)),
};
let anchor_level = anchor.map(|a| item.effects_item(&a.appearance, cmd)).transpose()?.flatten();
let aligned = aligned(p, cmd)?;
let mut out = Vec::with_capacity(roots.len());
@@ -312,7 +320,11 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us
let at = item.effects_item(&n.appearance, cmd)?;
// An object-level effect this target shares with the first is where it is in this target.
let index = match (first.filter(|_| aligned), at, anchor_level) {
(Some(f), None, None) => SharedStack::of(&[&*f.appearance, &*n.appearance]).effect_in(index, 1).unwrap_or(index),
(Some(f), None, None) => match SharedStack::of(&[&*f.appearance, &*n.appearance]).effect_in(index, 1) {
Some(i) => i,
None if of.is_some() && index != APPENDED => continue,
None => index,
},
_ => index,
};
out.push((*id, at, index));
@@ -320,6 +332,19 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us
Ok(out)
}
/// The effect index `effect.apply` gives [`effect_targets`] for the effect it appends, which names no effect the targets
/// have.
const APPENDED: usize = usize::MAX;
/// The [`design_reference`] of an effect edit aimed at `item`: none for the Appearance panel's active row, which is a
/// row of the first selected object's stack.
fn reference_of(s: &Session, p: &Value, item: ItemTarget, cmd: &str) -> Result<Option<NodeId>> {
if matches!(item, ItemTarget::Item { explicit: false, .. }) {
return Ok(None);
}
design_reference(s, p, cmd)
}
#[allow(clippy::too_many_arguments)]
fn edit_effects(
s: &mut Session,
@@ -362,6 +387,20 @@ fn edit_effects_at(
indices: &[usize],
mut f: impl FnMut(&mut Vec<Effect>, &[usize]) -> bool,
) -> Result<Vec<NodeId>> {
// With a graph design's art given first, the indices are effects of its list: one it doesn't have names no effect,
// and the edit is refused as a whole, as it is in a list that lacks one of them.
if let Some(of) = reference_of(s, p, item, cmd)?
&& let Some(n) = s.doc()?.doc.node(of)
{
let ap = &n.appearance;
let len = match item.effects_item(ap, cmd)? {
None => ap.effects.len(),
Some(i) => ap.items.get(i).map_or(0, |it| it.effects().len()),
};
if indices.iter().any(|i| *i >= len) {
return Err(EngineError::Other(none.into()));
}
}
// Each target's list and the positions in it, in the order the targets first come up.
let mut targets: Vec<(NodeId, Option<usize>, Vec<usize>)> = vec![];
for &index in indices {
@@ -453,13 +492,18 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true }));
}
let item = item_target(s, p, C)?;
// The item as each target has it, read before the effect changes their rows: a row of a graph design's art, which is
// left out of the targets, is the row equal to it there, and a target without one is left out.
let targets = edit_targets(s, p, C)?;
let of = reference_of(s, p, item, C)?;
let per: Vec<(NodeId, Option<ItemTarget>)> = targets.iter().copied().zip(item.per_target_of(s, of, &targets)).collect();
if effects::is_three_d(id) {
let targets = edit_targets(s, p)?;
for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) {
for &(target, at) in &per {
let Some(at) = at else { continue };
let d = &s.doc()?.doc;
let n = d.node(target).ok_or(EngineError::NoNode(target))?;
// A new effect goes after the item's (or the object's) others.
let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten();
let on = at.effects_item(&n.appearance, C).ok().flatten();
let on = on.and_then(|i| Some((Some(i), n.appearance.items.get(i)?.effects().len())));
let on = on.unwrap_or((None, n.appearance.effects.len()));
effects::validate_three_d_at_in(Some(d), n, id, &effect.params, on).map_err(|e| bad(C, e))?;
@@ -474,14 +518,19 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", 0, |fx, _| {
let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", APPENDED, |fx, _| {
fx.push(effect.clone());
true
})?;
// The item the effect landed on in the object whose row the item is (the first selected object
// for the active item, which applies where it fits), and the effect's index in that object.
let first = ids.get(item.anchor(s, &ids)).and_then(|id| s.doc().ok()?.doc.node(*id));
let landed = first.and_then(|n| item.effects_item(&n.appearance, C).ok().flatten());
let k = item.anchor(s, &ids);
let first = ids.get(k).and_then(|id| s.doc().ok()?.doc.node(*id));
let at = match of {
Some(_) => ids.get(k).and_then(|id| per.iter().find(|(t, _)| t == id)).and_then(|(_, at)| *at).unwrap_or(item),
None => item,
};
let landed = first.and_then(|n| at.effects_item(&n.appearance, C).ok().flatten());
let index = first.and_then(|n| n.appearance.effects_at(landed)).map_or(0, |fx| fx.len().saturating_sub(1));
Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed }))
}
@@ -495,7 +544,7 @@ fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result<Option<Vec<N
}
let d = &s.doc()?.doc;
// The art of a graph design in a series stays where the graph draws it.
let roots = super::appearance::without_design_art(d, super::appearance::subject_roots(s)?, "appearance")?;
let roots = super::appearance::without_design_art(d, super::appearance::subject_roots(s)?, "effect.apply")?;
let grouped = roots.iter().any(|r| d.node(*r).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })));
Ok((roots.len() >= 2 && !grouped).then_some(roots))
}
@@ -608,9 +657,9 @@ fn move_effect(s: &mut Session, p: &Value) -> Result<Value> {
let src = item_target_at(s, p, "fromItem", C)?;
let dst = if p.get("toItem").is_some() { item_target_at(s, p, "toItem", C)? } else { src };
let copy = switch_or(p, "copy", false, C)?;
let roots = edit_targets(s, p)?;
let roots = edit_targets(s, p, C)?;
// Where an aligned effect would go in each of several different stacks isn't one place.
if aligned(p, C)? && super::appearance::appearances_differ(&s.doc()?.doc, &roots) {
if aligned(p, C)? && super::appearance::appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &roots)) {
return Err(bad(C, "effects of objects whose appearances differ can't be moved together"));
}
// Where the effect landed in each object it moved in, in the order of `ids`.
@@ -898,8 +947,11 @@ fn can_expand(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
let ids = appearance_targets(s, &Value::Null).map_err(|e| e.to_string())?;
let d = &s.doc().map_err(|e| e.to_string())?.doc;
// The art of a graph design in a series, selected or inside a selected graph, is drawn again from the design.
let ids = super::appearance::drop_design_art(d, ids);
// A dimension selected with the art it measures is left as it is.
let kept = super::dimension::with_their_art(d, &ids);
let mut kept = super::dimension::with_their_art(d, &ids);
kept.extend(super::appearance::design_art_in(d, &ids));
if ids.iter().filter_map(|id| d.node(*id)).any(|n| expandable_except(n, &kept)) {
Ok(())
} else {
@@ -1225,12 +1277,22 @@ fn holds_plugin_effect(d: &Document, n: &Node, in_art: bool, seen: &mut std::col
}
fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
let roots = appearance_targets(s, p)?;
let roots = edit_targets(s, p, "effect.expandAppearance")?;
// In Safe Mode a plug-in effect draws nothing: expanding it would drop the effect and keep the
// geometry it hasn't changed.
if vectorcraft_plugins::safe_mode::active() {
// A dimension selected with the art it measures is left as it is.
let d = &*super::dimension::without_kept(&s.doc()?.doc, &roots);
// A dimension selected with the art it measures is left as it is, and so is the art of a graph design in a
// series.
let mut d = super::dimension::without_kept(&s.doc()?.doc, &roots);
let designs = super::appearance::design_art_in(&d, &roots);
if !designs.is_empty() {
let view = d.to_mut();
for id in designs {
// Art already gone (inside other art removed) needs nothing more.
let _ = view.remove(id);
}
}
let d = &*d;
let mut seen = std::collections::HashSet::new();
if roots.iter().filter_map(|id| d.node(*id)).any(|n| holds_plugin_effect(d, n, false, &mut seen)) {
return Err(EngineError::Other("Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode".into()));
@@ -1240,6 +1302,10 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
let ids = s.edit("Expand Appearance", |d, _| {
let before = d.clone();
// The art of graph designs in the graphs and series among the roots stays as the graph draws it. A group whose
// own effect turns it into other art as a whole (a blur, feather or inner glow makes it an image) takes its
// art into that art, which draws it, so that art has no place to go back to.
let designs = super::appearance::design_art_in(d, &roots);
// A dimension selected with the art it measures keeps its own lines and label: it is out
// of the document while the art expands.
let lifted = super::dimension::lift(d, &roots);
@@ -1251,6 +1317,15 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
for id in &roots {
expand_node(d, *id, &brushes, &mut out);
}
for id in &designs {
if let (Some(was), Some(slot)) = (before.node(*id), d.node_mut(*id)) {
*slot = was.clone();
}
}
if !designs.is_empty() {
// A mark that expands into a group stays listed: only what lies in the restored art is left out.
out.retain(|id| d.node(*id).is_some() && !d.ancestry(*id).is_some_and(|up| up.iter().any(|a| designs.contains(a))));
}
super::dimension::put_back(d, lifted);
// A dimension whose source was replaced stays where it was, no longer linked.
super::dimension::restore_unchanged(&before, d, ended);
+1 -1
View File
@@ -95,7 +95,7 @@ fn map_graphic(s: &mut Session, p: &Value) -> Result<Value> {
effect_targets(s, p, item, index, C)?
} else {
let mut out = vec![];
for id in edit_targets(s, p)? {
for id in edit_targets(s, p, C)? {
let (item, index) = d.node(id).and_then(first_three_d).ok_or_else(|| bad(C, format!("object {} needs a 3D effect", id.0)))?;
out.push((id, item, index));
}
+2 -1
View File
@@ -182,7 +182,8 @@ fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor
let item = item_target(s, p, cmd)?;
let stroke = edits_stroke(s, p, item, !s.fill_active, cmd)?;
let item = item.of_kind(s, !stroke);
let ids = item.targets(s, p)?;
// The art of a graph design in a series keeps the design's gradient.
let ids = super::appearance::without_design_art(&s.doc()?.doc, item.targets(s, p)?, cmd)?;
if ids.is_empty() {
return Err(bad(cmd, "select an object with a freeform gradient"));
}
+6 -1
View File
@@ -531,6 +531,10 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["angle"], C)?;
let item = item.of_kind(s, !stroke);
let ids = item.targets(s, p)?;
// Objects were given or selected, even when all of them are graph design art left out below: the stroke mode is then
// not one for new art.
let selected = !ids.is_empty();
let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids);
// How the gradient lies on a stroke (the panel's Stroke buttons).
let mode = match str_param(p, "strokeMode") {
None => None,
@@ -605,7 +609,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
Ok(())
})?;
if let Some(m) = mode
&& ids.is_empty()
&& !selected
&& p.get("ids").is_none()
&& let Some(st) = s.new_art_stroke_mut()
{
@@ -760,6 +764,7 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
if targets.is_empty() {
return Err(EngineError::Other("nothing selected".into()));
}
let targets = super::appearance::drop_design_art(&s.doc()?.doc, targets);
let model = super::paint::new_color_model(s, &Value::Null);
edit_items(s, &targets, item, C, "Gradient", !stroke, |n, index| {
if index.is_none()
+441
View File
@@ -416,9 +416,186 @@ fn refused_on_design_art(s: &mut Session, cmd: &str, p: serde_json::Value) {
let steps = s.doc().unwrap().history.undo.len();
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("drawn again from the design"), "{cmd}: {e}");
// `appearance.addEffect` is an alias of `effect.apply`, whose errors name `effect.apply`.
let named = if cmd == "appearance.addEffect" { "effect.apply" } else { cmd };
assert!(e.contains(&format!("`{named}`")), "the error names the command that ran: {e}");
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}");
}
/// The look of object `id`: its appearance, opacity, blend mode, isolation, knockout and overprint.
fn look(s: &Session, id: NodeId) -> String {
format!("{:?}", s.doc().unwrap().doc.node(id).unwrap())
}
/// An edit of a graph design's art in a series lasts only until the graph is drawn again: the graph draws the art again
/// from the design.
#[test]
fn an_edit_of_a_graph_designs_art_is_lost_when_the_graph_is_drawn_again() {
let (mut s, id, _, art, _) = design_art();
let path = |s: &Session| {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let mut out = None;
series(&n, 0).children().unwrap()[0].walk(&mut |m| {
if out.is_none() && matches!(m.kind, NodeKind::Path { .. }) {
out = Some(m.clone());
}
});
out.unwrap()
};
assert_eq!(path(&s).id, art);
s.execute("object.setProps", &json!({"id": art.0, "opacity": 50})).unwrap();
assert!((path(&s).opacity - 0.5).abs() < 1e-6);
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"csv": ",2024,2025\nQ1,3,2\nQ2,5,8\nQ3,-1,6"})).unwrap();
assert!((path(&s).opacity - 1.0).abs() < 1e-6);
}
/// The paint, stroke, gradient and overprint commands leave the art of a graph design in a series as the design draws
/// it, and edit the other objects they are given.
#[test]
fn paint_commands_leave_a_graph_designs_art_out() {
let (mut s, _, copy, art, mark) = design_art();
let fill = top_row(&s, art, true);
let commands = [
("stroke.set", json!({"weight": 5})),
("stroke.weightUp", json!({})),
("stroke.setAdvanced", json!({"arrowScale": [200, 200]})),
("paint.editGradient", json!({"kind": "linear"})),
("paint.setGradientGeom", json!({"start": [0, 0], "end": [50, 50]})),
("object.setOverprint", json!({"fill": true})),
("paint.swap", json!({})),
("paint.default", json!({})),
("paint.invert", json!({"item": fill, "stroke": false})),
];
let (copy_was, art_was) = (look(&s, copy), look(&s, art));
for (cmd, p) in &commands {
for ids in [json!([copy.0]), json!([art.0])] {
let steps = s.doc().unwrap().history.undo.len();
let mut q = p.clone();
q["ids"] = ids;
let _ = s.execute(cmd, &q);
assert_eq!((look(&s, copy), look(&s, art)), (copy_was.clone(), art_was.clone()), "{cmd}");
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}");
}
let mark_was = look(&s, mark);
let mut q = p.clone();
q["ids"] = json!([art.0, mark.0]);
s.execute(cmd, &q).unwrap();
assert_eq!(look(&s, art), art_was, "{cmd}");
assert_ne!(look(&s, mark), mark_was, "{cmd}");
s.execute("edit.undo", &json!({})).unwrap();
}
// Selected, without ids.
s.execute("select.set", &json!({"ids": [art.0]})).unwrap();
s.execute("stroke.set", &json!({"weight": 5})).unwrap();
assert_eq!(look(&s, art), art_was);
// A freeform gradient point is refused on the art alone.
refused_on_design_art(&mut s, "paint.freeform.addPoint", json!({"ids": [art.0], "at": [0, 0]}));
}
/// `attributes.info` reads the overprint of a graph design's art in a series, which the overprint edits leave out.
#[test]
fn attributes_info_reads_a_graph_designs_art() {
let (mut s, _, _, art, _) = design_art();
let info = s.execute("attributes.info", &json!({"ids": [art.0]})).unwrap();
assert_eq!(info["ids"], json!([art.0]));
assert_eq!(info["overprintFill"], json!(false), "{info}");
assert_eq!(info["overprintStroke"], json!(false), "{info}");
}
/// With only the art of a graph design selected, `stroke.setAdvanced` and the stroke mode of `paint.editGradient` act
/// as edits of a selection, not of the stroke the next object drawn takes.
#[test]
fn stroke_edits_of_selected_design_art_leave_the_new_art_stroke() {
let (mut s, _, _, art, _) = design_art();
s.execute("select.set", &json!({"ids": [art.0]})).unwrap();
let was = format!("{:?}", s.new_art_stroke());
s.execute("stroke.setAdvanced", &json!({"arrowScale": [200, 200], "swapArrows": true})).unwrap();
assert_eq!(format!("{:?}", s.new_art_stroke()), was, "stroke.setAdvanced");
let mode = s.new_art_stroke().gradient_mode;
s.execute("paint.editGradient", &json!({"kind": "linear", "stroke": true, "strokeMode": "along"})).unwrap();
assert_eq!(s.new_art_stroke().gradient_mode, mode, "paint.editGradient");
}
/// `transparency.set` without `item` refuses the art of a graph design in a series, and given it with other objects
/// sets only theirs.
#[test]
fn object_transparency_leaves_a_graph_designs_art_out() {
let (mut s, _, copy, art, mark) = design_art();
refused_on_design_art(&mut s, "transparency.set", json!({"ids": [art.0], "opacity": 40}));
refused_on_design_art(&mut s, "transparency.set", json!({"ids": [copy.0], "isolate": true}));
s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "opacity": 40, "blend": "multiply"})).unwrap();
let d = &s.doc().unwrap().doc;
assert!((d.node(art).unwrap().opacity - 1.0).abs() < 1e-6);
assert!((d.node(mark).unwrap().opacity - 0.4).abs() < 1e-6);
}
/// With the art of a graph design selected first, the Appearance panel's active row (a row of that art) stands for no
/// row of the other objects selected: an effect goes on each of them as a whole.
#[test]
fn an_active_row_of_a_graph_designs_art_is_not_applied_to_other_objects() {
let (mut s, _, _, art, mark) = design_art();
let stroke = top_row(&s, art, false);
s.execute("select.set", &json!({"ids": [art.0, mark.0]})).unwrap();
assert_eq!(s.doc().unwrap().selection.subjects().first(), Some(&art));
s.execute("appearance.setActiveItem", &json!({"index": stroke})).unwrap();
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow"})).unwrap();
let n = s.doc().unwrap().doc.node(mark).unwrap().clone();
assert_eq!(n.appearance.effects.len(), 1);
assert!(n.appearance.items.iter().all(|it| it.effects().is_empty()));
assert!(s.doc().unwrap().doc.node(art).unwrap().appearance.effects.is_empty());
}
/// Expand Appearance leaves the art of a graph design in a series as the design draws it.
#[test]
fn expand_appearance_leaves_a_graph_designs_art() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].clone();
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [r]})).unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("graph.design", &json!({"new": "Shadow"})).unwrap();
s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0], "design": "Shadow"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let mut art = vec![];
series(&n, 0).children().unwrap()[0].walk(&mut |m| {
if !m.appearance.effects.is_empty() {
art.push(m.id);
}
});
let art = art[0];
let other = s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [other]})).unwrap();
let art_was = look(&s, art);
// Given alone, by id or selected, it leaves Expand Appearance disabled.
for p in [json!({"ids": [art.0]}), json!({})] {
s.execute("select.set", &json!({"ids": [art.0]})).unwrap();
assert!(s.execute("effect.expandAppearance", &p).unwrap_err().to_string().contains("not available"));
assert_eq!(look(&s, art), art_was);
}
// Inside a selected graph or series group, the art stays as the design draws it: with nothing else to expand there,
// Expand Appearance is disabled, and with a mark's effect, the mark expands alone.
let series_group = series(&n, 0).id;
for root in [id, series_group] {
s.execute("select.set", &json!({"ids": [root.0]})).unwrap();
assert!(s.execute("effect.expandAppearance", &json!({})).unwrap_err().to_string().contains("not available"));
assert_eq!(look(&s, art), art_was);
}
let mark = series(&n, 1).children().unwrap()[0].id;
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0]})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let expanded = s.execute("effect.expandAppearance", &json!({})).unwrap();
assert_eq!(expanded["ids"], json!([mark.0]));
assert_eq!(look(&s, art), art_was);
s.execute("edit.undo", &json!({})).unwrap();
// Selected with another object, the art is left as it is and the other object expands.
s.execute("select.set", &json!({"ids": [art.0, other]})).unwrap();
s.execute("effect.expandAppearance", &json!({})).unwrap();
assert_eq!(look(&s, art), art_was);
assert!(s.doc().unwrap().doc.node(NodeId(other)).is_none_or(|m| m.appearance.effects.is_empty()));
}
/// The commands that give rows, effects or graphic styles refuse the art of a graph design inside a series, given by
/// id (the design's copy or a path inside it) or selected: the graph draws that art again from the design.
#[test]
@@ -445,6 +622,7 @@ fn appearance_commands_refuse_a_graph_designs_art_in_a_series() {
refused_on_design_art(&mut s, "effect.remove", json!({"ids": ids, "index": 0}));
refused_on_design_art(&mut s, "threeD.mapGraphic", json!({"ids": ids, "symbol": "Mark"}));
refused_on_design_art(&mut s, "graphicStyle.apply", json!({"name": "Blue", "ids": ids}));
refused_on_design_art(&mut s, "graphicStyle.breakLink", json!({"ids": ids}));
}
for cmd in ["appearance.removeItem", "appearance.duplicateItem"] {
refused_on_design_art(&mut s, cmd, json!({"ids": [art.0], "index": fill}));
@@ -499,6 +677,269 @@ fn appearance_commands_edit_the_objects_given_with_a_graph_designs_art() {
assert_eq!(rows(&s, mark).items.len(), mark_was.items.len() + 1);
}
/// `design_art()` with the design's art painted with a red and a blue fill and series 1's first mark with a blue fill and
/// a black stroke, so that row 1 of the art (its blue fill) is row 0 of the mark: the graph, the art and the mark.
fn aligned_design_art() -> (Session, NodeId, NodeId, NodeId) {
let (mut s, id, _, art, mark) = design_art();
let (red, blue) = (Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)), Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)));
let mark_look = vectorcraft_doc::Appearance::basic(blue, Paint::solid(vectorcraft_color::Color::BLACK), 1.0);
let mut art_look = mark_look.clone();
art_look.items = vec![art_look.items[0].clone(), art_look.items[0].clone()];
*art_look.items[0].paint_mut() = red;
s.edit("Looks", |d, _| {
d.node_mut(art).unwrap().appearance = vectorcraft_doc::Shared::new(art_look.clone());
d.node_mut(mark).unwrap().appearance = vectorcraft_doc::Shared::new(mark_look.clone());
Ok(())
})
.unwrap();
(s, id, art, mark)
}
/// Aligned rows given with the art of a graph design in a series first are rows of that art's stack, which the commands
/// leave as it is: each other object edits the row equal to the given one, never the row at the same index.
#[test]
fn aligned_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() {
let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone();
let green = Paint::solid(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0));
let (mut s, _, art, mark) = aligned_design_art();
let (art_was, mark_was) = (rows(&s, art), rows(&s, mark));
s.execute("appearance.removeItem", &json!({"ids": [art.0, mark.0], "index": 1, "aligned": true})).unwrap();
assert_eq!(rows(&s, art), art_was);
assert_eq!(rows(&s, mark).items, mark_was.items[1..], "the mark loses its blue fill and keeps its stroke");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [art.0, mark.0], "index": 1, "aligned": true, "color": "#00ff00"})).unwrap();
assert_eq!(rows(&s, art), art_was);
assert_eq!((rows(&s, mark).items[0].paint(), &rows(&s, mark).items[1]), (&green, &mark_was.items[1]));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "index": 1, "aligned": true})).unwrap();
assert_eq!(rows(&s, art), art_was);
let fills: Vec<bool> = rows(&s, mark).items.iter().map(|it| it.is_fill()).collect();
assert_eq!(fills, [true, true, false], "the mark's blue fill is copied, not its stroke");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 1, "aligned": true, "opacity": 40})).unwrap();
assert_eq!(rows(&s, art), art_was);
assert_eq!((rows(&s, mark).items[0].opacity(), rows(&s, mark).items[1].opacity()), (0.4, 1.0));
// A row of the art past the end of the mark's stack.
let (mut s, _, art, mark) = aligned_design_art();
s.edit("Rows", |d, _| {
let ap = &mut d.node_mut(art).unwrap().appearance;
let red = ap.items[0].clone();
ap.items.insert(0, red);
Ok(())
})
.unwrap();
s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 2, "aligned": true, "opacity": 40})).unwrap();
assert_eq!((rows(&s, mark).items[0].opacity(), rows(&s, mark).items[1].opacity()), (0.4, 1.0));
// A row of the art (its red fill) that the mark has no equal of: transparency.set is an error, and appearance.setItem
// and appearance.removeItem leave the document as it is.
let (art_was, mark_was) = (rows(&s, art), rows(&s, mark));
let steps = s.doc().unwrap().history.undo.len();
let e = s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 0, "aligned": true, "opacity": 40})).unwrap_err();
assert!(e.to_string().contains("no appearance item 0"), "{e}");
s.execute("appearance.setItem", &json!({"ids": [art.0, mark.0], "index": 0, "aligned": true, "color": "#00ff00"})).unwrap();
s.execute("appearance.removeItem", &json!({"ids": [art.0, mark.0], "index": 0, "aligned": true})).unwrap();
assert_eq!((rows(&s, art), rows(&s, mark)), (art_was, mark_was));
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "no undo step is recorded");
}
/// Aligned effects given with the art of a graph design in a series first go on, and come off, the rows equal to the art's
/// row in the other objects; an object without an equal row is left as it is.
#[test]
fn aligned_effects_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() {
let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone();
let effects = |s: &Session, id: NodeId| rows(s, id).items.iter().map(|it| it.effects().len()).collect::<Vec<_>>();
let (mut s, id, art, mark) = aligned_design_art();
// A second mark of series 1 with a green fill, which no row of the art equals, and the same black stroke.
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let other = series(&n, 1).children().unwrap()[1].id;
let mut other_look = rows(&s, mark);
*other_look.items[0].paint_mut() = Paint::solid(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0));
s.edit("Look", |d, _| {
d.node_mut(other).unwrap().appearance = vectorcraft_doc::Shared::new(other_look.clone());
Ok(())
})
.unwrap();
let (art_was, other_was) = (rows(&s, art), rows(&s, other));
let r = s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [art.0, mark.0, other.0], "item": 1, "aligned": true})).unwrap();
assert_eq!(rows(&s, art), art_was);
assert_eq!(effects(&s, mark), [1, 0], "the shadow goes on the mark's blue fill");
assert_eq!(rows(&s, other), other_was, "the other mark has no blue fill");
assert_eq!((&r["ids"], &r["item"], &r["index"]), (&json!([mark.0]), &json!(0), &json!(0)), "the reply names the mark's row");
s.execute("edit.undo", &json!({})).unwrap();
// A 3D effect is checked on the rows it goes on only.
s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [art.0, mark.0, other.0], "item": 1, "aligned": true})).unwrap();
assert_eq!((rows(&s, art), effects(&s, mark), rows(&s, other)), (art_was.clone(), vec![1, 0], other_was.clone()));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("appearance.addEffect", &json!({"effect": "stylize.dropShadow", "ids": [art.0, mark.0, other.0], "item": 1, "aligned": true})).unwrap();
assert_eq!((rows(&s, art), effects(&s, mark), rows(&s, other)), (art_was, vec![1, 0], other_was));
// The art's blue fill with the same shadow as the mark's: removing it from the art's row takes it off the mark's.
s.edit("Shadow", |d, _| {
let row = d.node(mark).unwrap().appearance.items[0].clone();
d.node_mut(art).unwrap().appearance.items[1] = row;
Ok(())
})
.unwrap();
let art_was = rows(&s, art);
// The effect dialog reads the shadow from the art's row and passes the art on first.
let (target, fx) = s.appearance_effect_target(&json!({"ids": [art.0, mark.0], "item": 1, "aligned": true})).unwrap();
assert_eq!((fx.len(), &target["ids"], &target["item"]), (1, &json!([art.0, mark.0]), &json!(1)));
// Edits of the art's row effect change the mark's equal row.
s.execute("effect.setParams", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "visible": false, "aligned": true})).unwrap();
assert_eq!(rows(&s, art), art_was);
assert!(!rows(&s, mark).items[0].effects()[0].visible, "the mark's shadow is hidden");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("effect.duplicate", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "aligned": true})).unwrap();
assert_eq!((rows(&s, art), effects(&s, mark)), (art_was.clone(), vec![2, 0]));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "aligned": true})).unwrap();
assert_eq!(rows(&s, art), art_was);
assert_eq!(effects(&s, mark), [0, 0]);
// An object-level effect of the art that the mark does not have: a new object-level effect still goes on the mark.
s.edit("Effect", |d, _| {
let shadow = d.node(art).unwrap().appearance.items[1].effects()[0].clone();
d.node_mut(art).unwrap().appearance.effects.push(shadow);
Ok(())
})
.unwrap();
let art_was = rows(&s, art);
s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [art.0, mark.0], "item": null, "aligned": true})).unwrap();
assert_eq!(rows(&s, art), art_was);
assert_eq!(rows(&s, mark).effects.len(), 1);
// The art's object-level shadow is not the mark's roughen: the effect dialog reads the art's, and removing it leaves
// the mark as it is.
let (_, fx) = s.appearance_effect_target(&json!({"ids": [art.0, mark.0], "item": null, "aligned": true})).unwrap();
assert_eq!(fx.iter().map(|e| e.id.to_string()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
let mark_was = rows(&s, mark);
assert!(s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 0, "aligned": true})).is_err());
assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was));
// An object-level effect index past the end of the art's list names no effect of the mark either.
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0], "item": null})).unwrap();
let mark_was = rows(&s, mark);
assert!(s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 1, "aligned": true})).is_err());
assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was));
// The mark with the art's object-level shadow after its roughen: removing the art's effect 0 takes off the mark's 1.
s.edit("Shadow", |d, _| {
let shadow = d.node(art).unwrap().appearance.effects[0].clone();
d.node_mut(mark).unwrap().appearance.effects[1] = shadow;
Ok(())
})
.unwrap();
s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 0, "aligned": true})).unwrap();
assert_eq!(rows(&s, art), art_was);
assert_eq!(rows(&s, mark).effects.iter().map(|e| e.id.to_string()).collect::<Vec<_>>(), ["distort.roughen"]);
}
/// Aligned rows of a graph design's art and of another object whose stack differs are not one place in both stacks, so
/// they are not reordered, and `to` is refused for their copies; with the same stacks, the copies go to `to`.
#[test]
fn aligned_rows_of_a_graph_designs_art_and_a_different_stack_are_not_moved() {
let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone();
let (mut s, _, art, mark) = aligned_design_art();
let mark_was = rows(&s, mark);
assert!(s.execute("appearance.moveItem", &json!({"ids": [art.0, mark.0], "from": 1, "to": 0, "aligned": true})).is_err());
assert_eq!(rows(&s, mark), mark_was);
assert!(s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "index": 1, "to": 0, "aligned": true})).is_err());
assert_eq!(rows(&s, mark), mark_was);
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0], "item": null})).unwrap();
s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [mark.0], "item": null})).unwrap();
let mark_was = rows(&s, mark);
assert!(s.execute("effect.move", &json!({"ids": [art.0, mark.0], "from": 1, "to": 0, "aligned": true})).is_err());
assert_eq!(rows(&s, mark), mark_was);
// The mark with the art's stack.
let (mut s, _, art, mark) = aligned_design_art();
let art_was = rows(&s, art);
s.edit("Look", |d, _| {
d.node_mut(mark).unwrap().appearance = vectorcraft_doc::Shared::new(art_was.clone());
Ok(())
})
.unwrap();
assert!(s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "indices": [1, 9], "to": 0, "aligned": true})).is_err());
assert_eq!(rows(&s, mark), art_was, "a row that does not exist is refused");
s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "index": 1, "to": 0, "aligned": true})).unwrap();
assert_eq!(rows(&s, art), art_was);
let paints: Vec<_> = rows(&s, mark).items.iter().map(|it| it.paint().clone()).collect();
assert_eq!(paints, [art_was.items[1].paint().clone(), art_was.items[0].paint().clone(), art_was.items[1].paint().clone()]);
}
/// The Contents row of a graph design's copy and of a group whose stack differs is not moved with `aligned: true`, as
/// the rows are not.
#[test]
fn aligned_contents_row_of_a_graph_designs_copy_and_a_different_stack_is_not_moved() {
let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone();
let (mut s, _, copy, _, _) = design_art();
s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("appearance.addFill", &json!({"ids": [g.0]})).unwrap();
s.execute("appearance.addFill", &json!({"ids": [g.0]})).unwrap();
assert_ne!(rows(&s, copy), rows(&s, g));
let (copy_was, g_was) = (rows(&s, copy), rows(&s, g));
let steps = s.doc().unwrap().history.undo.len();
let e = s.execute("appearance.moveItem", &json!({"ids": [copy.0, g.0], "from": "contents", "to": 1, "aligned": true})).unwrap_err();
assert!(e.to_string().contains("appearances differ"), "{e}");
assert_eq!((rows(&s, copy), rows(&s, g)), (copy_was, g_was));
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
}
/// Invert with an `item` remembers the paint of an object it recolored, not that of a graph design's art given first,
/// which it leaves out.
#[test]
fn invert_of_an_item_remembers_the_recolored_paint_not_the_design_arts() {
let (mut s, _, art, mark) = aligned_design_art();
s.execute("paint.invert", &json!({"ids": [art.0, mark.0], "item": 0, "stroke": false})).unwrap();
let Paint::Solid { color, .. } = s.doc().unwrap().doc.node(mark).unwrap().appearance.items[0].paint().clone() else {
panic!("the mark's fill stays a solid color");
};
assert_ne!(color, vectorcraft_color::Color::rgb(0.0, 0.0, 1.0), "the mark's blue fill is inverted");
assert_eq!(s.last_solid, color, "the mark's inverted fill is remembered, not the art's red one");
}
/// With a graph design's art given first and `aligned: true`, `effect.remove` and `effect.duplicate` with `indices` are an
/// error and change nothing when one of the indices names no effect of the art.
#[test]
fn aligned_effect_indices_past_the_end_of_a_graph_designs_art_are_refused() {
let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone();
let (mut s, _, art, mark) = aligned_design_art();
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0], "item": null})).unwrap();
s.edit("Shadow", |d, _| {
let shadow = d.node(mark).unwrap().appearance.effects[0].clone();
d.node_mut(art).unwrap().appearance.effects.push(shadow);
Ok(())
})
.unwrap();
let (art_was, mark_was) = (rows(&s, art), rows(&s, mark));
let steps = s.doc().unwrap().history.undo.len();
for cmd in ["effect.remove", "effect.duplicate"] {
let e = s.execute(cmd, &json!({"ids": [art.0, mark.0], "item": null, "indices": [0, 9], "aligned": true})).unwrap_err();
assert!(e.to_string().contains("no effects at indices 0, 9"), "{cmd}: {e}");
assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was.clone()), "{cmd}");
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}");
}
// With index 0 alone, which the art has, the mark's equal effect is removed.
s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "indices": [0], "aligned": true})).unwrap();
assert!(rows(&s, mark).effects.is_empty());
}
/// A blur on a series group makes the whole group an image, which draws the design's art with the series' other art:
/// Expand Appearance does not put the art back beside the image.
#[test]
fn expanding_a_blurred_series_group_takes_a_graph_designs_art_into_the_image() {
let (mut s, id, copy, _, _) = design_art();
let series_group = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
s.execute("effect.apply", &json!({"effect": "blur.gaussian", "ids": [series_group.0]})).unwrap();
s.execute("effect.expandAppearance", &json!({"ids": [series_group.0]})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(matches!(d.node(series_group).unwrap().kind, NodeKind::Image(_)));
assert!(d.node(copy).is_none());
}
/// The paint commands leave the art of a graph design in a series out, a copy of the design given by id with an item
/// included.
#[test]
+7 -2
View File
@@ -156,10 +156,15 @@ pub(super) fn edit_counted(s: &mut Session, label: &str, f: impl FnOnce(&mut Doc
pub(super) type OverprintJob = ((bool, ItemTarget, Vec<NodeId>), bool);
/// The overprint edits `p` asks for under `keys` (the fill's and the stroke's), aimed as
/// `object.setOverprint` aims.
/// `object.setOverprint` aims, leaving out the art of graph designs drawn in graph series.
pub(super) fn overprint_jobs(s: &Session, p: &Value, cmd: &str, keys: [&str; 2]) -> Result<Vec<OverprintJob>> {
let kinds = kinds(p, cmd, keys)?;
Ok(aims(s, p, cmd, kinds.iter().map(|k| k.0))?.into_iter().zip(kinds.iter().map(|k| k.1)).collect())
let d = &s.doc()?.doc;
Ok(aims(s, p, cmd, kinds.iter().map(|k| k.0))?
.into_iter()
.map(|(fill, it, ids)| (fill, it, super::appearance::drop_design_art(d, ids)))
.zip(kinds.iter().map(|k| k.1))
.collect())
}
/// Apply `jobs` to `d`, adding the objects changed to `changed`.
+26 -10
View File
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Transparency",
["Window", "Transparency"],
None,
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; when every target is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso, those points take the opacity themselves). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke, and on a series group without an item, the group's own opacity, blend mode, isolate, knockout and knockoutShape (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too",
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; when every target is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso, those points take the opacity themselves). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke, and on a series group without an item, the group's own opacity, blend mode, isolate, knockout and knockoutShape (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too",
has_doc,
transparency
),
@@ -966,7 +966,7 @@ pub(super) fn restore_unpainted_stroke(ap: &mut Appearance, index: Option<usize>
}
fn swap(s: &mut Session, p: &Value) -> Result<Value> {
let ids = paint_targets(s, p)?;
let ids = super::appearance::drop_design_art(&s.doc()?.doc, paint_targets(s, p)?);
if !ids.is_empty() {
// When every object comes out as it was, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
@@ -1001,7 +1001,7 @@ fn swap(s: &mut Session, p: &Value) -> Result<Value> {
}
fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
let ids = paint_targets(s, p)?;
let ids = super::appearance::drop_design_art(&s.doc()?.doc, paint_targets(s, p)?);
if !ids.is_empty() {
// When every object comes out as it was, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
@@ -1070,9 +1070,9 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(
return Ok(json!({ "changed": changed as usize }));
}
let item = item_target(s, p, cmd)?.of_kind(s, !stroke);
let (r, shown_item) = match item {
let (r, shown_item, edited) = match item {
ItemTarget::Item { index, .. } => {
let ids = item.targets(s, p)?;
let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?);
let mut changed = 0;
edit_items(s, &ids, item, cmd, label, !stroke, |n, i| {
let paint =
@@ -1080,15 +1080,19 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(
changed += paint.is_some_and(|paint| super::colorcmds::map_paint(paint, f)) as usize;
Ok(())
})?;
(json!({ "changed": changed }), Some(index))
(json!({ "changed": changed }), Some(index), Some(ids))
}
// The proxy recolors its paints, including a colorized image's fill, without changing image pixels or pattern tiles.
ItemTarget::Top => {
let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()});
(super::colorcmds::recolor(s, &q, cmd, label, f)?, None)
(super::colorcmds::recolor(s, &q, cmd, label, f)?, None, None)
}
};
let shown_ids = if shown_item.is_some() { ids } else { leaf_targets(s, &ids)? };
// The paint remembered is one the edit recolored: a graph design's art that the item edit leaves out gives none.
let shown_ids = match edited {
Some(ids) => ids,
None => leaf_targets(s, &ids)?,
};
if let Some(shown) = shown_ids.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, shown_item))) {
s.remember_paint(&shown);
}
@@ -1142,6 +1146,9 @@ fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
if ids.is_empty() {
return Err(bad(C, "select objects or give ids"));
}
// The object-level opacity, blend mode, isolation and knockout of a graph design's art in a series are drawn again
// from the design too.
let ids = super::appearance::without_design_art(&s.doc()?.doc, ids, C)?;
// While an opacity mask is edited the selection is its art, so the Appearance panel's active
// item (a row of that art) doesn't stand for the masked object.
let editing_mask = p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.doc.mask_edit.is_some();
@@ -1168,13 +1175,22 @@ fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
return s.execute("object.setProps", &Value::Object(q));
};
// Opacity and blend belong to the targeted fill/stroke; isolate/knockout/knockoutShape stay object-level.
let item_ids = if editing_mask { ids } else { row_targets(s, p)? };
let item_ids = if editing_mask { ids } else { row_targets(s, p, C)? };
// The art of a graph design whose stack an explicit aligned item is a row of goes first: appearance.setItem leaves
// it as it is and matches the other objects' rows against it.
let of = if editing_mask || !explicit { None } else { super::appearance::design_reference(s, p, C)? };
if explicit {
let d = &s.doc()?.doc;
if !item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) {
// With such art, an object has the item when it has a row equal to the art's.
let found = match of {
Some(_) => super::appearance::rows_per_target(d, of, &item_ids, &[index], true).iter().any(|r| !r.is_empty()),
None => item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())),
};
if !found {
return Err(bad(C, format!("no appearance item {index}")));
}
}
let item_ids = super::appearance::with_reference(of, &item_ids);
let (opacity, blend) = (q.remove("opacity"), q.remove("blend"));
if let Some(b) = &blend
&& b.as_str().and_then(BlendMode::parse).is_none()
+7 -1
View File
@@ -234,6 +234,8 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
let item = item_target(s, p, C)?.of_kind(s, false);
let mut ids = if item == ItemTarget::Top { own_stroke_or_leaves(s, p)? } else { item.targets(s, p)? };
let new_art = no_targets(&ids, p);
// The art of a graph design in a series keeps the design's stroke.
ids = super::appearance::drop_design_art(&s.doc()?.doc, ids);
if item == ItemTarget::Top {
// Images and symbol instances take no stroke from the panel.
let d = &s.doc()?.doc;
@@ -368,6 +370,7 @@ fn stroke_step(s: &mut Session, p: &Value, up: bool) -> Result<Value> {
}
return Ok(json!({"objects": 0}));
}
let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids);
let label = if up { "Stroke Weight Up" } else { "Stroke Weight Down" };
edit_items(s, &ids, item, c, label, false, |n, index| {
if index.is_none()
@@ -510,6 +513,9 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
}
let item = item_target(s, p, C)?.of_kind(s, false);
let ids = item.targets(s, p)?;
let new_art = no_targets(&ids, p);
// The art of a graph design in a series keeps the design's stroke.
let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids);
let arrows_and_profile = |st: &mut StrokeLayer| {
if let Some(sc) = scale {
st.arrow_scale = sc;
@@ -523,7 +529,7 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
}
};
// As stroke.set: with nothing selected, for the next object drawn.
if no_targets(&ids, p)
if new_art
&& (scale.is_some() || swap || flip.is_some())
&& let Some(st) = s.new_art_stroke_mut()
{
+3 -3
View File
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Graphic Style",
["Window", "Graphic Styles"],
None,
"{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}",
"{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}",
has_doc,
style_apply
),
@@ -332,7 +332,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
let i = style_index(&s.doc()?.doc, name)?;
let add = bool_or(p, "add", false);
let chars = s.prefs.override_char_color;
let own = super::appearance::edit_targets(s, p)?;
let own = super::appearance::edit_targets(s, p, "graphicStyle.apply")?;
let ids = super::appearance::rowless_series_marks(&s.doc()?.doc, &own);
// The series groups their paths stand for: a style that replaces the appearance clears what they hold themselves,
// and a style added on top unlinks them.
@@ -521,7 +521,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
}
fn style_break_link(s: &mut Session, p: &Value) -> Result<Value> {
let ids = super::appearance::whole_targets(s, p)?;
let ids = super::appearance::whole_targets(s, p, "graphicStyle.breakLink")?;
s.edit("Break Link to Graphic Style", |d, _| {
set_link(d, &ids, None);
Ok(())
+1 -1
View File
@@ -167,7 +167,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
}
if apply {
// Objects that can't take a style are refused before any style is added.
super::appearance::edit_targets(s, &if ids_given { json!({"ids": p["ids"]}) } else { json!({}) })?;
super::appearance::edit_targets(s, &if ids_given { json!({"ids": p["ids"]}) } else { json!({}) }, C)?;
}
let mut names = names_param(p, C)?;
names.extend(text_param(p, "name", C)?.map(str::to_string));
+20 -7
View File
@@ -49,7 +49,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Touch Type",
[],
None,
"{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright)} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first logical character of a line or at a reading-direction boundary; supported in either reading direction; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)",
"{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright)} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first logical character of a line or at a reading-direction boundary; supported in either reading direction; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width. An error for a character of type on an open path that is not drawn because it falls before the path's start → {id, start, end} (the character's bytes)",
has_doc,
touch_type
),
@@ -1550,8 +1550,21 @@ fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
change.localize(&t, C)?;
let points = t.style_scale().ok_or_else(|| bad(C, "text transform is collapsed or unrepresentable"))?.points;
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
let g = lay.glyphs.iter().find(|g| g.byte <= start && start < g.byte + g.len).ok_or_else(|| bad(C, format!("no character at byte {start}")))?;
let holds = |g: &&vectorcraft_text::PositionedGlyph| g.byte <= start && start < g.byte + g.len;
lay.glyphs.iter().find(holds).ok_or_else(|| bad(C, format!("no character at byte {start}")))?;
// A character before an open path's start is not drawn; one whose glyphs straddle the start
// is changed by its first drawn glyph.
let g = lay
.glyphs
.iter()
.filter(holds)
.find(|g| !g.off_path)
.ok_or_else(|| bad(C, format!("the character at byte {start} is not drawn: it falls before its path's start")))?;
let (a, b) = (g.byte, g.byte + g.len);
// The same glyph of the character in another layout of the type: its place among the glyphs
// of the character.
let nth = lay.glyphs.iter().filter(|h| h.byte == a && h.len > 0).position(|h| std::ptr::eq(h, g)).unwrap_or(0);
let same = |l: &vectorcraft_text::TextLayout| l.glyphs.iter().filter(|h| h.byte == a && h.len > 0).nth(nth).cloned();
// The line's direction in reading order.
let d = Vec2::new(g.angle.cos(), g.angle.sin());
let along = if g.rtl { -d } else { d };
@@ -1623,7 +1636,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
// rises, which would make its horizontal drift count as baseline movement.
touch(&mut trial, pr.map(|_| 0.0), Some(0.0), to - shift);
let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &trial);
l.glyphs.iter().find(|h| h.byte == a && h.len > 0).map(|h| h.xf * Point::ZERO)
same(&l).map(|h| h.xf * Point::ZERO)
};
let (per_step, across) = match (pen_at(from + step), pen_at(from + 2.0 * step)) {
(Some(p), Some(q)) => (linear(t.xf, q - p).hypot() / step.abs(), (q - p).dot(up) / step),
@@ -1653,7 +1666,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
touch(&mut trial, None, None, 0.0);
let l = vectorcraft_text::layout(db, &trial);
let intrinsic = |h: &vectorcraft_text::PositionedGlyph| h.xf * Point::ZERO - h.xf * h.pivot;
l.glyphs.iter().find(|h| h.byte == a && h.len > 0).map_or(Vec2::ZERO, |h| intrinsic(h) - intrinsic(g))
same(&l).map_or(Vec2::ZERO, |h| intrinsic(&h) - intrinsic(g))
} else {
Vec2::ZERO
};
@@ -1681,7 +1694,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
let mut trial = t.clone();
touch(&mut trial, k.0, k.1, raise);
let l = vectorcraft_text::layout(db, &trial);
let h = l.glyphs.iter().find(|h| h.byte == a && h.len > 0).ok_or_else(|| bad(C, "the character's size can't be measured"))?;
let h = same(&l).ok_or_else(|| bad(C, "the character's size can't be measured"))?;
let across_goal = (g.xf * Point::ZERO).to_vec2().dot(up) + dy / doc_len(up) + intrinsic_delta.dot(up);
raise += (across_goal - (h.xf * Point::ZERO).to_vec2().dot(up)) / baseline_step;
if !raise.is_finite() {
@@ -1695,7 +1708,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
let mut trial = t.clone();
touch(&mut trial, k.0, k.1, raise);
let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &trial);
let h = l.glyphs.iter().find(|h| h.byte == a && h.len > 0)?;
let h = same(&l)?;
let next = match (l.glyphs.iter().find(|n| n.byte == b && n.len > 0 && n.line == h.line), next_goal) {
(Some(n), Some(at)) => pen(n) - at,
_ => 0.0,
@@ -1704,7 +1717,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result<Value> {
(Some(n), Some(at)) => pen(n) - at,
_ => 0.0,
};
Some([pen(h) - goal, next, previous])
Some([pen(&h) - goal, next, previous])
};
let placed = |m: [f64; 3]| m.iter().all(|v| v.abs() <= 1e-9);
// A manual kerning value turns off the font's kerning for its character, each style run is
+17 -6
View File
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Type on a Path Options…",
["Type", "Type on a Path"],
None,
"{ids?|id?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); alignToPath runs each character's em box top (ascender), font bounding box bottom (descender), half the height of its H's top above the baseline (center) or baseline along the path, by the character's own font, size and vertical scale (vertical type: half an em either way, or none for center); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}",
"{ids?|id?, start?: -1000000..1 (below 0 only on an open path), end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path); on an open path a start below 0 starts the text that far before the path's start, and the characters whose middle falls before it are not drawn (on a closed path it is the path's start), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); alignToPath runs each character's em box top (ascender), font bounding box bottom (descender), half the height of its H's top above the baseline (center) or baseline along the path, by the character's own font, size and vertical scale (vertical type: half an em either way, or none for center); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}",
has_selection,
path_options
),
@@ -1018,7 +1018,18 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) => v.as_f64().filter(|x| x.is_finite()).map(|x| Some(x.clamp(0.0, 1.0))).ok_or_else(|| bad(C, format!("`{k}` is a number 0..1"))),
}
};
let start = fraction("start")?;
// On an open path the text may start before the path's start (below 0, see
// `TextKind::path_text_start`); on a closed path a start below 0 is the path's start.
let start = match p.get("start") {
None | Some(Value::Null) => None,
Some(v) => Some(
v.as_f64()
.filter(|x| x.is_finite())
.map(|x| x.clamp(-vectorcraft_doc::PATH_LEAD_MAX, 1.0))
.ok_or_else(|| bad(C, "`start` is a number up to 1 (below 0: before an open path's start)"))?,
),
};
let start_on = |path: &vectorcraft_geom::PathData, v: f64| if path.is_closed() { v.max(0.0) } else { v };
// `end: null` puts the end bracket back at the end of the path.
let end = p.get("end").map(|_| fraction("end")).transpose()?;
let flip = switch_or(p, "flip", false, C)?;
@@ -1037,8 +1048,8 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
// Without a flip, when every object already has every value given, the document and its undo
// history stay as they are.
let has_values = |t: &TextObject| match &t.kind {
TextKind::OnPath { start: st, end: en, .. } => {
start.is_none_or(|v| v == *st)
TextKind::OnPath { path, start: st, end: en } => {
start.is_none_or(|v| start_on(path, v) == *st)
&& end.is_none_or(|v| v == *en)
&& effect.is_none_or(|e| t.path_effect == e)
&& align.is_none_or(|a| t.path_align == a)
@@ -1053,9 +1064,9 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Type on a Path Options", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let TextKind::OnPath { start: st, end: en, .. } = &mut t.kind else { continue };
let TextKind::OnPath { path, start: st, end: en } = &mut t.kind else { continue };
if let Some(v) = start {
*st = v;
*st = start_on(path, v);
}
if let Some(v) = end {
*en = v;
+83 -1
View File
@@ -115,7 +115,8 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
"scatter-shapes",
"game-trails",
"energy-beams",
"dry-media"
"dry-media",
"tech-circuit"
]
);
assert!(libs.iter().all(|l| l["category"] == "builtIn"));
@@ -217,6 +218,87 @@ fn black_stroke() -> vectorcraft_doc::StrokeLayer {
vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0)
}
/// Original circuit art and repeat tiles survive adding, applying and saving a library.
#[test]
fn the_tech_circuit_library_constructs_adds_and_applies_each_brush() {
let mut s = session();
let listed = run(&mut s, "brush.libraries", json!({}));
let entry = listed["libraries"].as_array().unwrap().iter().find(|l| l["id"] == "tech-circuit").unwrap();
assert_eq!((entry["name"].as_str(), entry["category"].as_str(), entry["count"].as_u64()), (Some("Tech Circuit"), Some("builtIn"), Some(3)));
let (_, lib) = cmd::brushlib::library(&s, "Tech Circuit").unwrap();
let expected = [("Circuit Trace", "art"), ("Bus Lines", "pattern"), ("Terminal Nodes", "pattern")];
assert_eq!(lib.brushes.iter().map(|b| (b.name.as_str(), b.kind.type_id())).collect::<Vec<_>>(), expected);
let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 20, "x2": 210, "y2": 20}))["id"].as_u64().unwrap();
let curve = {
let mut p = line_to(40.0);
p.curve_to((60.0, -40.0), (140.0, 40.0), (200.0, 0.0));
p
};
let corner = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 80.0, 60.0)).to_bezpath();
for b in &lib.brushes {
let mut sanitized = b.clone();
sanitized.sanitize();
assert_eq!(&sanitized, b, "{} changes on load", b.name);
for tile in b.art() {
let pts = anchors(&[tile]);
assert!(!pts.is_empty() && pts.len() <= 100, "{}", b.name);
assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()));
tile.walk(&mut |n| {
if let Some(p) = n.path_data() {
assert!(p.subpaths.iter().all(|s| s.closed));
}
});
}
if let BrushKind::Pattern(p) = &b.kind {
assert_eq!(p.colorization, vectorcraft_brush::Colorization::Tints);
assert_eq!(p.spacing, 0.0, "conductors join adjacent tiles");
if b.name == "Bus Lines" {
let lanes = p.side.children().unwrap();
assert_eq!(lanes.len(), 3);
let boxes: Vec<_> = lanes.iter().map(|n| n.path_data().unwrap().bounds().unwrap()).collect();
for r in &boxes {
assert_eq!((r.x0, r.x1), (0.0, 24.0));
}
assert!(boxes.windows(2).all(|r| r[0].y1 < r[1].y0), "lanes stay separate");
}
if b.name == "Terminal Nodes" {
let mut rings = 0;
p.side.walk(&mut |n| {
if matches!(n.kind, NodeKind::Compound { rule: vectorcraft_geom::FillRule::EvenOdd, .. }) {
rings += 1;
}
});
assert_eq!(rings, 1, "a hollow circular test point per tile");
}
}
for p in [line_to(200.0), line_to(2.0), curve.clone(), corner.clone()] {
let pieces = vectorcraft_brush::stroke_pieces(&lib.doc, b, &p, &black_stroke());
let pts = anchors(&pieces.iter().collect::<Vec<_>>());
assert!(!pts.is_empty() && pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()), "{}", b.name);
}
let before = undo_len(&s);
let r = run(&mut s, "brush.addFromLibrary", json!({"library": "tech-circuit", "name": b.name, "apply": true, "ids": [path]}));
assert_eq!(strs(&r["added"]), [b.name.as_str()]);
assert_eq!(r["applied"], json!([path]));
assert_eq!(undo_len(&s), before + 1);
assert_eq!(brush(&s, &b.name), *b);
assert_eq!(doc(&s).node(NodeId(path)).unwrap().appearance.stroke().unwrap().brush.as_deref(), Some(b.name.as_str()));
run(&mut s, "edit.undo", json!({}));
assert!(vectorcraft_brush::find(doc(&s), &b.name).is_none());
run(&mut s, "edit.redo", json!({}));
let before = undo_len(&s);
let again = run(&mut s, "brush.addFromLibrary", json!({"library": "tech-circuit", "name": b.name}));
assert!(strs(&again["added"]).is_empty());
assert_eq!(strs(&again["existing"]), [b.name.as_str()]);
assert_eq!(undo_len(&s), before);
}
let names: Vec<&str> = expected.iter().map(|(name, _)| *name).collect();
let saved = run(&mut s, "brush.saveLibrary", json!({"names": names}));
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
let reopened = vectorcraft_format::load(&bytes).unwrap();
assert_eq!(vectorcraft_brush::library(&reopened), lib.brushes);
}
/// Ink and Wash: a tapered ink art brush, a brush pen whose size follows the pen pressure and two
/// translucent washes, with names of their own and a few paths of art each.
#[test]
+26
View File
@@ -127,6 +127,32 @@ fn options_keep_the_brackets() {
}
}
/// `start` below 0 starts type on an open path before the path's start, one undo step; on a closed
/// path it is the path's start, and a start that is not a number is an error.
#[test]
fn options_start_type_before_an_open_path() {
let mut s = session();
let id = on_line(&mut s);
let n = undo_len(&s);
let r = s.execute("type.pathOptions", &json!({"start": -0.25})).unwrap();
assert_eq!(brackets(&s, id), (-0.25, None));
assert!(near(r["start"].as_f64().unwrap(), -0.25), "{r}");
assert_eq!(undo_len(&s), n + 1);
// The same start again records nothing.
s.execute("type.pathOptions", &json!({"start": -0.25})).unwrap();
assert_eq!(undo_len(&s), n + 1);
// 100 pt before a 400 pt line: the 12 pt word is drawn nowhere.
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id));
assert!(!laid.glyphs.is_empty() && laid.glyphs.iter().all(|g| g.outline.elements().is_empty()));
assert!(s.execute("type.pathOptions", &json!({"start": "-0.5"})).is_err());
// A flip keeps the stretch from the path's start: the type then starts at the start bracket.
s.execute("type.pathOptions", &json!({"flip": true})).unwrap();
assert_eq!(brackets(&s, id), (0.0, None));
let ring = path_type(&mut s, "M100 100 L300 100 L300 300 L100 300 Z", [200.0, 90.0], "Ring");
s.execute("type.pathOptions", &json!({"ids": [ring.0], "start": -0.25})).unwrap();
assert_eq!(brackets(&s, ring).0, 0.0);
}
/// Type on a Path Options given the values the type has (OK in the dialog with nothing changed, the
/// effect it has from the Type on a Path menu, the end bracket at the end of the path, or the
/// query's own reply) records no undo step: the step to redo stays and the document stays
+38
View File
@@ -724,6 +724,44 @@ fn touch_type_moves_one_character_and_leaves_the_others_where_they_are() {
assert!(s.execute("text.touchType", &json!({"id": id.0, "start": (1u64 << 32) + 1, "dy": 1})).is_err(), "no character there");
}
/// Touch Type does not change a character of type on an open path that is not drawn because it
/// falls before the path's start; a drawn character of the same type still changes.
#[test]
fn touch_type_refuses_a_character_before_an_open_paths_start() {
let mut s = session();
let pid = s.execute("path.create", &json!({"d": "M100 300 L500 300"})).unwrap()["id"].as_u64().unwrap();
let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": "Hidden", "size": 12, "at": [100, 300]})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("type.pathOptions", &json!({"start": -0.02})).unwrap();
let steps = undo_labels(&s).len();
assert!(s.execute("text.touchType", &json!({"id": id.0, "start": 0, "dy": 2})).is_err(), "the H is not drawn");
assert_eq!(undo_labels(&s).len(), steps);
s.execute("text.touchType", &json!({"id": id.0, "start": 5, "dy": 2})).unwrap();
// An All Caps ß is two glyphs: with the first before the path's start and the second drawn, it
// still changes.
let pid = s.execute("path.create", &json!({"d": "M100 400 L500 400"})).unwrap()["id"].as_u64().unwrap();
let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": "ße", "size": 40, "at": [100, 400]})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "allCaps": true})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let adv = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(&s, id)).glyphs[0].advance;
s.execute("type.pathOptions", &json!({"start": -1.2 * adv / 400.0})).unwrap();
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(&s, id));
assert!(lay.glyphs[0].byte == 0 && lay.glyphs[1].byte == 0 && lay.glyphs[0].off_path && !lay.glyphs[1].off_path);
let drawn = |s: &Session| {
let t = obj(s, id);
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
let g = lay.glyphs.iter().rfind(|g| g.byte == 0 && !g.off_path).unwrap();
t.xf * (g.xf * Point::ZERO)
};
let before = drawn(&s);
s.execute("text.touchType", &json!({"id": id.0, "start": 0, "dy": 2})).unwrap();
// The drawn glyph rises 2 points.
let after = drawn(&s);
assert!((after.x - before.x).abs() < 1e-6 && (after.y - (before.y - 2.0)).abs() < 1e-6, "{before:?} -> {after:?}");
}
/// Touch Type on the first character of a list item moves that character and leaves the bullet
/// where it is. The bullet stands for no character, so the first character still moves only up
/// and down.
+10
View File
@@ -5128,6 +5128,16 @@ fn a_first_baseline_minimum_and_a_path_end_the_reader_cant_read_are_reported() {
};
assert_eq!(with(on_path(Some(0.999))).0, [writer::TYPE_ATTRIBUTES]);
assert!(with(on_path(None)).0.is_empty());
// Type that starts before its open path comes back starting at the path's start.
let mut before = on_path(None);
if let TextKind::OnPath { start, .. } = &mut before.kind {
*start = -0.25;
}
let (warnings, back) = with(before);
assert_eq!(warnings, [writer::TYPE_ATTRIBUTES]);
let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type") };
let TextKind::OnPath { start, .. } = t.kind else { panic!("type on a path") };
assert!(start.abs() < 1e-3, "{start}");
let (warnings, back) = with(on_path(Some(0.5)));
assert!(warnings.is_empty(), "{warnings:?}");
let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type") };
+7 -1
View File
@@ -440,7 +440,13 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool {
// The text document holds the Ascent first baseline by leaving it out, without a minimum.
|| (t.area.first_baseline == FirstBaseline::Ascent && t.area.first_baseline_min != 0.0)))
|| (matches!(t.kind, TextKind::OnPath { .. })
&& (t.path_effect != PathEffect::default() || t.path_align != PathAlign::default() || t.path_spacing != 0.0 || end_read_as_none(t)))
&& (t.path_effect != PathEffect::default()
|| t.path_align != PathAlign::default()
|| t.path_spacing != 0.0
|| end_read_as_none(t)
// The writer writes type that starts before its open path starting at the
// path's start.
|| t.kind.starts_before_path()))
}
/// Key 35 of a run in style `st`, as the reader's `Texts::style` reads it: 3 for Tate-chu-yoko, 1
+5 -1
View File
@@ -137,7 +137,11 @@ pub(super) fn lost_attributes(t: &TextObject) -> bool {
|| !t.fractional_widths
|| (matches!(t.kind, TextKind::Area { .. }) && t.area != AreaOptions::default())
|| (matches!(t.kind, TextKind::OnPath { .. })
&& (t.path_effect != PathEffect::default() || t.path_align != PathAlign::default() || t.path_spacing != 0.0 || end_bracket(t)))
&& (t.path_effect != PathEffect::default()
|| t.path_align != PathAlign::default()
|| t.path_spacing != 0.0
|| end_bracket(t)
|| t.kind.starts_before_path()))
|| several_paths(&t.kind)
}
+16
View File
@@ -1560,6 +1560,22 @@ fn type_on_a_path_alignment_and_end_bracket_are_warned_as_left_out() {
assert!(warned(&|t| t.kind = TextKind::OnPath { path: along(true), start: 0.7, end: Some(0.5) }));
// An open path's end bracket before its start bracket.
assert!(warned(&|t| t.kind = TextKind::OnPath { path: along(false), start: 0.5, end: Some(0.3) }));
// Type that starts before its open path: the text path holds a start on the path only.
assert!(warned(&|t| t.kind = TextKind::OnPath { path: along(false), start: -0.25, end: None }));
assert!(!warned(&|t| t.kind = TextKind::OnPath { path: along(false), start: 0.0, end: None }));
// It is written starting at the path's start: the same text object as a start of 0.
let written = |start: f64| {
let mut doc = Document::new(300.0, 200.0);
let layer = doc.layers[0].id;
let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "Along", style(12.0, Paint::solid(Color::BLACK)));
t.kind = TextKind::OnPath { path: along(false), start, end: None };
t.xf = Affine::IDENTITY;
let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
doc.insert(Some(layer), usize::MAX, n).unwrap();
write(&doc).bytes
};
assert_eq!(written(-0.25), written(0.0));
assert_ne!(written(0.25), written(0.0));
let two = |first_closed: bool| {
let mut path = along(first_closed);
let pts = [(150.0, 100.0).into(), (250.0, 100.0).into()];
+3 -3
View File
@@ -800,7 +800,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"Cmd+Shift+F6",
"{} make the open document before the current one in tab order the current one, as a click on its tab does (before the first document it goes to the last, and from the Home screen to the last); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view",
),
("tool.select", "Select Tool", "", "{tool: id} (see tools)"),
("tool.select", "Select Tool", "", "{tool: id} (see tools) → {tool}; a tool that cannot be chosen returns the error"),
(
"tool.setOption",
"Tool Option",
@@ -811,7 +811,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"tool.toggleTouchMode",
"Switch Selection and Drawing Modes",
"",
"{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)",
"{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}; a tool that cannot be chosen returns the error and leaves the drawing tool as it was. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)",
),
(
"effect.dialog",
@@ -830,7 +830,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.cropImage",
"Crop Image",
"",
"{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box",
"{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box. → {tool}; a tool that cannot be chosen returns the error",
),
("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"),
(
+2 -1
View File
@@ -246,7 +246,8 @@ impl Exporter<'_> {
// Spaces are written too (text extraction needs them); control characters and soft
// hyphens draw nothing.
let blank = g.outline.elements().is_empty() && !coloured;
if blank && !ch.is_some_and(|c| c.is_whitespace() && !c.is_control()) {
// Characters before the start of an open path are not drawn, spaces included.
if g.off_path || (blank && !ch.is_some_and(|c| c.is_whitespace() && !c.is_control())) {
continue;
}
// The glyph at the font size of its vertical scale: `frame` maps that size's glyph
+20
View File
@@ -304,6 +304,26 @@ fn real_text_lands_within_half_a_point_of_the_outlines() {
}
}
/// Type on an open path that starts before the path writes only the characters drawn on the path,
/// as text and as outlines.
#[test]
fn type_starting_before_its_open_path_writes_only_what_is_drawn() {
let mut line = BezPath::new();
line.move_to((20.0, 150.0));
line.line_to((380.0, 150.0));
let mut t = TextObject::point(Point::ZERO, "A C DEF", style(20.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
t.xf = Affine::IDENTITY;
let laid = vectorcraft_text::layout(FontDb::global(), &t);
// Four glyphs, two of them spaces, and a little more before the start: "DEF" is drawn.
let lead: f64 = laid.glyphs.iter().take(4).map(|g| g.advance).sum::<f64>() + 1.0;
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -lead / 360.0, end: None };
let d = doc(vec![t]);
assert_eq!(texts(&import_as(&pdf(&d, false).bytes, TextAs::Text)).concat(), "DEF");
let outlined = ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines));
assert!(outlined.x0 > 20.0 - 1e-6, "{outlined:?}");
}
/// Area type 200 points wide holding `text`, its paragraphs in lists `lists`, with an 18 pt
/// hanging indent.
fn listed(text: &str, lists: &[Option<ParaList>]) -> TextObject {
+18
View File
@@ -117,6 +117,24 @@ fn variable_font_settings_are_not_highlighted_as_substituted_glyphs() {
assert!(g.alternate_glyphs(&t).elements().is_empty());
}
/// Type in a missing font that starts before its open path: only the characters drawn on the path
/// are highlighted as substituted.
#[test]
fn characters_before_an_open_path_are_not_highlighted() {
let _steady = fonts_steady();
let style = CharStyle { size: 20.0, font_family: "No Such Family 5932".into(), ..Default::default() };
let mut t = TextObject::point(Point::ZERO, "MMMM", style);
let mut line = vectorcraft_geom::BezPath::new();
line.move_to((0.0, 50.0));
line.line_to((500.0, 50.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
let whole = text_geom(&t).substituted_fonts;
assert!(!whole.elements().is_empty());
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -0.9, end: None };
let g = text_geom(&t);
assert!(g.all.elements().is_empty() && g.substituted_fonts.elements().is_empty(), "{:?}", g.substituted_fonts);
}
/// Type in a composite font draws each class of character in its part's face, which is not a
/// substitute: Document Setup's highlight of substituted glyphs leaves it alone.
#[test]
+2 -1
View File
@@ -183,7 +183,8 @@ pub(crate) fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeo
let (mut substituted_fonts, mut substituted_glyphs, mut alternate_candidates) = (BezPath::new(), BezPath::new(), vec![]);
let plain = t.plain_text();
let (mut images, mut colours) = (vec![], vec![]);
for g in &layout.glyphs {
// Glyphs before the start of an open path draw nothing and have no highlight cells.
for g in layout.glyphs.iter().filter(|g| !g.off_path) {
push(&mut all, g.run, &g.outline);
if let Some((image, xf)) = g.placed_image() {
images.push((g.run, image.clone(), xf));
+3
View File
@@ -571,6 +571,9 @@ impl Writer<'_> {
let base = self.char_props(&drawn.first_style(), space);
let mut props = base.clone();
let (from, to) = t.kind.path_span().unwrap_or((start, 1.0));
// Type on an open path may start before the path: a negative `startOffset`, and a reader
// does not draw the characters before the path's start, as the layout does not.
let from = t.kind.path_text_start().unwrap_or(from);
// The anchor sits where the layout aligns the line within its span.
let at = match t.para_at(0).justify {
Justify::Center | Justify::JustifyCenter => {
+13 -3
View File
@@ -1473,16 +1473,26 @@ impl Ctx {
let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0];
let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 };
// On a path of one closed subpath, text that starts before the path's start or past its
// end wraps around the path. Otherwise it starts at the path's start or end.
// end wraps around the path. On an open path text may start before the path's start,
// and its characters before the start are not drawn; text that starts past the end
// starts at the end. Other paths start it at the path's start or end.
let path = PathData::from_bezpath(&bp);
let wraps = path.subpaths.len() == 1 && path.is_closed();
let open = !path.is_closed();
let wrap = |s: f64| {
// `rem_euclid` can round a tiny negative value up to 1.
let s = s.rem_euclid(1.0);
if s < 1.0 { s } else { 0.0 }
};
let start =
Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| if wraps { wrap(s) } else { s.clamp(0.0, 1.0) });
let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| {
if wraps {
wrap(s)
} else if open {
s.clamp(-vectorcraft_doc::PATH_LEAD_MAX, 1.0)
} else {
s.clamp(0.0, 1.0)
}
});
obj.kind = TextKind::OnPath { path, start, end: None };
obj.xf = Affine::IDENTITY;
} else if vertical {
+51 -3
View File
@@ -335,10 +335,12 @@ fn text_on_a_closed_path_wraps_past_the_path_start() {
// A negative startOffset, in user units or as a percentage, counts back from the path's end.
assert!(close(start_of(t[4]), 0.9, 1e-9), "{}", start_of(t[4]));
assert!(close(start_of(t[5]), 0.9, 1e-9), "{}", start_of(t[5]));
// A path of two closed subpaths, and an open path, do not wrap: the text starts at the path's
// start or end.
// A path of two closed subpaths does not wrap: the text starts at the path's start. An open
// path does not wrap either: text anchored before its start starts there
// (`text_on_an_open_path_starts_before_the_path`), and text offset past its end starts at
// its end.
assert_eq!(start_of(t[6]), 0.0);
assert_eq!(start_of(t[7]), 0.0);
assert!(close(start_of(t[7]), -width / 300.0, 1e-6), "{} {width}", start_of(t[7]));
assert_eq!(start_of(t[8]), 1.0);
// Laid out, the end-anchored text draws every glyph up the left side and ends at the start
@@ -372,6 +374,52 @@ fn text_on_a_closed_path_wraps_past_the_path_start() {
assert!(lay.glyphs.iter().enumerate().all(|(i, g)| g.byte == i), "{:?}", lay.glyphs.iter().map(|g| g.byte).collect::<Vec<_>>());
}
#[test]
fn text_on_an_open_path_starts_before_the_path() {
// An open path 300 long. Text anchored, or offset, before its start starts that far before it,
// and only the characters whose middle is on the path are drawn; the text keeps every character.
let d = open(
r##"<path id="o" d="M50 50 H150 V150 H50" fill="none" stroke="black"/>
<text font-size="20" text-anchor="end"><textPath href="#o">Wave</textPath></text>
<text font-size="20" text-anchor="middle"><textPath href="#o">Wave</textPath></text>
<text font-size="20"><textPath href="#o" startOffset="-20%">Wave</textPath></text>
<text font-size="20"><textPath href="#o">Wave</textPath></text>"##,
);
let t = texts(&d);
assert_eq!(t.len(), 4);
let start_of = |t: &TextObject| match t.kind {
TextKind::OnPath { start, .. } => start,
_ => panic!("{:?}", t.kind),
};
let whole = layout(FontDb::global(), t[3]);
let width: f64 = whole.glyphs.iter().map(|g| g.advance).sum();
assert!(width > 0.0 && width < 60.0, "{width}");
assert!(close(start_of(t[0]), -width / 300.0, 1e-6), "{} {width}", start_of(t[0]));
assert!(close(start_of(t[1]), -width / 600.0, 1e-6), "{} {width}", start_of(t[1]));
assert!(close(start_of(t[2]), -0.2, 1e-9), "{}", start_of(t[2]));
let drawn = |t: &TextObject| {
let lay = layout(FontDb::global(), t);
assert!(!lay.overflow && lay.glyphs.len() == 4, "{} {}", lay.overflow, lay.glyphs.len());
assert_eq!(t.plain_text(), "Wave");
lay.glyphs.iter().map(|g| !g.outline.elements().is_empty()).collect::<Vec<_>>()
};
// Ending at the path's start: nothing is drawn.
assert_eq!(drawn(t[0]), [false; 4]);
// Centered on the path's start: the characters whose middle is past the start are drawn, the
// last ones of the text.
// (A hidden glyph has no advance: the advances are the ones the text takes on the path.)
let middle = drawn(t[1]);
let mut x = -width / 2.0;
for (g, shown) in whole.glyphs.iter().zip(&middle) {
assert_eq!(*shown, x + g.advance / 2.0 >= 0.0, "{x} {middle:?}");
x += g.advance;
}
assert!(middle.iter().any(|d| *d) && !middle[0] && middle[3], "{middle:?}");
// 60 before the start, more than the text's width: nothing is drawn.
assert_eq!(drawn(t[2]), [false; 4]);
assert_eq!(drawn(t[3]), [true; 4]);
}
/// The unit vector along a glyph's baseline.
fn baseline_dir(angle: f64) -> vectorcraft_geom::Vec2 {
vectorcraft_geom::Vec2::new(angle.cos(), angle.sin())
+25
View File
@@ -478,6 +478,31 @@ fn aligned_text_on_path_anchors_within_its_span() {
}
}
/// Type on an open path that starts before the path is written with a negative `startOffset` and
/// reads back starting there, with the same characters drawn.
#[test]
fn text_on_an_open_path_starting_before_it_round_trips() {
let mut line = vectorcraft_geom::BezPath::new();
line.move_to((0.0, 100.0));
line.line_to((200.0, 100.0));
let mut d = Document::new(200.0, 200.0);
let mut t = TextObject::point(Point::ZERO, "Before", CharStyle { size: 14.0, ..CharStyle::default() });
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -0.1, end: None };
let shown = |t: &TextObject| {
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).glyphs.iter().map(|g| !g.outline.elements().is_empty()).collect::<Vec<_>>()
};
let before = shown(&t);
assert!(before.contains(&true) && before.contains(&false), "{before:?}");
let id = d.alloc_id();
let svg = export(&doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]), &ExportOptions::default());
assert!(svg.contains("startOffset=\"-10%\""), "{svg}");
let r = import(&svg).unwrap();
let NodeKind::Text(t) = &art(&r)[0].kind else { panic!() };
let TextKind::OnPath { start, .. } = &t.kind else { panic!("not type on a path: {:?}", t.kind) };
assert!((start + 0.1).abs() < 1e-6, "start {start}");
assert_eq!(shown(t), before);
}
/// An end bracket, Align to Path or Spacing, which `<textPath>` can't express, keep their look as
/// outlines (#429).
#[test]
+1
View File
@@ -626,6 +626,7 @@ impl Ctx<'_> {
width_start,
warichu: g.wari.map(|w| w.start),
warichu_line: g.wari.map_or(0.0, |w| w.across),
off_path: false,
});
}
+38 -2
View File
@@ -380,6 +380,35 @@ pub(crate) fn layout_trying_each_start(db: &crate::fontdb::FontDb, t: &TextObjec
l
}
/// For type that starts before its open path `ap` (`lead`), a glyph whose middle is `mid` along the
/// path and falls before the path's start is not drawn, as SVG 2 lays out text on a path
/// (https://www.w3.org/TR/SVG2/text.html#TextLayoutPath): glyph `at` keeps its place in the layout
/// for the caret, with no length at the path's start (moved off the path by `rise`, as Align to Path
/// moves the glyph), and draws nothing ([`crate::PositionedGlyph::off_path`]). True when it is hidden.
fn hide_before_open_path(cx: &mut Ctx<'_>, ap: &ArcPath, lead: bool, at: usize, mid: f64, rise: f64) -> bool {
if !lead || ap.is_closed() || mid >= 0.0 {
return false;
}
let (p, dir) = ap.at(0.0);
let start = p + Vec2::new(-dir.y, dir.x) * rise;
if let Some(glyph) = cx.out.glyphs.get_mut(at) {
glyph.origin = start;
glyph.advance = 0.0;
glyph.width = 0.0;
glyph.width_start = 0.0;
glyph.outline = BezPath::new();
glyph.image = None;
glyph.colour = None;
// A ligature's caret stops stay, at the path's start with the glyph.
for c in &mut glyph.carets {
c.1 = 0.0;
}
glyph.off_path = true;
}
cx.out.inlines.retain(|i| i.glyph != at);
true
}
/// Lay type on a path out along `path` (text space), between its brackets.
pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &PathData) {
cx.out.on_path = true;
@@ -453,6 +482,11 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject,
}
let centre = path.bounds().unwrap_or_default().center();
let (from, to) = t.kind.path_span().unwrap_or((0.0, 1.0));
// On an open path the text may start before the path's start: the span runs from there, so
// Dynamic Text fills it to the end bracket, and the drawn text runs from the path's start to the
// end bracket.
let from = t.kind.path_text_start().unwrap_or(from);
let lead = t.kind.starts_before_path();
let s_start = from * ap.len();
let avail = (to - from) * ap.len();
let spacing = if t.path_spacing.is_finite() { t.path_spacing } else { 0.0 };
@@ -491,7 +525,7 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject,
}
}
} else {
(l.min(s_start), r.min((ap.len() - s_start - avail).max(0.0)))
(l.min(s_start.max(0.0)), r.min((ap.len() - s_start - avail).max(0.0)))
};
(l, r, hung_r)
};
@@ -673,10 +707,12 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject,
m.tab = at;
}
}
let at = cx.out.glyphs.len();
cx.emit(g, pre, origin, angle, g.adv, 0);
let hidden = hide_before_open_path(cx, &ap, lead, at, s + step * 0.5, rise);
// A glyph of a script whose letters join is bent along a curved path (Rainbow), so its
// joining strokes meet its neighbours' instead of standing off the curve.
if t.path_effect == PathEffect::Rainbow && g.cursive && !cx.vertical && g.inline.is_none() {
if !hidden && t.path_effect == PathEffect::Rainbow && g.cursive && !cx.vertical && g.inline.is_none() {
let bend = GlyphBend { s, step, advance: g.adv, rise };
if let Some(last) = cx.out.glyphs.last_mut()
&& last.image.is_none()
+1
View File
@@ -379,5 +379,6 @@ fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usi
width_start: 0.0,
warichu: None,
warichu_line: 0.0,
off_path: false,
}
}
+13 -3
View File
@@ -188,6 +188,12 @@ pub struct PositionedGlyph {
/// line's baseline before Character Alignment moves the glyph (y down). It names the block
/// line whatever the alignment of each character on it. 0 for any other glyph.
pub warichu_line: f64,
/// Type on an open path that starts before the path: the glyph's middle falls before the
/// path's start, so it draws nothing (its `outline` is empty and it has no image or color
/// art), hit testing and Touch Type skip it while a glyph is drawn, and exports leave it out.
/// It stays in the layout for the caret, at the path's start with no advance or width, and its
/// ligature carets stand there too.
pub off_path: bool,
}
/// How a glyph on a path is bent along it: a point of its pen space (x along its advance from its
@@ -600,11 +606,15 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize {
.map(|(_, d)| (p - (g.origin + along * if g.rtl { g.advance - d } else { d })).hypot2())
.fold(mid, f64::min)
};
let best = layout.glyphs.iter().enumerate().min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b)));
// Glyphs before the start of an open path draw nothing: a click goes to a drawn one when
// there is one.
let drawn = layout.glyphs.iter().any(|g| !g.off_path);
let best = layout.glyphs.iter().enumerate().filter(|(_, g)| !(drawn && g.off_path)).min_by(|(_, a), (_, b)| near(a).total_cmp(&near(b)));
let Some((i, g)) = best else { return layout.lines.first().map_or(0, |l| l.start) };
// `first` and `last` are the first and last glyphs of the cluster. A click before the middle of
// the cluster, measured along the first glyph's direction, goes to the cluster's start.
let same = |o: &&PositionedGlyph| o.byte == g.byte && o.len == g.len;
// the cluster, measured along the first glyph's direction, goes to the cluster's start. A
// cluster's glyphs before an open path's start are left out when one of its glyphs is drawn.
let same = |o: &&PositionedGlyph| o.byte == g.byte && o.len == g.len && !(drawn && o.off_path);
let first = layout.glyphs[..=i].iter().rev().take_while(same).last().unwrap_or(g);
let last = layout.glyphs[i..].iter().take_while(same).last().unwrap_or(g);
let along = dir(first.angle);
+79
View File
@@ -635,6 +635,85 @@ fn on_path_placement() {
assert_eq!(hit_byte(&l, mid), 1);
}
/// Type on an open path that starts before the path's start draws only the glyphs whose middle is
/// on the path; the others stay in the layout, drawing nothing. On a closed path a start below 0 is
/// the path's start, as before.
#[test]
fn on_path_open_start_before_the_path_hides_the_glyphs_before_it() {
let mut line = BezPath::new();
line.move_to((0.0, 50.0));
line.line_to((500.0, 50.0));
let mut t = point("MMMM", style(20.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
let whole = layout(db(), &t);
let adv = whole.glyphs[0].advance;
assert!(adv > 1.0 && whole.glyphs.iter().all(|g| !g.outline.elements().is_empty()));
// 2.4 glyphs before the start: the first two are before it, and the third's middle is just past
// the path's start, so it is drawn.
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -2.4 * adv / 500.0, end: None };
let l = layout(db(), &t);
assert!(l.on_path && !l.overflow);
assert_eq!(l.glyphs.len(), 4);
let drawn: Vec<bool> = l.glyphs.iter().map(|g| !g.outline.elements().is_empty()).collect();
assert_eq!(drawn, [false, false, true, true]);
assert_eq!(l.glyphs.iter().map(|g| g.off_path).collect::<Vec<_>>(), [true, true, false, false]);
assert!(l.glyphs[..2].iter().all(|g| g.carets.iter().all(|c| c.1 == 0.0) && g.advance == 0.0 && g.width == 0.0));
assert!(whole.glyphs.iter().all(|g| !g.off_path));
// Type that starts on the path keeps drawing every glyph, even where negative tracking steps
// its glyphs back before the path's start.
let mut packed = t.clone();
packed.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
packed.runs[0].style.tracking = -1000.0;
let p = layout(db(), &packed);
assert!(p.glyphs.len() == 4 && p.glyphs.iter().all(|g| !g.off_path && !g.outline.elements().is_empty()));
assert!((l.glyphs[2].origin.x + 0.4 * adv).abs() < 1e-6, "{:?}", l.glyphs[2].origin);
assert!((l.glyphs[3].origin.x - 0.6 * adv).abs() < 1e-6, "{:?}", l.glyphs[3].origin);
assert_eq!(l.lines[0].end, 4);
// A click at the path's start goes before the first drawn character, not into a hidden one, and
// the caret in the hidden characters stands at the path's start.
assert_eq!(hit_byte(&l, Point::new(0.0, 50.0)), 2);
for b in [0, 1] {
let (top, bottom) = caret_position(&l, b);
assert!(top.x.abs() < 1e-6 && bottom.x.abs() < 1e-6, "{b}: {top:?} {bottom:?}");
}
// An All Caps ß is two glyphs. With the first before the path's start, a click is measured
// against the drawn one: just past its middle, the caret goes after the character.
let mut caps = point("ße", style(20.0));
caps.runs[0].style.all_caps = true;
caps.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
let s = layout(db(), &caps).glyphs[0].advance;
caps.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -1.2 * s / 500.0, end: None };
let c = layout(db(), &caps);
assert!(c.glyphs[0].byte == 0 && c.glyphs[1].byte == 0 && c.glyphs[0].off_path && !c.glyphs[1].off_path);
let mid = c.glyphs[1].origin.x + c.glyphs[1].advance / 2.0;
assert_eq!(hit_byte(&c, Point::new(mid + 0.05 * s, 50.0)), 2);
assert_eq!(hit_byte(&c, Point::new(mid - 0.05 * s, 50.0)), 0);
// With Align to Path, the hidden characters' caret is moved off the path as the drawn ones are.
let mut aligned = t.clone();
aligned.path_align = vectorcraft_doc::PathAlign::Ascender;
let a = layout(db(), &aligned);
let rise = a.glyphs[2].origin.y - 50.0;
assert!(rise.abs() > 1.0, "{rise}");
for g in &a.glyphs[..2] {
assert!(g.off_path && g.origin.x.abs() < 1e-6 && (g.origin.y - 50.0 - rise).abs() < 1e-6, "{:?}", g.origin);
}
// The whole text before the path: nothing is drawn, and nothing overflows.
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: -0.9, end: None };
let l = layout(db(), &t);
assert!(!l.overflow && l.glyphs.len() == 4 && l.glyphs.iter().all(|g| g.outline.elements().is_empty()));
// A closed path starts the text at the path's start.
let mut square = BezPath::new();
square.move_to((0.0, 0.0));
square.line_to((200.0, 0.0));
square.line_to((200.0, 200.0));
square.line_to((0.0, 200.0));
square.close_path();
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&square), start: -0.1, end: None };
let l = layout(db(), &t);
assert!(l.glyphs.iter().all(|g| !g.outline.elements().is_empty()));
assert!(l.glyphs[0].origin.x.abs() < 1e-6 && l.glyphs[0].origin.y.abs() < 1e-6, "{:?}", l.glyphs[0].origin);
}
#[test]
fn on_path_effects_orient_glyphs() {
use vectorcraft_doc::PathEffect;
+1
View File
@@ -170,6 +170,7 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout
width_start: 0.0,
warichu: None,
warichu_line: 0.0,
off_path: false,
});
x += advance;
}
+1
View File
@@ -649,6 +649,7 @@ fn duplicate_authored_glyphs_keep_occurrence_order() {
width_start: 0.0,
warichu: None,
warichu_line: 0.0,
off_path: false,
};
let before = TextLayout { glyphs: vec![glyph(0), glyph(1)], ..TextLayout::default() };
assert_eq!(assert_kashida_ownership(&text, &before, &before, &red(), "duplicate glyphs", false), 0);
+41 -2
View File
@@ -59,6 +59,8 @@ pub struct Brackets {
arc: ArcPath,
/// [`TextKind::path_span`].
span: (f64, f64),
/// [`TextKind::path_text_start`]: below 0 when the type starts before an open path's start.
text_start: f64,
/// The end bracket was placed (not the end of the path, or once round it).
end_set: bool,
}
@@ -78,7 +80,9 @@ impl Brackets {
if !det.is_finite() || det.abs() < 1e-12 || arc.is_empty() {
return None;
}
Some(Self { id, xf: t.xf, arc, span: t.kind.path_span()?, end_set: end.is_some() })
let span = t.kind.path_span()?;
let text_start = t.kind.path_text_start().unwrap_or(span.0);
Some(Self { id, xf: t.xf, arc, span, text_start, end_set: end.is_some() })
}
/// Where bracket `b` stands, as a fraction of the path's length (past 1 round a closed path).
@@ -269,7 +273,13 @@ impl BracketDrag {
d -= d.round();
}
let place = |x: f64| if closed { x.rem_euclid(1.0) } else { x.clamp(0.0, 1.0) };
let start = place(s + d);
// Type that starts before an open path's start slides from there, and goes no
// further before the path than it was: both brackets stay where they are.
let lead = !closed && b.text_start < 0.0;
if lead {
d = d.max(0.0);
}
let start = if lead { (b.text_start + d).min(1.0) } else { place(s + d) };
m.insert("start".into(), json!(start));
if b.end_set {
m.insert("end".into(), json!(if closed { place(e + d) } else { place(e + d).max(start) }));
@@ -409,6 +419,35 @@ mod tests {
assert!(v.get("flip").is_none(), "{v}");
}
/// Type that starts before its open path slides from where it starts, not from the path's start.
#[test]
fn dragging_the_center_bracket_slides_type_that_starts_before_the_path() {
let (mut d, id) = doc_with_path_type();
if let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind)
&& let TextKind::OnPath { start, .. } = &mut t.kind
{
*start = -0.25;
}
let s = selected(id);
let p = paint();
let cx = cx(&d, &s, &p);
// The center bracket stands in the middle of the brackets (the start bracket at the path's start).
let v = preview(&drag(&cx, Point::new(250.0, 295.0), Point::new(280.0, 295.0), Mods::default())).clone();
assert!((v["start"].as_f64().unwrap() + 0.15).abs() < 1e-6, "{v}");
// Back past where it started: it stays there, and so does a placed end bracket.
let v = preview(&drag(&cx, Point::new(250.0, 295.0), Point::new(150.0, 295.0), Mods::default())).clone();
assert!((v["start"].as_f64().unwrap() + 0.25).abs() < 1e-6, "{v}");
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind)
&& let TextKind::OnPath { end, .. } = &mut t.kind
{
*end = Some(0.5);
}
let ended = crate::testutil::cx(&d, &s, &p);
// The center bracket stands at 0.25 of the path, between the start bracket (0) and the end.
let v = preview(&drag(&ended, Point::new(175.0, 295.0), Point::new(130.0, 295.0), Mods::default())).clone();
assert!((v["start"].as_f64().unwrap() + 0.25).abs() < 1e-6 && (v["end"].as_f64().unwrap() - 0.5).abs() < 1e-6, "{v}");
}
#[test]
fn dragging_the_start_and_end_brackets_moves_them() {
let (d, id) = doc_with_path_type();
+8 -5
View File
@@ -150,7 +150,7 @@ fn is_inline(lay: &TextLayout, gi: usize) -> bool {
/// inline graphics, glyphs whose font is gone and type whose transform is collapsed.
fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option<CharBox> {
let g: &PositionedGlyph = lay.glyphs.get(gi)?;
if is_inline(lay, gi) {
if is_inline(lay, gi) || g.off_path {
return None;
}
let m = g.xf;
@@ -161,7 +161,7 @@ fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option<CharBox> {
let corners: Vec<Point> = lay
.glyphs
.iter()
.filter(|h| h.byte == g.byte && h.len > 0 && h.line == g.line)
.filter(|h| h.byte == g.byte && h.len > 0 && h.line == g.line && !h.off_path)
.filter_map(glyph_quad)
.flatten()
.map(|p| to_glyph * p)
@@ -218,7 +218,8 @@ impl TouchTypeTool {
let selected = std::iter::successors(Some(id), |c| cx.doc.parent_of(*c)).any(|c| cx.selection.contains(c));
let t = text_of(cx, id).filter(|_| selected)?;
let lay = self.layout(t);
let gi = lay.glyphs.iter().position(|g| g.byte <= start && start < g.byte + g.len)?;
// The character's first drawn glyph: glyphs before an open path's start are not drawn.
let gi = lay.glyphs.iter().position(|g| g.byte <= start && start < g.byte + g.len && !g.off_path)?;
Some((id, t, char_box(t, &lay, gi)?))
}
@@ -230,7 +231,7 @@ impl TouchTypeTool {
let lay = self.layout(t);
let local = t.xf.inverse() * p;
let boxes: Vec<(usize, [Point; 4])> = (lay.glyphs.iter().enumerate())
.filter(|(gi, g)| g.len > 0 && !is_inline(&lay, *gi))
.filter(|(gi, g)| g.len > 0 && !g.off_path && !is_inline(&lay, *gi))
.filter_map(|(_, g)| Some((g.byte, glyph_quad(g)?)))
.collect();
let center = |q: &[Point; 4]| q[0].midpoint(q[2]);
@@ -360,7 +361,9 @@ impl Tool for TouchTypeTool {
let Some(t) = text_of(cx, id) else { return out };
let lay = self.layout(t);
let start = self.target.map_or(0, |(_, s)| s);
let Some(b) = lay.glyphs.iter().position(|g| g.byte == start && g.len > 0).and_then(|gi| char_box(t, &lay, gi)) else {
// The character's first drawn glyph: glyphs before an open path's start are not drawn.
let first = lay.glyphs.iter().position(|g| g.byte == start && g.len > 0 && !g.off_path);
let Some(b) = first.and_then(|gi| char_box(t, &lay, gi)) else {
return out;
};
(Part::Move, b)
+54
View File
@@ -388,6 +388,60 @@ fn the_box_of_a_character_drawn_with_two_glyphs_holds_both() {
}
}
/// A character of type on an open path that is not drawn, because it falls before the path's
/// start, has no box, so the tool shows no handles for it; a drawn character of the same type has
/// one.
#[test]
fn a_character_before_an_open_paths_start_has_no_box() {
let mut d = Document::new(600.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let mut text = TextObject::point(Point::ZERO, "Type", CharStyle { size: 40.0, ..Default::default() });
let line =
vectorcraft_geom::PathData::new(vec![vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 200.0), Point::new(500.0, 200.0)], false)]);
text.kind = vectorcraft_doc::TextKind::OnPath { path: line, start: -0.05, end: None };
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(text.clone())))).unwrap();
let mut s = Selection::default();
s.add(id);
let p = paint();
let cx = cx(&d, &s, &p);
let lay = vectorcraft_text::layout(FontDb::global(), &text);
assert!(lay.glyphs[0].off_path && !lay.glyphs[1].off_path, "the T is before the path's start, the y is on it");
assert!(char_box(&text, &lay, 0).is_none());
assert!(TouchTypeTool { target: Some((id, 0)), ..Default::default() }.current(&cx).is_none());
assert!(TouchTypeTool { target: Some((id, 1)), ..Default::default() }.current(&cx).is_some());
// An All Caps ß is two glyphs: with the first before the path's start and the second drawn, the
// character has the second's box.
let mut caps = text.clone();
caps.runs = vec![vectorcraft_doc::TextRun::new("ße", CharStyle { size: 40.0, all_caps: true, ..Default::default() })];
if let vectorcraft_doc::TextKind::OnPath { start, .. } = &mut caps.kind {
*start = 0.0;
}
let adv = vectorcraft_text::layout(FontDb::global(), &caps).glyphs[0].advance;
if let vectorcraft_doc::TextKind::OnPath { start, .. } = &mut caps.kind {
*start = -1.2 * adv / 400.0;
}
let mut d = Document::new(600.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
d.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(caps.clone())))).unwrap();
let mut s = Selection::default();
s.add(id);
let cx = crate::testutil::cx(&d, &s, &p);
let lay = vectorcraft_text::layout(FontDb::global(), &caps);
assert!(lay.glyphs[0].byte == 0 && lay.glyphs[1].byte == 0 && lay.glyphs[0].off_path && !lay.glyphs[1].off_path);
let t = TouchTypeTool { target: Some((id, 0)), ..Default::default() };
let (_, _, b) = t.current(&cx).expect("the ß has a box");
assert!((b.pen - lay.glyphs[1].xf * Point::ZERO).hypot() < 1e-9, "{:?}", b.pen);
// A press on its drawn glyph, on the path, starts a drag of it.
let q = glyph_quad(&lay.glyphs[1]).unwrap();
let at = Point::new(q[0].midpoint(q[2]).x, 200.0);
assert!(in_quad(&q, at), "{at:?} {q:?}");
let mut t = TouchTypeTool::default();
let acts = drag(&mut t, &cx, at, at + Vec2::new(0.0, -5.0));
assert_eq!(preview(&acts[1])["start"], 0, "{:?}", acts[1]);
}
/// A glyph skewed by its transform (the Skew effect of type on a path) keeps its own box.
#[test]
fn a_skewed_glyph_keeps_its_own_box() {
@@ -19,7 +19,7 @@ const FIELD_W: f32 = 96.0;
/// Object › Plug-ins › Path Precision Tools › Corner Tool.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
app.select_tool(ID);
app.try_select_tool(ID)?;
crate::panel_docking::reveal_panel(app, super::ID)
}
@@ -17,7 +17,7 @@ const LABEL_W: f32 = 80.0;
/// Object › Plug-ins › Path Precision Tools › Shared Edge Merge Tool.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
app.select_tool(ID);
app.try_select_tool(ID)?;
crate::panel_docking::reveal_panel(app, super::ID)
}
+2
View File
@@ -185,6 +185,8 @@ impl VectorcraftApp {
host_iteration: 0,
#[cfg(test)]
now_override: None,
#[cfg(test)]
fail_next_tool_choice: None,
tick_iteration: None,
input_frames: vec![],
shortcuts_frames: vec![],
+32 -10
View File
@@ -406,34 +406,54 @@ impl VectorcraftApp {
}
}
/// Select a tool (also used by the toolbar and shortcuts).
/// Select a tool (also used by the toolbar and shortcuts). A choice that fails shows its error
/// in the status bar.
pub fn select_tool(&mut self, id: &str) {
if let Err(e) = self.try_select_tool(id) {
self.ui.status = e;
}
}
/// Select a tool for a command ([`Self::select_tool`]): a choice that fails returns its error,
/// and the tool remembered for the toolbar group and for the Touch workspace's drawing mode
/// stays as it was.
pub(crate) fn try_select_tool(&mut self, id: &str) -> Result<(), String> {
// The tip of a pen whose eraser end selected the Eraser doesn't undo this choice, and
// neither does letting go of a held tool shortcut.
self.pen_end.forget_tool();
self.held_tool.clear();
self.activate_tool(id, true);
self.activate_tool(id, true)?;
let now = self.session.tool_id();
if crate::touch::is_drawing_tool(now) {
self.ui.touch_drawing_tool = Some(now.to_string());
}
Ok(())
}
/// Go back to tool `id` after a tool that stood in for it (the pen's eraser end, [`stylus`]):
/// a perspective grid hidden meanwhile stays hidden.
/// a perspective grid hidden meanwhile stays hidden. A switch that fails shows its error in the
/// status bar.
pub(crate) fn return_to_tool(&mut self, id: &str) {
self.activate_tool(id, false);
if let Err(e) = self.activate_tool(id, false) {
self.ui.status = e;
}
}
/// Make tool `id` active: chosen afresh (`fresh`, [`vectorcraft_engine::Session::select_tool`]),
/// or back from a tool that stood in for it ([`vectorcraft_engine::Session::switch_tool`]).
fn activate_tool(&mut self, id: &str, fresh: bool) {
/// The toolbar group remembers `id` only when the switch succeeds.
fn activate_tool(&mut self, id: &str, fresh: bool) -> Result<(), String> {
let v = self.view_info();
let before = self.session.tool_id();
let switched = if fresh { self.session.select_tool(id, v) } else { self.session.switch_tool(id, v) };
if let Err(e) = switched {
self.ui.status = e.to_string();
}
#[cfg(test)]
let failed = self.fail_next_tool_choice.take();
#[cfg(not(test))]
let failed: Option<String> = None;
let switched = match failed {
Some(e) => Err(e),
None if fresh => self.session.select_tool(id, v).map_err(|e| e.to_string()),
None => self.session.switch_tool(id, v).map_err(|e| e.to_string()),
};
if before != self.session.tool_id() {
// A control gesture cannot resume after another tool has owned the pointer.
self.control_rotate_press = None;
@@ -445,13 +465,15 @@ impl VectorcraftApp {
{
panels::artboards::select(self, panels::artboards::selected(self, n));
}
self.ui.flyout = None;
switched?;
if let Some(g) = vectorcraft_tools::catalog::group_of(id)
&& let Some(slot) = self.ui.group_tool.get_mut(g)
{
*slot = id.to_string();
}
toolbar::remember(self, id);
self.ui.flyout = None;
Ok(())
}
/// Show a transient status message.
+58
View File
@@ -1415,6 +1415,64 @@ fn switch_selection_and_drawing_modes_command() {
}
}
/// A Switch Selection and Drawing Modes whose tool choice fails returns the error, in both
/// directions: the tool stays, the drawing tool to return to stays, and no undo step is added.
#[test]
fn a_failed_mode_switch_returns_its_error() {
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": 40, "y": 40, "width": 30, "height": 20})).unwrap();
let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
let steps = undo(&app);
app.select_tool("rectangle");
app.ui.touch_drawing_tool = Some("pen".into());
app.fail_next_tool_choice = Some("no switch".into());
let r = app.run(crate::touch::TOGGLE_MODE, json!({}));
assert!(r.as_ref().is_err_and(|e| e.contains("no switch")), "{r:?}");
assert_eq!((app.session.tool_id(), app.ui.touch_drawing_tool.as_deref(), undo(&app)), ("rectangle", Some("pen"), steps), "drawing mode");
app.select_tool("selection");
app.fail_next_tool_choice = Some("no switch".into());
let r = app.run(crate::touch::TOGGLE_MODE, json!({}));
assert!(r.as_ref().is_err_and(|e| e.contains("no switch")), "{r:?}");
assert_eq!((app.session.tool_id(), app.ui.touch_drawing_tool.as_deref(), undo(&app)), ("selection", Some("pen"), steps), "selection mode");
// The next switch works again.
assert_eq!(app.run(crate::touch::TOGGLE_MODE, json!({})).unwrap(), json!({"tool": "pen"}));
assert_eq!(undo(&app), steps);
}
/// The commands that choose a tool (Select Tool, Crop Image, and the Path Precision Tools' Corner
/// Tool and Shared Edge Merge Tool) return the error when the choice fails, and the tool and the
/// toolbar group's tool stay as they were.
#[test]
fn tool_commands_return_a_failed_choice() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let img = image::RgbaImage::from_pixel(20, 10, image::Rgba([200, 120, 40, 255]));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let data = vectorcraft_format::base64_encode(&png);
app.run("file.place", json!({"name": "texture.png", "dataBase64": data, "link": false, "at": [150, 150]})).unwrap();
app.select_tool("selection");
let group = vectorcraft_tools::catalog::group_of("ellipse").unwrap();
let commands = [
("tool.select", json!({"tool": "ellipse"})),
("ui.cropImage", json!({})),
(crate::addons::pathtools::corner::COMMAND, json!({})),
(crate::addons::pathtools::shared_edge::COMMAND, json!({})),
];
for (command, params) in commands {
let slot = app.ui.group_tool[group].clone();
app.fail_next_tool_choice = Some("no switch".into());
let r = app.run(command, params.clone());
assert!(r.as_ref().is_err_and(|e| e.contains("no switch")), "{command}: {r:?}");
assert_eq!(app.session.tool_id(), "selection", "{command}");
assert_eq!(app.ui.group_tool[group], slot, "{command}");
app.run(command, params).unwrap();
assert_ne!(app.session.tool_id(), "selection", "{command} works again");
app.select_tool("selection");
}
}
/// The Artboard tool's label holds the artboard's name (never translated); the tools' own
/// labels are interface text.
#[test]
+7
View File
@@ -6254,6 +6254,13 @@ axis Y Axis Y ос
Graphite Pencil Графитен молив
Wax Crayon Восъчен пастел
Charcoal Dust Въглен прах
# Original procedural circuit brushes.
Tech Circuit Технологични схеми
Circuit Trace Проводяща писта
Bus Lines Шинни линии
Terminal Nodes Крайни възли
Notched Ribbon Назъбена лента
Double Line Двойна линия
Wave Вълна
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -6307,6 +6307,13 @@ axis Y Axis Y osa
Graphite Pencil Grafitna olovka
Wax Crayon Voštana bojica
Charcoal Dust Ugljena prašina
# Original procedural circuit brushes.
Tech Circuit Tehnički sklopovi
Circuit Trace Vod sklopa
Bus Lines Linije sabirnice
Terminal Nodes Priključni čvorovi
Notched Ribbon Zarezana traka
Double Line Dvostruka linija
Wave Talas
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -3972,6 +3972,13 @@ digits Malayalam Malaiàlam
Graphite Pencil Llapis de grafit
Wax Crayon Cera de colors
Charcoal Dust Pols de carbó
# Original procedural circuit brushes.
Tech Circuit Circuits tecnològics
Circuit Trace Pista de circuit
Bus Lines Línies de bus
Terminal Nodes Nodes terminals
Notched Ribbon Cinta dentada
Double Line Línia doble
Wave Ona
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -6811,6 +6811,13 @@ axis Y Axis osa Y
Graphite Pencil Grafitová tužka
Wax Crayon Vosková pastelka
Charcoal Dust Uhlový prach
# Original procedural circuit brushes.
Tech Circuit Technické obvody
Circuit Trace Vodivá cesta
Bus Lines Sběrnicové linky
Terminal Nodes Koncové uzly
Notched Ribbon Vroubkovaná stuha
Double Line Dvojitá čára
Wave Vlna
Can't render this file because it is too large.
+7
View File
@@ -6880,6 +6880,13 @@ axis Y Axis Y-akse
Graphite Pencil Grafitblyant
Wax Crayon Vokskridt
Charcoal Dust Kulstøv
# Original procedural circuit brushes.
Tech Circuit Tekniske kredsløb
Circuit Trace Kredsløbsbane
Bus Lines Buslinjer
Terminal Nodes Terminalpunkter
Notched Ribbon Takket bånd
Double Line Dobbelt linje
Wave Bølge
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -6403,6 +6403,13 @@ axis Y Axis Y-Achse
Graphite Pencil Graphitstift
Wax Crayon Wachsmalstift
Charcoal Dust Kohlestaub
# Original procedural circuit brushes.
Tech Circuit Technische Schaltungen
Circuit Trace Leiterbahn
Bus Lines Busleitungen
Terminal Nodes Anschlussknoten
Notched Ribbon Gekerbtes Band
Double Line Doppellinie
Wave Welle
Can't render this file because it is too large.
+7
View File
@@ -6765,6 +6765,13 @@ axis Y Axis Άξονας Y
Graphite Pencil Μολύβι γραφίτη
Wax Crayon Κηρομπογιά
Charcoal Dust Σκόνη κάρβουνου
# Original procedural circuit brushes.
Tech Circuit Τεχνολογικά κυκλώματα
Circuit Trace Διαδρομή κυκλώματος
Bus Lines Γραμμές διαύλου
Terminal Nodes Τερματικοί κόμβοι
Notched Ribbon Οδοντωτή κορδέλα
Double Line Διπλή γραμμή
Wave Κύμα
Can't render this file because it is too large.
+7
View File
@@ -6756,6 +6756,13 @@ axis Y Axis Eje Y
Graphite Pencil Lápiz de grafito
Wax Crayon Cera de colores
Charcoal Dust Polvo de carboncillo
# Original procedural circuit brushes.
Tech Circuit Circuitos tecnológicos
Circuit Trace Pista de circuito
Bus Lines Líneas de bus
Terminal Nodes Nodos terminales
Notched Ribbon Cinta dentada
Double Line Línea doble
Wave Onda
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -6843,6 +6843,13 @@ axis Y Axis Y-akseli
Graphite Pencil Grafiittikynä
Wax Crayon Vahaliitu
Charcoal Dust Hiilipöly
# Original procedural circuit brushes.
Tech Circuit Tekniset virtapiirit
Circuit Trace Piirijohdin
Bus Lines Väylälinjat
Terminal Nodes Liitäntäpisteet
Notched Ribbon Lovettu nauha
Double Line Kaksoisviiva
Wave Aalto
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -6806,6 +6806,13 @@ axis Y Axis Axe Y
Graphite Pencil Crayon graphite
Wax Crayon Craie grasse
Charcoal Dust Poussière de fusain
# Original procedural circuit brushes.
Tech Circuit Circuits techniques
Circuit Trace Piste de circuit
Bus Lines Lignes de bus
Terminal Nodes Nœuds terminaux
Notched Ribbon Ruban cranté
Double Line Double ligne
Wave Vague
Can't render this file because it is too large.
+7
View File
@@ -4503,6 +4503,13 @@ antiAlias Sharp શાર્પ
Graphite Pencil ગ્રેફાઇટ પેન્સિલ
Wax Crayon વેક્સ ક્રેયોન
Charcoal Dust ચારકોલનો ભૂકો
# Original procedural circuit brushes.
Tech Circuit ટેક સર્કિટ
Circuit Trace સર્કિટ પથ
Bus Lines બસ રેખાઓ
Terminal Nodes ટર્મિનલ નોડ્સ
Notched Ribbon ખાંચાવાળી રિબન
Double Line બેવડી રેખા
Wave તરંગ
Can't render this file because it contains an unexpected character in line 72 and column 5.
+7
View File
@@ -444,6 +444,13 @@ antiAlias Sharp शार्प
Graphite Pencil ग्रेफाइट पेंसिल
Wax Crayon मोम क्रेयॉन
Charcoal Dust चारकोल धूल
# Original procedural circuit brushes.
Tech Circuit टेक सर्किट
Circuit Trace सर्किट पथ
Bus Lines बस रेखाएँ
Terminal Nodes टर्मिनल नोड
Notched Ribbon नोकदार रिबन
Double Line दोहरी लाइन
Wave लहर
Can't render this file because it is too large.
+7
View File
@@ -6794,6 +6794,13 @@ axis Y Axis Y tengely
Graphite Pencil Grafitceruza
Wax Crayon Zsírkréta
Charcoal Dust Szénpor
# Original procedural circuit brushes.
Tech Circuit Műszaki áramkörök
Circuit Trace Vezetősáv
Bus Lines Buszvonalak
Terminal Nodes Csatlakozási pontok
Notched Ribbon Csipkézett szalag
Double Line Dupla vonal
Wave Hullám
Can't render this file because it is too large.
+7
View File
@@ -6270,6 +6270,13 @@ axis Y Axis Sumbu Y
Graphite Pencil Pensil grafit
Wax Crayon Krayon lilin
Charcoal Dust Debu arang
# Original procedural circuit brushes.
Tech Circuit Sirkuit teknologi
Circuit Trace Jalur sirkuit
Bus Lines Jalur bus
Terminal Nodes Simpul terminal
Notched Ribbon Pita bertakik
Double Line Garis ganda
Wave Gelombang
Can't render this file because it contains an unexpected character in line 2454 and column 5.
+7
View File
@@ -6437,6 +6437,13 @@ axis Y Axis Asse Y
Graphite Pencil Matita di grafite
Wax Crayon Pastello a cera
Charcoal Dust Polvere di carboncino
# Original procedural circuit brushes.
Tech Circuit Circuiti tecnologici
Circuit Trace Pista del circuito
Bus Lines Linee del bus
Terminal Nodes Nodi terminali
Notched Ribbon Nastro dentellato
Double Line Doppia linea
Wave Onda
Can't render this file because it is too large.
+7
View File
@@ -8309,6 +8309,13 @@ axis Y Axis Y 軸
Graphite Pencil 黒鉛鉛筆
Wax Crayon ワックスクレヨン
Charcoal Dust 木炭の粉
# Original procedural circuit brushes.
Tech Circuit 電子回路
Circuit Trace 回路配線
Bus Lines バス配線
Terminal Nodes 端子ノード
Notched Ribbon ノッチリボン
Double Line 二重線
Wave 波
Can't render this file because it is too large.
+7
View File
@@ -6764,6 +6764,13 @@ axis Y Axis Y ღერძი
Graphite Pencil გრაფიტის ფანქარი
Wax Crayon ცვილის ფანქარი
Charcoal Dust ნახშირის მტვერი
# Original procedural circuit brushes.
Tech Circuit ტექნიკური სქემები
Circuit Trace სქემის გამტარი
Bus Lines სალტის ხაზები
Terminal Nodes ტერმინალური კვანძები
Notched Ribbon დაკბილული ლენტი
Double Line ორმაგი ხაზი
Wave ტალღა
Can't render this file because it is too large.
+7
View File
@@ -6398,6 +6398,13 @@ axis Y Axis Y축
Graphite Pencil 흑연 연필
Wax Crayon 왁스 크레용
Charcoal Dust 목탄 가루
# Original procedural circuit brushes.
Tech Circuit 전자 회로
Circuit Trace 회로 배선
Bus Lines 버스 배선
Terminal Nodes 단자 노드
Notched Ribbon 노치 리본
Double Line 이중선
Wave 물결
Can't render this file because it is too large.
+7
View File
@@ -8715,6 +8715,13 @@ axis Y Axis Y-as
Graphite Pencil Grafietpotlood
Wax Crayon Waskrijt
Charcoal Dust Houtskoolstof
# Original procedural circuit brushes.
Tech Circuit Technische schakelingen
Circuit Trace Printspoor
Bus Lines Buslijnen
Terminal Nodes Aansluitpunten
Notched Ribbon Gekerfd lint
Double Line Dubbele lijn
Wave Golf
Can't render this file because it is too large.
+7
View File
@@ -6666,6 +6666,13 @@ axis Y Axis Oś Y
Graphite Pencil Ołówek grafitowy
Wax Crayon Kredka woskowa
Charcoal Dust Pył węglowy
# Original procedural circuit brushes.
Tech Circuit Obwody techniczne
Circuit Trace Ścieżka obwodu
Bus Lines Linie magistrali
Terminal Nodes Węzły końcowe
Notched Ribbon Wstęga z nacięciami
Double Line Podwójna linia
Wave Fala
Can't render this file because it is too large.
+7
View File
@@ -6726,6 +6726,13 @@ axis Y Axis Eixo Y
Graphite Pencil Lápis de Grafite
Wax Crayon Giz de Cera
Charcoal Dust Pó de Carvão
# Original procedural circuit brushes.
Tech Circuit Circuitos tecnológicos
Circuit Trace Trilha de circuito
Bus Lines Linhas de barramento
Terminal Nodes Nós terminais
Notched Ribbon Fita Entalhada
Double Line Linha Dupla
Wave Onda
Can't render this file because it is too large.
+7
View File
@@ -6424,6 +6424,13 @@ axis Y Axis Ось Y
Graphite Pencil Графитный карандаш
Wax Crayon Восковой мелок
Charcoal Dust Угольная пыль
# Original procedural circuit brushes.
Tech Circuit Технические схемы
Circuit Trace Дорожка схемы
Bus Lines Линии шины
Terminal Nodes Контактные узлы
Notched Ribbon Лента с насечками
Double Line Двойная линия
Wave Волна
Can't render this file because it is too large.
+7
View File
@@ -6791,6 +6791,13 @@ axis Y Axis Y-axel
Graphite Pencil Grafitpenna
Wax Crayon Vaxkrita
Charcoal Dust Koldamm
# Original procedural circuit brushes.
Tech Circuit Tekniska kretsar
Circuit Trace Kretsbana
Bus Lines Bussledningar
Terminal Nodes Anslutningspunkter
Notched Ribbon Hackat band
Double Line Dubbellinje
Wave Våg
Can't render this file because it is too large.
+1 -1
View File
@@ -437,7 +437,7 @@ fn built_in_library_brush_names_are_in_complete_catalogs() {
.collect();
names.sort();
names.dedup();
assert_eq!(names.len(), 43, "{names:?}");
assert_eq!(names.len(), 46, "{names:?}");
let mut bad = vec![];
for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) {
let missing: Vec<&String> = names.iter().filter(|n| lang.catalog().plain(n).is_none()).collect();
+7
View File
@@ -8689,6 +8689,13 @@ axis Y Axis Y ekseni
Graphite Pencil Grafit Kalem
Wax Crayon Mum Boya
Charcoal Dust Kömür Tozu
# Original procedural circuit brushes.
Tech Circuit Teknik devreler
Circuit Trace Devre izi
Bus Lines Veriyolu hatları
Terminal Nodes Bağlantı düğümleri
Notched Ribbon Çentikli Şerit
Double Line Çift Çizgi
Wave Dalga
Can't render this file because it is too large.
+7
View File
@@ -6417,6 +6417,13 @@ axis Y Axis Вісь Y
Graphite Pencil Графітний олівець
Wax Crayon Восковий олівець
Charcoal Dust Вугільний пил
# Original procedural circuit brushes.
Tech Circuit Технічні схеми
Circuit Trace Доріжка схеми
Bus Lines Лінії шини
Terminal Nodes Контактні вузли
Notched Ribbon Стрічка з насічками
Double Line Подвійна лінія
Wave Хвиля
Can't render this file because it is too large.
+7
View File
@@ -6420,6 +6420,13 @@ axis Y Axis Trục Y
Graphite Pencil Bút chì than chì
Wax Crayon Bút sáp màu
Charcoal Dust Bụi than
# Original procedural circuit brushes.
Tech Circuit Mạch công nghệ
Circuit Trace Đường mạch
Bus Lines Đường bus
Terminal Nodes Nút đầu cuối
Notched Ribbon Dải băng khía
Double Line Đường đôi
Wave Sóng
Can't render this file because it contains an unexpected character in line 204 and column 5.
+7
View File
@@ -6650,6 +6650,13 @@ axis Y Axis Y 轴
Graphite Pencil 石墨铅笔
Wax Crayon 蜡笔
Charcoal Dust 炭粉
# Original procedural circuit brushes.
Tech Circuit 电子电路
Circuit Trace 电路走线
Bus Lines 总线
Terminal Nodes 端子节点
Notched Ribbon 凹口丝带
Double Line 双线
Wave 波浪
Can't render this file because it is too large.
+7
View File
@@ -6457,6 +6457,13 @@ axis Y Axis Y 軸
Graphite Pencil 石墨鉛筆
Wax Crayon 蠟筆
Charcoal Dust 炭粉
# Original procedural circuit brushes.
Tech Circuit 電子電路
Circuit Trace 電路走線
Bus Lines 匯流排
Terminal Nodes 端子節點
Notched Ribbon 凹口緞帶
Double Line 雙線
Wave 波浪
Can't render this file because it is too large.
+4
View File
@@ -353,6 +353,10 @@ pub struct VectorcraftApp {
/// can be driven one simulated frame at a time (production always leaves this `None`).
#[cfg(test)]
pub(crate) now_override: Option<f64>,
/// An error the next tool choice returns before the engine sees it ([`Self::try_select_tool`]),
/// so tests can drive a failed switch (production has no such field).
#[cfg(test)]
pub(crate) fail_next_tool_choice: Option<String>,
/// The host iteration [`Self::tick`] ran in (`None`: never): once per iteration, never per pass.
tick_iteration: Option<u64>,
/// The context, viewport and frame whose UI-phase input was already taken in this host
+3 -9
View File
@@ -679,14 +679,11 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
"window.nextDocument" => crate::chrome::step_document(app, true),
"window.previousDocument" => crate::chrome::step_document(app, false),
"tool.select" => match s("tool") {
Some(t) if vectorcraft_tools::tool_info(&t).is_some() => {
app.select_tool(&t);
Ok(json!({"tool": app.session.tool_id()}))
}
Some(t) if vectorcraft_tools::tool_info(&t).is_some() => app.try_select_tool(&t).map(|()| json!({"tool": app.session.tool_id()})),
Some(t) => Err(format!("unknown tool `{t}`")),
None => Err("missing `tool`".into()),
},
crate::touch::TOGGLE_MODE => Ok(crate::touch::toggle_mode(app)),
crate::touch::TOGGLE_MODE => crate::touch::toggle_mode(app),
crate::addons::pathtools::corner::COMMAND => crate::addons::pathtools::corner::open(app),
crate::addons::pathtools::shared_edge::COMMAND => crate::addons::pathtools::shared_edge::open(app),
crate::addons::arrange::ZOOM => crate::addons::arrange::zoom(app),
@@ -698,10 +695,7 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
"effect.dialog" => crate::dialogs::open_effect_dialog(app, p),
"ui.recolorDialog" => crate::dialogs::recolor::open(app, p),
"ui.cropImage" if !selected_image(app, |_| true) => Err("select an image".into()),
"ui.cropImage" => {
app.select_tool(vectorcraft_tools::cropimage::ID);
Ok(json!({"tool": app.session.tool_id()}))
}
"ui.cropImage" => app.try_select_tool(vectorcraft_tools::cropimage::ID).map(|()| json!({"tool": app.session.tool_id()})),
"ui.paramDialog" => {
let cmd = s("command").unwrap_or_default();
let mut fields = p.get("params").and_then(Value::as_object).cloned().unwrap_or_default();
+3 -3
View File
@@ -801,7 +801,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"Cmd+Shift+F6",
"{} make the open document before the current one in tab order the current one, as a click on its tab does (before the first document it goes to the last, and from the Home screen to the last); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view",
),
("tool.select", "Select Tool", "", "{tool: id} (see tools)"),
("tool.select", "Select Tool", "", "{tool: id} (see tools) → {tool}; a tool that cannot be chosen returns the error"),
(
"tool.setOption",
"Tool Option",
@@ -812,7 +812,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
crate::touch::TOGGLE_MODE,
"Switch Selection and Drawing Modes",
"",
"{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)",
"{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}; a tool that cannot be chosen returns the error and leaves the drawing tool as it was. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)",
),
(
"effect.dialog",
@@ -831,7 +831,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.cropImage",
"Crop Image",
"",
"{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box",
"{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box. → {tool}; a tool that cannot be chosen returns the error",
),
("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"),
(
@@ -200,7 +200,8 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() {
json!("scatter-shapes"),
json!("game-trails"),
json!("energy-beams"),
json!("dry-media")
json!("dry-media"),
json!("tech-circuit")
]
);
let dry = entries.iter().find(|e| under(e) && e.params["library"] == "dry-media").map(|e| e.label.as_str());
@@ -225,6 +226,12 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() {
click(&mut app, &ctx, "Chalk Grain", Modifiers::NONE, 1.0);
assert!(has(&app, "Chalk Grain") && !has(&app, "Wax Crayon"));
assert_eq!(undo_len(&app), undo + 1);
let r = app.run("window.brushLibrary", json!({"library": "Tech Circuit"})).unwrap();
assert_eq!((r["open"].as_str(), r["count"].as_u64()), (Some("tech-circuit"), Some(3)));
let undo = undo_len(&app);
click(&mut app, &ctx, "Circuit Trace", Modifiers::NONE, 2.0);
assert!(has(&app, "Circuit Trace") && !has(&app, "Bus Lines"));
assert_eq!(undo_len(&app), undo + 1);
// Without brushes there is nothing to save.
let all: Vec<String> = vectorcraft_brush::library(&app.session.doc().unwrap().doc).into_iter().map(|b| b.name).collect();
app.run("brush.delete", json!({ "names": all })).unwrap();
+11 -10
View File
@@ -153,19 +153,20 @@ pub(crate) fn is_drawing_tool(id: &str) -> bool {
/// [`TOGGLE_MODE`]: from a tool of selection mode, choose the drawing tool chosen last (none yet:
/// [`FIRST_DRAWING_TOOL`]); from any other tool, keep it as the drawing tool and choose the
/// Selection tool. → `{tool}`, the tool now active.
pub(crate) fn toggle_mode(app: &mut crate::VectorcraftApp) -> serde_json::Value {
/// Selection tool. → `{tool}`, the tool now active. A switch that fails returns its error and
/// leaves the drawing tool to return to as it was.
pub(crate) fn toggle_mode(app: &mut crate::VectorcraftApp) -> Result<serde_json::Value, String> {
let now = app.session.tool_id().to_string();
let next = if selects(&now) {
app.ui.touch_drawing_tool.clone().unwrap_or_else(|| FIRST_DRAWING_TOOL.to_string())
let (next, outgoing) = if selects(&now) {
(app.ui.touch_drawing_tool.clone().unwrap_or_else(|| FIRST_DRAWING_TOOL.to_string()), None)
} else {
if is_drawing_tool(&now) {
app.ui.touch_drawing_tool = Some(now);
}
"selection".to_string()
("selection".to_string(), is_drawing_tool(&now).then_some(now))
};
app.select_tool(&next);
serde_json::json!({"tool": app.session.tool_id()})
app.try_select_tool(&next)?;
if let Some(tool) = outgoing {
app.ui.touch_drawing_tool = Some(tool);
}
Ok(serde_json::json!({"tool": app.session.tool_id()}))
}
#[cfg(test)]
+7 -5
View File
File diff suppressed because one or more lines are too long
+52 -20
View File
@@ -1,6 +1,8 @@
# VectorCraft MCP server
> **Last updated:** 2026-11-14 · **Change:** Pencil ignores Smart Guides and Snap to Point and shows no Smart Guides, #7252; no response-shape change. Previously: Pencil continuation, straight-line and closing cursors; scores and estimates unchanged; no response-shape change. Previously: Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; no response-shape change. Previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097); existing response shapes retained.
>
> Tech Circuit update (2026-11-14): three original procedural brushes; ten built-in brush libraries (G5).
`vectorcraft-cli mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio
(newline-delimited JSON-RPC 2.0; see [Protocol](#protocol) for version negotiation). Agents use it to
@@ -826,15 +828,30 @@ the `effect.*` commands and `appearance.addEffect`, and the paint, stroke and gr
`item`, which is a row of the group's own stack. The Appearance panel and `appearance.setActiveItem` read the selected
group's own rows.
The graph draws the art of a column or marker design in a series again from the design, so the commands that add,
change, move or remove fill and stroke rows, effects or graphic styles leave that art out: the `appearance.*` commands
listed above, `effect.apply`, `appearance.addEffect` and the other `effect.*` commands that edit, move or remove an
effect, `graphicStyle.apply` (and `graphicStyle.addFromLibrary` with `apply`), `transparency.set` with a fill or stroke
`item`, and `appearance.copyFrom` as the target. The art is any object inside a series group other than a path directly
in it, whether given by id, selected, or reached through `target: "contents"`. The commands edit the other objects they
are given, and a command whose objects are all design art is an error. `appearance.transfer` with design art as its
`target`, or as its `source` without `copy`, is an error. The paint commands leave the art out as well, without an
error. To change the art, edit the design with `graph.design`.
When a graph is drawn again (a change to its data, its type, or its column or marker designs), it draws the art of a
column or marker design in a series again from the design, and an edit made to that art is lost. The art is any object
inside a series group other than a path directly in it, a legend swatch's included, whether given by id, selected, or
reached through `target: "contents"`. These commands leave that art out, edit the other objects they are given, and
are an error when all their objects are design art: the `appearance.*` commands listed above, `effect.apply`,
`appearance.addEffect` and the other `effect.*` commands that edit, move or remove an effect,
`effect.expandAppearance` (which also leaves the art inside a selected graph or series group as it was, unless an
effect of that group turns the whole group into other art, and is disabled when only design art is selected or
given), `threeD.mapGraphic`,
`graphicStyle.apply`, `graphicStyle.addFromLibrary` with `apply`, `graphicStyle.breakLink`, `transparency.set`, the
`paint.freeform.*` commands that edit points and lines, and `appearance.copyFrom` as the target.
`appearance.transfer` with design art as its `target`, or as its `source` without `copy`, is an error.
`paint.setFill`, `paint.setStroke`, `paint.swap`, `paint.default`, `paint.invert` and `paint.complement` with an
`item`, `stroke.set`, `stroke.weightUp`, `stroke.weightDown`, `stroke.setAdvanced`, `paint.editGradient`,
`paint.setGradientGeom` and `object.setOverprint` leave the art out without an error. When the first selected object is design art, the commands
do not use the Appearance panel's active row. When the first object given is design art, rows and effects given with
`aligned: true` are rows of its stack in `appearance.setItem`, `appearance.removeItem`, `appearance.duplicateItem`,
`transparency.set` with an `item`, `effect.apply`, `appearance.addEffect` and the other `effect.*` commands that edit,
move or remove an effect: each other object edits the row or effect equal to it, and an object without one is left as
it is. When no object has one, `appearance.setItem` and `appearance.removeItem` leave the document as it is, as they do
for a row no object has, and the other commands are an error. `appearance.moveItem`, `effect.move` and
`appearance.duplicateItem` with `to` are an error when the art's stack differs from the other objects', and
`appearance.duplicateItem` with `to` is also an error when two or more other objects are given, as it is without the
art. To change the art, edit the design with `graph.design`.
The graph also stores a paint or stroke weight that one of these commands changes on the topmost visible fill or stroke
of a path directly in a series: a weight set with `stroke.set`, `stroke.weightUp` or `stroke.weightDown`; a gradient
@@ -4586,9 +4603,9 @@ error.
## Brush libraries
Brush libraries are read-only sets of brushes: nine generated in code (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle
Brush libraries are read-only sets of brushes: ten generated in code (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle
Brushes with one bristle brush for each tip shape, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, and Dry Media with the Chalk Grain, Graphite Pencil, Wax Crayon and
Charcoal Dust art brushes, id `dry-media`), the `.vectorcraft` files in the user library folder of the desktop app (User
Charcoal Dust art brushes, id `dry-media`, and Tech Circuit with Circuit Trace (art), Bus Lines and Terminal Nodes (pattern), id `tech-circuit`), the `.vectorcraft` files in the user library folder of the desktop app (User
Defined) and the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has
the default ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and
`brush.library {library}` returns one's brushes (`name`, `type`; `library` is an id or a name).
@@ -6339,7 +6356,10 @@ desktop app) instead and undoes nothing ([the help page](https://helpx.adobe.com
From the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool it chooses the
drawing tool chosen last in the window, or the Pencil tool when none has been chosen yet; from any other tool it
chooses the Selection tool, and the tool left becomes the drawing tool unless it is the Hand, Rotate View, Zoom or Print
Tiling tool. It returns `{tool}` and adds no undo step. Pencil as the first drawing tool and the list of selection tools
Tiling tool. It returns `{tool}` and adds no undo step. When the tool cannot be chosen it returns the error, the tool
and the drawing tool stay as they were, and it adds no undo step; `tool.select`, `ui.cropImage`,
`addon.pathtools.cornerTool` and `addon.pathtools.sharedEdgeTool` also return the error when their tool cannot be
chosen. Pencil as the first drawing tool and the list of selection tools
are VectorCraft's own rules. A tap in Presentation Mode runs nothing. Under VectorCraft's own rule, a tap on the canvas
while a modal dialog is open runs nothing, and fingers already down when it opens make no tap when they lift, during
the dialog or after it closes.
@@ -9791,7 +9811,9 @@ neighbors, the kerning of the two characters keeps it and the characters after i
wider or narrower character moves the characters after it by the change in its width. Touch Type gives the character
before it a kerning value of its own (with `dx`, and with any other change where the font kerned the character with
its neighbors), and that character can then move by the font's kerning pair with the one before
it, as with any style change to one character (`text.setRangeStyle`). The type stays editable.
it, as with any style change to one character (`text.setRangeStyle`). The type stays editable. The characters of type
on an open path that are not drawn because they fall before the path's start cannot be selected with the tool, and
`text.touchType` returns an error for them.
```json
{"name":"run_command","arguments":{"command":"text.touchType","params":{"id":7,"start":1,"dx":10,"dy":4}}}
@@ -9881,9 +9903,10 @@ same in any language. Saved documents keep what they say, and imported text (PDF
Type on a path flows between a start and an end bracket, stored as fractions of its path's length.
`type.pathOptions {start?, end?, flip?, effect?, alignToPath?, spacing?, ids?}` sets them and the rest of Type on a
Path Options: `end: null` puts the end bracket back at the end of the path (or once round a closed one); `flip`
then turns the type to the other side of its path, the path running the other way and the brackets swapping ends so
the type keeps its stretch of the path; `alignToPath` runs the `ascender`, `descender`, `center` or `baseline` (the
Path Options: `end: null` puts the end bracket back at the end of the path (or once round a closed one); on an open
path a `start` below 0 starts the text that far before the path's start (on a closed path it is the path's start);
`flip` then turns the type to the other side of its path, the path running the other way and the brackets swapping
ends so the type keeps its stretch of the path; `alignToPath` runs the `ascender`, `descender`, `center` or `baseline` (the
default) along the path, each character by its own font, size and vertical scale: `ascender` runs the top of the
font's em box along it (its ideographic em box, else an em box centered on the cap height), `descender` runs the
bottom of the font's bounding box along it, and `center` runs a line at half the height of the top of the font's H
@@ -9896,6 +9919,13 @@ show selected type on a path's brackets: dragging the start or end bracket (`poi
begins or ends, dragging the centre bracket slides the type along its path and, dragged across the path, flips it
(with Cmd/Ctrl held it only slides). Each is one undo step.
Type on an open path that starts before the path's start keeps every character, and the characters whose middle falls
before the path's start are not drawn. A `flip` starts it at the start bracket, and dragging the center bracket slides
it from where it starts and no further before the path. Past the path's end, text that does not fit is overflow, as
for other type on a path. SVG import reads a `<textPath>` anchored or offset before an open path's start this way, and
SVG export writes it as a negative `startOffset`. Releasing it to point type keeps the first drawn character where it
was drawn.
```json
{"name":"run_command","arguments":{"command":"type.pathOptions","params":{"start":0.25,"end":0.75,"flip":true}}}
```
@@ -12260,10 +12290,11 @@ VectorCraft addition and changes no parity score.
the path's fill and stroke), so the released path has no fill and no stroke.
- **The text** keeps its runs with their character attributes and its paragraph attributes, and becomes point type on
one straight line along the object's own axis, with the baseline starting where the first character sat on the path
(type with no characters starts at its start bracket). Type on a Path Options (effect, align to path, spacing, flip,
brackets) don't apply to point type and are dropped. Type with Dynamic Text on (`type.dynamicText`) becomes point
type with it off, at the sizes it was drawn at: each size, leading and baseline shift is multiplied by the fit factor.
Without `keepOriginal` the type object itself becomes the point
(type with no characters starts at its start bracket; type that starts before an open path's start keeps its first
drawn character where it was drawn, with the characters that were not drawn before it). Type on a Path Options
(effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped. Type with Dynamic Text
on (`type.dynamicText`) becomes point type with it off, at the sizes it was drawn at: each size, leading and
baseline shift is multiplied by the fit factor. Without `keepOriginal` the type object itself becomes the point
type, keeping its id, so a frame of threaded text leaves its thread with the text it holds, as
`type.convertToPointType` does for area type.
- It acts on the type on a path among `ids` (or `id`, else the selection), groups included; other objects are left
@@ -12305,6 +12336,7 @@ the error text. The interactive fallback applies only to ordinary File > Save, n
| Date | Change |
|---|---|
| 2026-11-14 | Built-in Tech Circuit brush library (id `tech-circuit`): Circuit Trace, Bus Lines and Terminal Nodes; ten built-in libraries. |
| 2026-11-13 | The Pencil ignores Smart Guides and Snap to Point and shows no Smart Guides; Snap to Grid and Snap to Pixel still move its ends (#7252). |
| 2026-11-13 | `path.freehand`: `closed`, when given, decides whether a stroke that continues a path (`extend`) closes it; the Pencil and the Paintbrush send it by Close Paths When Ends Are Within. |
| 2026-11-13 | Snap to Point with Smart Guides off draws no Smart Guides labels or lines (#7250). |
+3 -2
View File
@@ -1,6 +1,6 @@
# Roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Tech Circuit brush library, G5; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
>
> Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865).
>
@@ -165,7 +165,7 @@ Gujarati interface follow-up (2026-11-14): the catalog and zero/one plural rule
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs (feature-valued alternates and unencoded glyphs applied in one Undo step while keeping Arabic joining, #5514), OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, six generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons, Weather Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, nine generated libraries including Ink and Wash, Bristle Brushes and Dry Media, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, six generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons, Weather Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, ten generated libraries including Tech Circuit, Ink and Wash, Bristle Brushes and Dry Media, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369); Live Paint Bucket fills faces crossed in one drag (#6373) | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 |
@@ -207,6 +207,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 3–13 h (estimated); parity scores and headline totals unchanged. |
| 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. |
| 2026-11-12 | minor | Gujarati (`gu`): 4,286-row machine-assisted catalog reviewed by a native speaker, zero/one singular plurals, desktop font fallback and optional Gujarati craft-fonts fallback; all 72 translation tests pass; a web font and about 2,600 untranslated messages remain; scores and estimates unchanged |
| 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. |
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -2,6 +2,8 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Pencil continuation, straight-line and closing cursors; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
>
> Tech Circuit update (2026-11-14): three original procedural brushes; ten built-in brush libraries (G5).
>
> Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369).
>
> Numeric spinner update (2026-11-10): held mouse arrows repeat past the click timeout; scores and estimates unchanged (#6307).
@@ -51,6 +53,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-14 | minor | G5: Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 3–13 h (estimated); parity scores and headline totals unchanged. |
| 2026-11-14 | minor | Layers panel: right-clicking a layer/group/object row shows a context menu with Options…, Duplicate, Delete, Lock/Hide Others, Locate Object, and Collect for Export — matching standard desktop layer panel conventions. Scores unchanged. |
| 2026-11-13 | minor | Pencil cursors: the continuation cursor (a short rising line) shows over an open end of a selected path before any press, freehand too, and while a continuing stroke nears another selected path's open end it connects to; the straight-line cursor is a short level line; a continued path, a straight line too, shows the closing cursor and closes within Close Paths When Ends Are Within of its other end, and with Alt at the release. Scores and estimates unchanged. |
| 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. |
@@ -429,7 +432,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
| Attributes fill-rule controls with a compound member selected | done | Edits the compound's rule; direction still edits only the member; rendered-hole and Undo/Redo regression tests (#1197) |
| Make Compound Path on a group (such as outlined type) keeps its backmost path's paint; compound paths joined into one keep their holes | done | #1537 |
| Symbol libraries: Window › Symbol Libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons, User Defined, Other Library…, Save Symbols…) and the Symbols panel's Symbol Libraries Menu button and its menu's Open Symbol Library / Save Symbol Library…; a click on a library symbol adds it to the document's Symbols (one undo step, it becomes the current symbol), Shift/Cmd-clicks select for Add to Symbols, and a symbol dragged onto the canvas is added and placed where it is dropped | done | [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html); `window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary` (dialog `saveSymbolLibrary`), `window.userSymbolLibrary1`…`10`; `panels::tests_symbol_libraries`; five built-in libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons; original, generated in code) |
| Brush libraries: Window › Brush Libraries (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media, User Defined, Other Library…, Save Brushes…) and the Brushes panel's Brush Libraries Menu button and its menu's Open Brush Library / Save Brush Library…; a click on a library brush adds it to the document's Brushes (one undo step, it becomes the current brush, and selected paths take it in the same step), Shift/Cmd-clicks select for Add to Brushes, a brush dragged onto a path is added and applied to it and one dragged onto the Brushes panel is added; thumbnails render with the library's patterns and symbols | done | [public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel overview](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html); `window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary` (dialog `saveBrushLibrary`), `window.userBrushLibrary1`…`10`; `panels::tests_brush_libraries`; nine built-in libraries (original, generated in code) |
| Brush libraries: Window › Brush Libraries (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media, Tech Circuit, User Defined, Other Library…, Save Brushes…) and the Brushes panel's Brush Libraries Menu button and its menu's Open Brush Library / Save Brush Library…; a click on a library brush adds it to the document's Brushes (one undo step, it becomes the current brush, and selected paths take it in the same step), Shift/Cmd-clicks select for Add to Brushes, a brush dragged onto a path is added and applied to it and one dragged onto the Brushes panel is added; thumbnails render with the library's patterns and symbols | done | [public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel overview](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html); `window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary` (dialog `saveBrushLibrary`), `window.userBrushLibrary1`…`10`; `panels::tests_brush_libraries`; ten built-in libraries (original, generated in code) |
| Symbols panel: New Symbol opens Symbol Options (Name) before making the symbol; Alt-click skips it | partial | #1560 (#1566); Registration 9-point grid in Symbol Options (`symbol.new` / `symbol.place`); Export Type (Graphic / Movie Clip) and Enable Guides for 9-Slice Scaling (scaled instances keep their corners; in symbol editing mode the guides are drawn over the art and the Selection tool drags them); Symbol Type (Dynamic Symbol / Static Symbol, new symbols dynamic; #4938) |
| Dynamic symbols: an instance changes the fill, stroke, opacity and blend of objects in its symbol's art and stays linked; Reset in the Properties panel's Quick Actions and the Symbols panel menu (Reset Symbol Instance) | partial | #4938; `symbol.instance.setAppearance` (by child-index path) and `symbol.instance.reset`; redefining keeps the changes, Break Link bakes them; Direct Selection of a path, compound path or type object inside an instance, with the Fill/Stroke controls setting its solid fill or stroke, is on canvas; on-canvas editing beyond a solid fill and stroke, and `.ai` read and write, remain |
| Pathfinder Trim, Merge and Crop remove strokes; Merge joins same-fill objects whatever their strokes | done | #1570 (#1572) |
@@ -540,6 +543,7 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra
| Date | Change | Summary |
|---|---|---|
| 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 3–13 h (estimated); parity scores and headline totals unchanged. |
| 2026-11-12 | minor | Numeric fields: a focus click released more than 1 pt from its press keeps the caret; a tap within 1 pt still selects the whole value (#6659, follow-up to #1326). Scores and estimates unchanged. |
| 2026-11-12 | minor | Remove Background for a selected image in the Contextual Task Bar, Properties › Quick Actions and the Control bar, which runs at once with its defaults. Scores and estimates unchanged. |
| 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. |