Merge pull request #5049 from dotCooCoo/feat/pattern-library-geometric

Swatch libraries: Patterns › Geometric Patterns adds a pattern with its swatch
This commit is contained in:
BFlat
2026-11-02 12:27:08 +07:00
committed by GitHub
38 changed files with 413 additions and 24 deletions
+1
View File
@@ -129,6 +129,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 2026-10-31: Pattern libraries: Window › Swatch Libraries › Patterns and the Swatches panel's library menu open Geometric Patterns, a built-in library of eleven pattern swatches (dots, stripes, checks, a grid and shapes); clicking one adds its pattern and swatch to the document, once, and applies it to the active fill or stroke (`swatch.library.add`).
- 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923).
- 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922).
- 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925).
+1
View File
@@ -62,6 +62,7 @@ pub mod params;
mod path;
pub(crate) mod pathops;
mod patterncmds;
pub mod patternlib;
pub mod pdfcmds;
pub mod perspgrid;
pub mod place;
+152
View File
@@ -0,0 +1,152 @@
//! Pattern libraries (Window → Swatch Libraries → Patterns, the Swatches panel's library button):
//! read-only sets of pattern swatches the swatch library panel shows. Each built-in library is a
//! document holding its patterns, drawn here from plain shapes ([`PATTERN_LIBRARIES`]).
//! `swatch.library.add` copies a pattern into the document with its swatch.
use std::sync::{Arc, LazyLock};
use vectorcraft_color::{Color, Paint, Swatch, SwatchLibrary};
use vectorcraft_doc::pattern::pattern_paint;
use vectorcraft_doc::{Appearance, Document, Node, NodeId, PatternDef, TileType};
use vectorcraft_geom::{PathData, Point, Rect, SubPath, shapes};
use super::clipboard::Clipboard;
use super::clipboard::resources::SwatchChoices;
use super::swatchlib::LibraryInfo;
/// A built-in pattern library: a stable id, its menu name and the function that adds its patterns.
pub struct BuiltinPatternLibrary {
pub id: &'static str,
pub name: &'static str,
make: fn(&mut Document),
}
/// The built-in pattern libraries (Window → Swatch Libraries → Patterns), in menu order.
pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] =
&[BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns }];
/// The library category of the built-in pattern libraries.
pub const CATEGORY: &str = "patterns";
/// The built-in pattern libraries as the menus list them.
pub fn infos() -> impl Iterator<Item = LibraryInfo> {
PATTERN_LIBRARIES.iter().map(|b| LibraryInfo { id: b.id.into(), name: b.name.into(), category: CATEGORY })
}
/// Built-in pattern library `id`: the document holding its patterns, made once.
pub fn library_doc(id: &str) -> Option<Arc<Document>> {
static MADE: LazyLock<Vec<Arc<Document>>> = LazyLock::new(|| {
PATTERN_LIBRARIES
.iter()
.map(|b| {
let mut d = Document::new(100.0, 100.0);
d.title = b.name.into();
(b.make)(&mut d);
Arc::new(d)
})
.collect()
});
PATTERN_LIBRARIES.iter().position(|b| b.id == id).and_then(|i| MADE.get(i).cloned())
}
/// Built-in pattern library `id` as the library panel shows it: a swatch for each pattern.
pub fn library(id: &str) -> Option<(LibraryInfo, Arc<SwatchLibrary>)> {
static MADE: LazyLock<Vec<Arc<SwatchLibrary>>> = LazyLock::new(|| {
PATTERN_LIBRARIES
.iter()
.map(|b| {
let swatches = library_doc(b.id)
.map(|d| {
d.patterns
.iter()
.map(|p| Swatch { name: p.name.clone(), paint: pattern_paint(&p.name), global: false, spot: false })
.collect()
})
.unwrap_or_default();
Arc::new(SwatchLibrary { name: b.name.into(), swatches, groups: vec![] })
})
.collect()
});
let i = PATTERN_LIBRARIES.iter().position(|b| b.id == id)?;
Some((infos().nth(i)?, MADE.get(i)?.clone()))
}
/// The built-in library document that holds the pattern of swatch `w`, when `w` is one of a
/// built-in pattern library's own swatches (a library panel draws them from it). A swatch of
/// another library is not, even under the same name.
pub fn swatch_source(w: &Swatch) -> Option<Arc<Document>> {
let Paint::Pattern { pattern, .. } = &w.paint else { return None };
let own = |lib: &SwatchLibrary| lib.swatches.iter().any(|s| std::ptr::eq(s, w));
let b = PATTERN_LIBRARIES.iter().find(|b| library(b.id).is_some_and(|(_, lib)| own(&lib)))?;
library_doc(b.id).filter(|d| d.pattern(pattern).is_some())
}
/// Add pattern `name` of library `lib` to `d` with the resources its art uses, as pasting art
/// painted with it does: the same pattern there already is used, and a name another pattern or
/// swatch has gets a number → the pattern's name in `d`.
pub(crate) fn import(d: &mut Document, lib: &Document, name: &str) -> String {
let user = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(pattern_paint(name), Paint::None, 0.0));
let imported = Clipboard::gather(lib, vec![user]).import_into(d, &SwatchChoices::default(), false);
let renamed = imported.renamed.iter().find(|r| r["kind"] == "pattern" && r["from"] == name).and_then(|r| r["to"].as_str());
renamed.unwrap_or(name).to_string()
}
/// The pattern of `d` that is pattern `name` of library `lib`: the same tile options and art under
/// that name or a numbered copy of it ("Polka Dots 2"), the name itself first.
pub(crate) fn existing_pattern(d: &Document, lib: &Document, name: &str) -> Option<String> {
let def = lib.pattern(name)?;
let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() };
let copy = |n: &str| n == name || n.strip_prefix(name).and_then(|r| r.strip_prefix(' ')).is_some_and(|r| r.parse::<u32>().is_ok());
let mut found: Vec<&PatternDef> = d.patterns.iter().filter(|p| copy(&p.name) && bare(p) == bare(def) && p.art == def.art).collect();
found.sort_by_key(|p| p.name != name);
found.first().map(|p| p.name.clone())
}
/// The name of the first swatch of `d` that paints with pattern `pattern` as placed in its own
/// space, as a pattern's own swatch does.
pub(crate) fn pattern_swatch(d: &Document, pattern: &str) -> Option<String> {
let paint = pattern_paint(pattern);
d.swatches_iter().find(|w| w.paint == paint).map(|w| w.name.clone())
}
/// A pattern of `shapes` filled with black on the tile `w` × `h` from the origin (`tile_type`:
/// how the tiles are laid out).
fn add(d: &mut Document, name: &str, w: f64, h: f64, tile_type: TileType, shapes: Vec<PathData>) {
let art =
shapes.into_iter().map(|p| Arc::new(Node::path(d.alloc_id(), p, Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)))).collect();
let def = PatternDef { tile: Rect::new(0.0, 0.0, w, h), tile_type, ..PatternDef::new(name, art) };
d.patterns.push(def);
}
fn polygon(points: &[(f64, f64)]) -> PathData {
let points: Vec<Point> = points.iter().map(|&(x, y)| Point::new(x, y)).collect();
PathData::single(SubPath::polyline(&points, true))
}
/// Geometric Patterns: black dots, stripes, checks, grids and shapes on a clear background.
fn geometric_patterns(d: &mut Document) {
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| shapes::rectangle(Rect::new(x0, y0, x1, y1));
add(d, "Polka Dots", 12.0, 12.0, TileType::BrickByRow { offset: 0.5 }, vec![shapes::ellipse(Rect::new(3.0, 3.0, 9.0, 9.0))]);
add(d, "Fine Dots", 6.0, 6.0, TileType::Grid, vec![shapes::ellipse(Rect::new(2.0, 2.0, 4.0, 4.0))]);
add(d, "Vertical Stripes", 8.0, 8.0, TileType::Grid, vec![rect(0.0, 0.0, 3.0, 8.0)]);
add(d, "Horizontal Stripes", 8.0, 8.0, TileType::Grid, vec![rect(0.0, 0.0, 8.0, 3.0)]);
// A band along the tile's diagonal and the corners of the two bands beside it.
add(
d,
"Diagonal Stripes",
10.0,
10.0,
TileType::Grid,
vec![
polygon(&[(0.0, 0.0), (1.5, 0.0), (10.0, 8.5), (10.0, 10.0), (8.5, 10.0), (0.0, 1.5)]),
polygon(&[(8.5, 0.0), (10.0, 0.0), (10.0, 1.5)]),
polygon(&[(0.0, 8.5), (1.5, 10.0), (0.0, 10.0)]),
],
);
add(d, "Checkerboard", 10.0, 10.0, TileType::Grid, vec![rect(0.0, 0.0, 5.0, 5.0), rect(5.0, 5.0, 10.0, 10.0)]);
add(d, "Grid", 10.0, 10.0, TileType::Grid, vec![rect(0.0, 0.0, 10.0, 0.75), rect(0.0, 0.75, 0.75, 10.0)]);
add(d, "Diamonds", 10.0, 10.0, TileType::Grid, vec![polygon(&[(5.0, 1.0), (9.0, 5.0), (5.0, 9.0), (1.0, 5.0)])]);
add(d, "Triangles", 10.0, 10.0, TileType::BrickByRow { offset: 0.5 }, vec![polygon(&[(1.0, 9.0), (5.0, 1.0), (9.0, 9.0)])]);
add(d, "Crosses", 10.0, 10.0, TileType::Grid, vec![rect(4.0, 1.5, 6.0, 8.5), rect(1.5, 4.0, 8.5, 6.0)]);
add(d, "Zigzag", 10.0, 8.0, TileType::Grid, vec![polygon(&[(0.0, 5.0), (5.0, 1.0), (10.0, 5.0), (10.0, 7.0), (5.0, 3.0), (0.0, 7.0)])]);
}
+51 -14
View File
@@ -1,8 +1,9 @@
//! Swatch libraries (Window → Swatch Libraries, the Swatches panel's library button): read-only
//! sets of swatches the library panel shows. The built-in libraries are computed in
//! [`vectorcraft_color::libraries`]; User Defined ones are the library files in the user library
//! folder ([`Libraries`]), and Other Library… loads more from files. `swatch.library.add` copies
//! swatches into the document; `swatch.library.save` writes the document's ([`palette_io`]).
//! [`vectorcraft_color::libraries`] and the pattern libraries in [`super::patternlib`]; User
//! Defined ones are the library files in the user library folder ([`Libraries`]), and Other
//! Library… loads more from files. `swatch.library.add` copies swatches (and a pattern swatch's
//! pattern) into the document; `swatch.library.save` writes the document's ([`palette_io`]).
//! `swatch.library.copyToUser` copies a loaded library into the user library folder.
use std::collections::HashSet;
@@ -13,10 +14,12 @@ use vectorcraft_color::libraries::{BuiltinLibrary, GRADIENT_LIBRARIES, SWATCH_LI
use vectorcraft_color::palette_io::{self, PaletteFormat};
use vectorcraft_color::recolor::Palette;
use vectorcraft_color::{Paint, Swatch, SwatchGroup, SwatchLibrary, default_swatches};
use vectorcraft_doc::pattern::pattern_paint;
use super::fileio::{create_dir, extension, read_file, write_file, write_new_file};
use super::menucmds::squash;
use super::package::free_name;
use super::patternlib;
use super::swatch::{map_default_paints, map_swatch_links, str_list, swatch_json};
use super::{Color, CommandSpec, Result, Session, always, bad, cmd, has_doc, switch_or, text_param};
@@ -27,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Swatch Libraries",
[],
None,
"{} → {libraries: [{id, name, category: \"builtIn\"|\"gradients\"|\"user\" (User Defined: the library files in the user library folder, rescanned now)|\"loaded\" (swatch.library.load), count, path?}], userFolder} every library the library panel opens, in menu order",
"{} → {libraries: [{id, name, category: \"builtIn\"|\"gradients\"|\"patterns\"|\"user\" (User Defined: the library files in the user library folder, rescanned now)|\"loaded\" (swatch.library.load), count, path?}], userFolder} every library the library panel opens, in menu order",
always,
list
),
@@ -36,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Swatch Library",
[],
None,
"{library: id or name} → {id, name, category, swatches: [{name, kind, group, global, spot, color?, hex?, gradient?}] (as swatch.list), groups: [{name, swatches: [names]}]}",
"{library: id or name} → {id, name, category, swatches: [{name, kind, group, global, spot, color?, hex?, gradient?, pattern?}] (as swatch.list), groups: [{name, swatches: [names]}]}",
always,
get
),
@@ -45,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add to Swatches",
["Window", "Swatch Libraries"],
None,
"{library: id or name, names?: [swatch or colour group names in the library] (default: all of it; a group comes as a colour group, a single swatch ungrouped), apply?: \"fill\"|\"stroke\" (also apply the first one to the selection and the defaults, as paint.setFill / paint.setStroke), focus?: true (with apply: false keeps the active proxy)} copy library swatches into the document as one undo step. A swatch the document already has (same name and paint) isn't added again; a name taken by another swatch gets a number (\"Clay 1 2\") → {library, added: [names in the document], existing: [names already there], applied?: name}",
"{library: id or name, names?: [swatch or colour group names in the library] (default: all of it; a group comes as a colour group, a single swatch ungrouped), apply?: \"fill\"|\"stroke\" (also apply the first one to the selection and the defaults, as paint.setFill / paint.setStroke), focus?: true (with apply: false keeps the active proxy)} copy library swatches into the document as one undo step. A swatch the document already has (same name and paint) isn't added again; a name taken by another swatch gets a number (\"Clay 1 2\"). A pattern swatch brings its pattern, as pasting art painted with it does: the same pattern the document has is used, and a name another pattern or swatch has gets a number → {library, added: [names in the document], existing: [names already there], applied?: name}",
has_doc,
add
),
@@ -380,7 +383,7 @@ pub(crate) fn stem(name: &str) -> &str {
pub struct LibraryInfo {
pub id: String,
pub name: String,
/// "builtIn", "gradients", "user" or "loaded".
/// "builtIn", "gradients", "patterns", "user" or "loaded".
pub category: &'static str,
}
@@ -393,9 +396,9 @@ fn builtin_info((category, b): (&'static str, &BuiltinLibrary)) -> LibraryInfo {
LibraryInfo { id: b.id.into(), name: b.name.into(), category }
}
/// Every library, in menu order: built-in, gradients, User Defined, loaded.
/// Every library, in menu order: built-in, gradients, patterns, User Defined, loaded.
pub fn libraries(s: &Session) -> Vec<LibraryInfo> {
builtins().map(builtin_info).chain(s.swatch_libraries.infos().cloned()).collect()
builtins().map(builtin_info).chain(patternlib::infos()).chain(s.swatch_libraries.infos().cloned()).collect()
}
/// Library `key` (an id, or a name in any case) with its info.
@@ -406,6 +409,9 @@ pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc<SwatchLibrary
if let Some(b) = builtins().find(|(_, b)| b.id == key) {
return Some((builtin_info(b), builtin_library(key)?));
}
if let Some(found) = patternlib::library(key) {
return Some(found);
}
let id = libraries(s).into_iter().find(|l| l.name.eq_ignore_ascii_case(key))?.id;
library(s, &id)
}
@@ -611,10 +617,25 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
};
let focus = switch_or(p, "focus", true, C)?;
let picked = picked(&lib, &names_param(p, C)?, C)?;
// Swatches the document already has (same name and paint) are reused, not added again.
// Swatches the document already has (same name and paint; for a pattern swatch, a swatch of the
// same pattern) are reused, not added again.
let patterns = patternlib::library_doc(&info.id);
let d = &s.doc()?.doc;
let (existing, fresh): (Vec<_>, Vec<_>) = picked.iter().partition(|(_, w)| d.swatch(&w.name).is_some_and(|x| x.paint == w.paint));
let existing: Vec<String> = existing.iter().map(|(_, w)| w.name.clone()).collect();
let there = |w: &Swatch| match (&w.paint, &patterns) {
(Paint::Pattern { pattern, .. }, Some(src)) => {
patternlib::existing_pattern(d, src, pattern).and_then(|target| patternlib::pattern_swatch(d, &target))
}
_ => d.swatch(&w.name).is_some_and(|x| x.paint == w.paint).then(|| w.name.clone()),
};
// Library name → the name of the document's swatch.
let mut existing: Vec<(String, String)> = vec![];
let mut fresh = vec![];
for &(group, w) in &picked {
match there(w) {
Some(name) => existing.push((w.name.clone(), name)),
None => fresh.push((group, w)),
}
}
let undo_before = s.doc()?.history.undo.len();
// Library name → the name it got in the document.
let added: Vec<(String, String)> = if fresh.is_empty() {
@@ -627,6 +648,21 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
let before = d.swatch_groups.len();
let mut placed: Vec<(&str, usize)> = vec![];
for (group, w) in fresh {
if let (Paint::Pattern { pattern, .. }, Some(src)) = (&w.paint, &patterns) {
// The pattern comes with its swatch; a pattern the document has without one
// gets one.
let target = patternlib::import(d, src, pattern);
let name = match patternlib::pattern_swatch(d, &target) {
Some(name) => name,
None => {
let name = d.free_swatch_name(&target);
d.swatches.push(Swatch { name: name.clone(), paint: pattern_paint(&target), global: false, spot: false });
name
}
};
out.push((w.name.clone(), name));
continue;
}
let name = d.free_swatch_name(&w.name);
let sw = Swatch { name: name.clone(), ..(*w).clone() };
match group {
@@ -652,9 +688,10 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
})?
};
let mut out = json!({"library": info.id, "added": added.iter().map(|(_, n)| n).collect::<Vec<_>>(), "existing": existing});
let names = |list: &[(String, String)]| list.iter().map(|(_, n)| n.clone()).collect::<Vec<_>>();
let mut out = json!({"library": info.id, "added": names(&added), "existing": names(&existing)});
if let (Some(cmd), Some((_, first))) = (apply, picked.first()) {
let name = added.iter().find(|(l, _)| *l == first.name).map_or(first.name.clone(), |(_, n)| n.clone());
let name = added.iter().chain(&existing).find(|(l, _)| *l == first.name).map_or(first.name.clone(), |(_, n)| n.clone());
s.execute(cmd, &json!({"swatch": name, "focus": focus}))?;
// Adding and applying undo together.
squash(s, undo_before, "Add to Swatches");
+127
View File
@@ -638,6 +638,133 @@ fn gradient_libraries_are_listed_and_gradient_swatches_rename_and_take_new_gradi
assert_eq!(doc(&s).swatch("Red").unwrap().paint.color().map(|c| c.to_hex()), Some("#00ff00".into()));
}
#[test]
fn pattern_libraries_bring_their_patterns_and_add_each_once() {
let mut s = session();
let list = run(&mut s, "swatch.library.list", json!({}));
let libs = list["libraries"].as_array().unwrap();
let categories: Vec<&str> = libs.iter().map(|l| l["category"].as_str().unwrap()).collect();
let first = categories.iter().position(|c| *c == "patterns").expect("a pattern library");
assert!(categories.iter().rposition(|c| *c == "gradients") < Some(first), "{categories:?}");
let geo = libs.iter().find(|l| l["id"] == "geometric-patterns").unwrap();
assert_eq!(geo["name"], "Geometric Patterns");
let got = run(&mut s, "swatch.library.get", json!({"library": "geometric patterns"}));
let swatches = got["swatches"].as_array().unwrap();
assert_eq!(swatches.len() as u64, geo["count"].as_u64().unwrap());
assert!(swatches.iter().all(|w| w["kind"] == "pattern" && w["pattern"] == w["name"]));
// Clicking a pattern adds it with its definition and fills the selection with it, one undo step.
let id = vectorcraft_doc::NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap());
let before = undo_len(&s);
let r = run(&mut s, "swatch.library.add", json!({"library": "geometric-patterns", "names": ["Polka Dots"], "apply": "fill"}));
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Polka Dots"]), json!("Polka Dots")));
assert_eq!(undo_len(&s), before + 1, "added and applied as one step");
let d = doc(&s);
let def = d.pattern("Polka Dots").expect("the pattern comes with its swatch");
assert!(!def.art.is_empty());
assert!(matches!(d.swatch("Polka Dots").map(|w| &w.paint), Some(Paint::Pattern { pattern, .. }) if pattern == "Polka Dots"));
assert!(matches!(d.node(id).unwrap().appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Polka Dots"));
// Again: nothing is added; the stroke takes the same pattern.
let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count());
let had = counts(doc(&s));
let r = run(&mut s, "swatch.library.add", json!({"library": "geometric-patterns", "names": ["Polka Dots"], "apply": "stroke"}));
assert_eq!((r["added"].clone(), r["existing"].clone(), r["applied"].clone()), (json!([]), json!(["Polka Dots"]), json!("Polka Dots")));
assert_eq!(counts(doc(&s)), had);
assert!(matches!(doc(&s).node(id).unwrap().appearance.stroke_paint(), Paint::Pattern { pattern, .. } if pattern == "Polka Dots"));
// A different pattern under the name: the library's comes in numbered, with its swatch.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
s.edit("Pattern", |d, _| {
let dot = vectorcraft_doc::Node::path(
d.alloc_id(),
vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 4.0, 4.0)),
vectorcraft_doc::Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0),
);
d.patterns.push(vectorcraft_doc::PatternDef::new("Polka Dots", vec![std::sync::Arc::new(dot)]));
d.swatches.push(vectorcraft_color::Swatch {
name: "Polka Dots".into(),
paint: vectorcraft_doc::pattern::pattern_paint("Polka Dots"),
global: false,
spot: false,
});
Ok(())
})
.unwrap();
let r = run(&mut s, "swatch.library.add", json!({"library": "geometric-patterns", "apply": "fill"}));
let added: Vec<&str> = r["added"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
assert_eq!((added.len(), added[0], r["applied"].as_str()), (swatches.len(), "Polka Dots 2", Some("Polka Dots 2")));
let d = doc(&s);
assert!(added.iter().all(|n| d.pattern(n).is_some() && d.swatch(n).is_some()), "{added:?}");
assert!(matches!(d.node(id).unwrap().appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Polka Dots 2"));
// A swatch of the pattern moved in its space is not the pattern's own swatch: a new one is added
// and applied, and no second pattern.
s.edit("Move", |d, _| {
if let Some(w) = d.swatches_iter_mut().find(|w| w.name == "Polka Dots 2") {
w.paint = Paint::Pattern { pattern: "Polka Dots 2".into(), xf: kurbo::Affine::translate((3.0, 0.0)) };
}
Ok(())
})
.unwrap();
let patterns = doc(&s).patterns.len();
let r = run(&mut s, "swatch.library.add", json!({"library": "geometric-patterns", "names": ["Polka Dots"], "apply": "fill"}));
let applied = r["applied"].as_str().unwrap().to_string();
assert_ne!(applied, "Polka Dots 2");
let d = doc(&s);
assert_eq!(
(d.patterns.len(), d.swatch(&applied).map(|w| w.paint.clone())),
(patterns, Some(vectorcraft_doc::pattern::pattern_paint("Polka Dots 2")))
);
assert_eq!(d.node(id).unwrap().appearance.fill_paint(), vectorcraft_doc::pattern::pattern_paint("Polka Dots 2"));
// A pattern library has no colours to limit to, and is built in.
assert!(s.execute("swatch.library.copyToUser", &json!({"library": "geometric-patterns"})).is_err());
}
/// The built-in pattern libraries: unique, tiling patterns with art, whose names no two libraries
/// share. Each library's own swatches draw from it; a swatch of the same name elsewhere does not.
#[test]
fn built_in_pattern_libraries_hold_tiling_patterns_with_unique_names() {
use crate::cmd::patternlib::{PATTERN_LIBRARIES, library, library_doc, swatch_source};
let mut names = std::collections::HashSet::new();
for b in PATTERN_LIBRARIES {
let d = library_doc(b.id).unwrap();
assert!(!d.patterns.is_empty(), "{}", b.id);
for p in &d.patterns {
assert!(names.insert(p.name.clone()), "{} is in two libraries", p.name);
assert!(!p.art.is_empty() && p.width() > 0.0 && p.height() > 0.0, "{}", p.name);
assert!(vectorcraft_render::render_pattern_swatch(&d, &p.name, 16).is_some(), "{}", p.name);
}
let (_, lib) = library(b.id).unwrap();
for w in &lib.swatches {
assert!(std::sync::Arc::ptr_eq(&swatch_source(w).unwrap(), &d), "{}", w.name);
assert!(swatch_source(&w.clone()).is_none(), "a copy of {} is another library's swatch", w.name);
}
}
}
/// Listing and reading a pattern library add nothing to the document. A pattern added from it
/// undoes and redoes with its swatch, and both stay after a save and reopen.
#[test]
fn a_pattern_library_adds_only_the_picked_pattern_and_it_undoes_and_saves() {
let mut s = session();
let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count());
let empty = counts(doc(&s));
run(&mut s, "swatch.library.list", json!({}));
run(&mut s, "swatch.library.get", json!({"library": "geometric-patterns"}));
assert_eq!(counts(doc(&s)), empty, "reading a library adds no pattern or swatch");
run(&mut s, "swatch.library.add", json!({"library": "geometric-patterns", "names": ["Checkerboard"]}));
assert_eq!(counts(doc(&s)), (empty.0 + 1, empty.1 + 1), "only the picked pattern and its swatch");
let has = |d: &vectorcraft_doc::Document| d.pattern("Checkerboard").is_some() && d.swatch("Checkerboard").is_some();
assert!(has(doc(&s)));
run(&mut s, "edit.undo", json!({}));
assert_eq!(counts(doc(&s)), empty);
assert!(doc(&s).pattern("Checkerboard").is_none() && doc(&s).swatch("Checkerboard").is_none());
run(&mut s, "edit.redo", json!({}));
assert!(has(doc(&s)));
let back = vectorcraft_format::load(&vectorcraft_format::save(doc(&s), false)).unwrap();
assert_eq!(back.patterns, doc(&s).patterns);
assert!(has(&back));
assert_eq!(back.swatch("Checkerboard").map(|w| &w.paint), doc(&s).swatch("Checkerboard").map(|w| &w.paint));
}
/// #3602: a library titled with a Windows device name is saved under a name that is not one.
#[test]
fn a_library_file_name_is_never_a_windows_device_name() {
+1
View File
@@ -5517,6 +5517,7 @@ theme Appearance Външен вид
Ruby… Руби…
Ruby Руби
Geometric Guides Геометрични водачи
Geometric Patterns Геометрични мостри
Ease In Плавно начало
Ease Out Плавен край
Ease In Out Плавно начало и край
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5537,6 +5537,7 @@ mark-style Japanese Japanski
Ruby… Ruby…
Ruby Ruby
Geometric Guides Geometrijske vodilice
Geometric Patterns Geometrijski uzorci
Ease In Ublaži ulaz
Ease Out Ublaži izlaz
Ease In Out Ublaži ulaz i izlaz
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1693,6 +1693,7 @@ digits Malayalam Malaiàlam
Generate CSS Genera CSS
Generate CSS: the whole document's CSS while nothing is selected Genera CSS: el CSS de tot el document quan no hi ha res seleccionat
Geometric Guides Guies geomètriques
Geometric Patterns Motius geomètrics
German Alemany
Get Preferences Obtén les preferències
Get Text Range Obtén l’interval de text
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5614,6 +5614,7 @@ mark-style Japanese Japonské
Ruby… Ruby…
Ruby Ruby
Geometric Guides Geometrická vodítka
Geometric Patterns Geometrické vzorky
Ease In Pozvolný začátek
Ease Out Pozvolný konec
Ease In Out Pozvolný začátek a konec
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5533,6 +5533,7 @@ swatch Bright Violet Kraftig violet
Ruby… Ruby…
Ruby Ruby
Geometric Guides Geometriske hjælpelinjer
Geometric Patterns Geometriske mønstre
Ease In Langsom start
Ease Out Langsom slutning
Ease In Out Langsom start og slutning
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5194,6 +5194,7 @@ effect Soft Weich
Ruby… Ruby…
Ruby Ruby
Geometric Guides Geometrische Hilfslinien
Geometric Patterns Geometrische Muster
Ease In Langsam starten
Ease Out Langsam enden
Ease In Out Langsam starten und enden
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5565,6 +5565,7 @@ effect Points Σημεία
Ruby… Ruby…
Ruby Ruby
Geometric Guides Γεωμετρικοί οδηγοί
Geometric Patterns Γεωμετρικά μοτίβα
Ease In Ομαλή αρχή
Ease Out Ομαλό τέλος
Ease In Out Ομαλή αρχή και τέλος
Can't render this file because it contains an unexpected character in line 164 and column 5.
+1
View File
@@ -5555,6 +5555,7 @@ mark-style Japanese Japonesas
Ruby… Ruby…
Ruby Ruby
Geometric Guides Guías geométricas
Geometric Patterns Motivos geométricos
Ease In Entrada suave
Ease Out Salida suave
Ease In Out Entrada y salida suaves
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5644,6 +5644,7 @@ swatch Bright Violet Kirkas violetti
Ruby… Ruby…
Ruby Ruby
Geometric Guides Geometriset apuviivat
Geometric Patterns Geometriset kuviot
Ease In Hidas alku
Ease Out Hidas loppu
Ease In Out Hidas alku ja loppu
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5611,6 +5611,7 @@ theme Appearance Apparence
Ruby… Ruby…
Ruby Ruby
Geometric Guides Repères géométriques
Geometric Patterns Motifs géométriques
Ease In Accélération progressive
Ease Out Décélération progressive
Ease In Out Accélération et décélération
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5599,6 +5599,7 @@ imageTrace Outline Körvonal
Ruby… Ruby…
Ruby Ruby
Geometric Guides Geometriai segédvonalak
Geometric Patterns Geometriai mintázatok
Ease In Lassú kezdés
Ease Out Lassú befejezés
Ease In Out Lassú kezdés és befejezés
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5533,6 +5533,7 @@ swatch Bright Violet Violet cerah
Ruby… Ruby…
Ruby Ruby
Geometric Guides Panduan Geometris
Geometric Patterns Pola Geometris
Ease In Awal Lambat
Ease Out Akhir Lambat
Ease In Out Awal dan Akhir Lambat
Can't render this file because it contains an unexpected character in line 2197 and column 5.
+1
View File
@@ -5228,6 +5228,7 @@ lighting Light Luce
Ruby… Ruby…
Ruby Ruby
Geometric Guides Guide geometriche
Geometric Patterns Motivi geometrici
Ease In Inizio graduale
Ease Out Fine graduale
Ease In Out Inizio e fine graduali
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5280,6 +5280,7 @@ lighting Light ライト
Ruby… ルビ…
Ruby ルビ
Geometric Guides 幾何ガイド
Geometric Patterns 幾何パターン
Ease In イーズイン
Ease Out イーズアウト
Ease In Out イーズインアウト
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5518,6 +5518,7 @@ imageTrace Outline კონტური
Ruby… რუბი…
Ruby რუბი
Geometric Guides გეომეტრიული მიმმართველები
Geometric Patterns გეომეტრიული პატერნები
Ease In ნელი დასაწყისი
Ease Out ნელი დასასრული
Ease In Out ნელი დასაწყისი და დასასრული
Can't render this file because it is too large.
+1
View File
@@ -5191,6 +5191,7 @@ lighting Light 광원
Ruby… 루비…
Ruby 루비
Geometric Guides 기하학적 안내선
Geometric Patterns 기하학적 패턴
Ease In 서서히 시작
Ease Out 서서히 종료
Ease In Out 서서히 시작 및 종료
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5577,6 +5577,7 @@ swatch Bright Violet Felviolet
Ruby… Ruby…
Ruby Ruby
Geometric Guides Geometrische hulplijnen
Geometric Patterns Geometrische patronen
Ease In Langzaam beginnen
Ease Out Langzaam eindigen
Ease In Out Langzaam beginnen en eindigen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5467,6 +5467,7 @@ mark-style Japanese Japońskie
Ruby… Ruby…
Ruby Ruby
Geometric Guides Geometryczne linie pomocnicze
Geometric Patterns Wzorki geometryczne
Ease In Łagodny początek
Ease Out Łagodny koniec
Ease In Out Łagodny początek i koniec
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5530,6 +5530,7 @@ mark-style Japanese Japonesas
Ruby… Ruby…
Ruby Ruby
Geometric Guides Guias geométricas
Geometric Patterns Padrões geométricos
Ease In Entrada suave
Ease Out Saída suave
Ease In Out Entrada e saída suaves
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -5223,6 +5223,7 @@ lighting Light Свет
Ruby… Руби…
Ruby Руби
Geometric Guides Геометрические направляющие
Geometric Patterns Геометрические узоры
Ease In Плавное начало
Ease Out Плавное окончание
Ease In Out Плавное начало и окончание
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5637,6 +5637,7 @@ font-script Japanese Japanska
Ruby… Ruby…
Ruby Ruby
Geometric Guides Geometriska stödlinjer
Geometric Patterns Geometriska mönster
Ease In Långsam start
Ease Out Långsamt slut
Ease In Out Långsam start och slut
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -5610,6 +5610,7 @@ axis Y Axis Y ekseni
Snap to Tangent Teğete Yasla
Snap to Perpendicular Dikeye Yasla
Geometric Guides Geometrik Kılavuzlar
Geometric Patterns Geometrik Desenler
@msg `method` must be classic or perceptual `method` classic veya perceptual olmalıdır
# Heavy plug-in filters (plugin.inspect, plug-in runs)
Install “{name}”, which asks for a heavy budget? Büyük bir hesaplama bütçesi isteyen “{name}” yüklensin mi?
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -5208,6 +5208,7 @@ lighting Light Світло
Ruby… Рубі…
Ruby Рубі
Geometric Guides Геометричні напрямні
Geometric Patterns Геометричні візерунки
Ease In Плавний початок
Ease Out Плавне завершення
Ease In Out Плавний початок і завершення
Can't render this file because it contains an unexpected character in line 178 and column 5.
+1
View File
@@ -5211,6 +5211,7 @@ theme Appearance Giao diện
Ruby… Ruby…
Ruby Ruby
Geometric Guides Đường dẫn hình học
Geometric Patterns Họa tiết hình học
Ease In Chậm lúc đầu
Ease Out Chậm lúc cuối
Ease In Out Chậm lúc đầu và cuối
Can't render this file because it contains an unexpected character in line 200 and column 5.
+1
View File
@@ -5453,6 +5453,7 @@ lighting Light 光源
Ruby… 注音…
Ruby 注音
Geometric Guides 几何参考线
Geometric Patterns 几何图案
Ease In 缓入
Ease Out 缓出
Ease In Out 缓入缓出
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5248,6 +5248,7 @@ lighting Light 光源
Ruby… 注音…
Ruby 注音
Geometric Guides 幾何參考線
Geometric Patterns 幾何圖樣
Ease In 緩入
Ease Out 緩出
Ease In Out 緩入緩出
Can't render this file because it contains an unexpected character in line 6 and column 47.
@@ -408,6 +408,7 @@ fn key(panel: &str, key: &str) -> String {
pub(crate) fn submenu(category: &str) -> Option<&'static str> {
match category {
"gradients" => Some("Gradients"),
"patterns" => Some("Patterns"),
"user" => Some("User Defined"),
"loaded" => Some("Other Libraries"),
_ => None,
@@ -70,6 +70,25 @@ fn clicking_a_library_swatch_adds_and_applies_it_in_one_step() {
assert!(last_open(&app.ui).is_none());
}
#[test]
fn clicking_a_library_pattern_adds_the_pattern_and_applies_it_to_the_active_proxy() {
let mut app = app();
let ctx = context();
let r = app.run("window.swatchLibrary", json!({"library": "Geometric Patterns"})).unwrap();
assert_eq!(r["panel"].as_str(), Some("library:swatches:geometric-patterns"));
let undo = undo_len(&app);
click(&mut app, &ctx, "Checkerboard", Modifiers::NONE, 0.0);
let d = &app.session.doc().unwrap().doc;
assert!(d.pattern("Checkerboard").is_some() && d.swatch("Checkerboard").is_some(), "added with its pattern");
assert!(matches!(&app.session.paint.fill, Paint::Pattern { pattern, .. } if pattern == "Checkerboard"), "applied to the fill");
assert_eq!(undo_len(&app), undo + 1);
// Alt-click paints the stroke; the pattern is added once.
let patterns = app.session.doc().unwrap().doc.patterns.len();
click(&mut app, &ctx, "Checkerboard", Modifiers::ALT, 2.0);
assert!(matches!(&app.session.paint.stroke, Paint::Pattern { pattern, .. } if pattern == "Checkerboard"));
assert_eq!(app.session.doc().unwrap().doc.patterns.len(), patterns);
}
/// A color clicked in a swatch library becomes the Color Guide's base color (#3137).
#[test]
fn a_clicked_library_color_becomes_the_color_guides_base() {
@@ -136,7 +155,8 @@ fn find_filters_list_view_and_arrows_step_through_the_libraries() {
assert!(ctx.read_response(tile_id(panel, "#00FF00")).is_none());
let first = SWATCH_IDS[0];
let last = SwatchLibraries::list(&app).pop().unwrap().id;
assert_eq!(last, "radial-glows", "gradient libraries come after the swatch libraries");
assert_eq!(last, "geometric-patterns", "gradient and then pattern libraries come after the swatch libraries");
assert_eq!(neighbour::<SwatchLibraries>(&app, "radial-glows", 1).as_deref(), Some("geometric-patterns"));
assert_eq!(neighbour::<SwatchLibraries>(&app, first, -1), Some(last), "wraps around");
assert_eq!(neighbour::<SwatchLibraries>(&app, "web-safe-216", 1).as_deref(), Some(SWATCH_IDS[1]));
}
@@ -172,6 +192,13 @@ fn the_window_submenu_lists_every_library() {
.collect();
assert!(gradients.len() >= 5);
assert_eq!(gradients, listed("gradients"));
let patterns: Vec<Value> = crate::menus::menu_entries(&app)
.into_iter()
.filter(|e| e.path == ["Window", "Swatch Libraries", "Patterns"])
.map(|e| e.params["library"].clone())
.collect();
assert!(!patterns.is_empty());
assert_eq!(patterns, listed("patterns"));
let defaults = crate::menus::menu_entries(&app).into_iter().any(|e| e.command.as_deref() == Some("swatch.resetDefaults"));
assert!(defaults, "Default Swatches is listed");
}
+1
View File
@@ -13,6 +13,7 @@ use vectorcraft_color::libraries::{BuiltinLibrary, GRADIENT_LIBRARIES, SWATCH_LI
use vectorcraft_color::swatch::REGISTRATION;
use vectorcraft_color::{Color, GradientKind, Paint, Swatch, SwatchLibrary};
use vectorcraft_doc::Document;
use vectorcraft_engine::cmd::patternlib::{self, PATTERN_LIBRARIES};
use vectorcraft_engine::cmd::swatchlib;
use super::library_panel::{self, LibraryKind, LibraryRef, Row};
@@ -1,9 +1,9 @@
use super::actions::deletable;
use super::dnd::selection;
use super::tiles::{describe, global_mark, list_icons};
use super::tiles::{describe, global_mark, list_icons, pattern_thumb_in};
use super::{
Arc, BuiltinLibrary, GRADIENT_LIBRARIES, Item, LibraryKind, LibraryRef, Rect, Row, SWATCH_LIBRARIES, Swatch, SwatchLibrary, Ui, Value,
VectorcraftApp, json, library_panel, menu_item, swatch_tile, swatchlib,
Arc, BuiltinLibrary, GRADIENT_LIBRARIES, Item, LibraryKind, LibraryRef, PATTERN_LIBRARIES, Rect, Row, SWATCH_LIBRARIES, Swatch, SwatchLibrary,
Ui, Value, VectorcraftApp, json, library_panel, menu_item, patternlib, swatch_tile, swatchlib,
};
fn library_refs(libs: Vec<swatchlib::LibraryInfo>) -> Vec<LibraryRef> {
@@ -99,6 +99,10 @@ impl LibraryKind for SwatchLibraries {
}
fn draw(_: &VectorcraftApp, ui: &Ui, r: Rect, w: &Swatch, selected: bool, hovered: bool) {
swatch_tile(ui, r, &w.paint, selected, hovered);
// A built-in pattern library's pattern, drawn from the library.
if let Some(src) = patternlib::swatch_source(w) {
pattern_thumb_in(&src, ui, r.shrink(1.0), &w.paint);
}
if w.global {
global_mark(ui, r, w.spot);
}
@@ -194,10 +198,12 @@ pub(crate) fn window_menu() -> Vec<Item> {
"window.userSwatchLibrary10",
];
let open = |b: &'static BuiltinLibrary| Item::Cmd(b.name, "window.swatchLibrary", json!({ "library": b.id }));
let patterns = PATTERN_LIBRARIES.iter().map(|b| Item::Cmd(b.name, "window.swatchLibrary", json!({ "library": b.id }))).collect();
let mut items = vec![Item::Cmd("Default Swatches", "swatch.resetDefaults", Value::Null), Item::Sep];
items.extend(SWATCH_LIBRARIES.iter().map(open));
items.extend([
Item::Sub("Gradients", GRADIENT_LIBRARIES.iter().map(open).collect()),
Item::Sub("Patterns", patterns),
Item::Sep,
Item::Sub("User Defined", SLOTS.iter().map(|id| Item::Cmd("User Library", id, Value::Null)).collect()),
Item::Sep,
+11 -4
View File
@@ -1,17 +1,24 @@
use super::{
Color, Color32, Entry, GradientKind, Paint, Rect, Response, Sense, Shape, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, icons, pos2, vec2,
Color, Color32, Document, Entry, GradientKind, Paint, Rect, Response, Sense, Shape, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, icons,
pos2, vec2,
};
/// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size).
pub(super) fn pattern_thumb(app: &VectorcraftApp, ui: &Ui, r: Rect, paint: &Paint) {
if let Some(st) = app.session.active() {
pattern_thumb_in(&st.doc, ui, r, paint);
}
}
/// A pattern swatch drawn from the pattern of its name in `doc`, as [`pattern_thumb`] does.
pub(super) fn pattern_thumb_in(doc: &Document, ui: &Ui, r: Rect, paint: &Paint) {
use std::collections::HashMap;
type Key = (String, Vec<usize>, String, u32);
thread_local! {
static CACHE: crate::graphics::TexCache<HashMap<Key, Option<egui::TextureHandle>>> = crate::graphics::TexCache::default();
}
let Paint::Pattern { pattern, .. } = paint else { return };
let Some(st) = app.session.active() else { return };
let Some(def) = st.doc.pattern(pattern) else { return };
let Some(def) = doc.pattern(pattern) else { return };
let px = (r.width() * ui.ctx().pixels_per_point()).round().max(4.0) as u32;
let key = (
pattern.clone(),
@@ -20,7 +27,7 @@ pub(super) fn pattern_thumb(app: &VectorcraftApp, ui: &Ui, r: Rect, paint: &Pain
px,
);
let tex = CACHE.with(|c| c.borrow().get(&key).cloned()).unwrap_or_else(|| {
let tex = vectorcraft_render::render_pattern_swatch(&st.doc, pattern, px).map(|img| {
let tex = vectorcraft_render::render_pattern_swatch(doc, pattern, px).map(|img| {
let color = crate::canvas::color_image(&img);
ui.ctx().load_texture(format!("pattern-swatch-{pattern}-{px}"), color, egui::TextureOptions::LINEAR)
});
+4 -1
View File
@@ -2034,7 +2034,10 @@ in the `swatch.list` shape (`library` is an id or a name). `swatch.library.add {
into the document as one undo step: a colour group's name brings the whole group, a swatch's name the swatch alone,
no names the whole library; swatches the document already has (same name and paint) are reported under `existing`
and not added again. `apply: "fill"|"stroke"` also applies the first one, in the same undo step (what clicking a
swatch in the library panel does). `swatch.resetDefaults {replace?}` brings back the missing default swatches.
swatch in the library panel does). The pattern library Geometric Patterns (`geometric-patterns`, category
`patterns`) holds pattern swatches drawn in code; adding one also adds its pattern, as pasting art painted with it does
(the same pattern the document has is reused, and a name another pattern or swatch has gets a number).
`swatch.resetDefaults {replace?}` brings back the missing default swatches.
```json
{"name":"run_command","arguments":{"command":"swatch.library.get","params":{"library":"earth-tones"}}}
+1 -1
View File
@@ -950,7 +950,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Double-clicking the Objects on Path tool opens Objects on Path Tool Options ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)): the nine-point Pivot grid and the Rotate field set the attachment point and angle of the objects attached next, and groups already attached keep theirs; Reset puts the center and 0° back. Save keeps the options across tool switches and saves them with the preferences (`tool.setOption {tool: "objectsOnPath"}`).
- **Paint and appearance (M3):**
- **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged.
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, five gradient libraries and a pattern library (Geometric Patterns, whose swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too.
- **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. SVG writes the fill color through a coverage mask (or a colorized PNG in Tiny 1.2); EPS writes binary coverage as an image mask in the color's ink, and a grayscale image as ink samples scaled by coverage, with white pixels left out at Level 3 and gray midtones written over white.
The prompt for deleting several swatches and groups uses the interface language's plural form for the count. Evidence: `deleting_several_swatches_uses_each_count_form` in `crates/ui-egui/src/panels/swatches/tests.rs`.