Swatch libraries: Patterns › Geometric Patterns adds a pattern with its swatch

Window › Swatch Libraries and the Swatches panel's library menu get a
Patterns submenu with Geometric Patterns, a built-in library of eleven
black pattern swatches drawn in code (dots, stripes, a checkerboard, a
grid, diamonds, triangles, crosses and a zigzag).

- Clicking a pattern in its library panel adds the pattern and its
  swatch to the document and applies it to the active fill or stroke
  (Alt: the other one), as one undo step. A second click reuses the
  swatch; a different pattern under the same name keeps its name and
  the library's pattern comes in numbered.
- swatch.library.list lists the pattern libraries after the gradient
  libraries (category "patterns"), and swatch.library.add brings a
  pattern swatch's pattern as pasting art painted with it does.
- The library panel draws each pattern tile from the library's pattern.
- "Geometric Patterns" is translated in every catalog.
This commit is contained in:
dotCooCoo
2026-11-02 12:04:02 +07:00
committed by bflatastic
parent 65dcc83bf1
commit cb7df83217
38 changed files with 355 additions and 24 deletions
+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;
+135
View File
@@ -0,0 +1,135 @@
//! 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 pattern `name` (a library panel draws its pattern
/// swatches from it). Pattern names are unique across the built-in libraries.
pub fn pattern_source(name: &str) -> Option<Arc<Document>> {
PATTERN_LIBRARIES.iter().filter_map(|b| library_doc(b.id)).find(|d| d.pattern(name).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`, and whether anything was added.
pub(crate) fn import(d: &mut Document, lib: &Document, name: &str) -> (String, bool) {
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(), imported.added > 0)
}
/// The name of the first swatch of `d` that paints with pattern `pattern`.
pub(crate) fn pattern_swatch(d: &Document, pattern: &str) -> Option<String> {
d.swatches_iter().find(|w| matches!(&w.paint, Paint::Pattern { pattern: p, .. } if p == pattern)).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)])]);
}
+55 -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,29 @@ 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)) => {
let mut probe = (**d).clone();
match patternlib::import(&mut probe, src, pattern) {
(target, false) => patternlib::pattern_swatch(d, &target),
_ => None,
}
}
_ => 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 +652,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 +692,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");
+80
View File
@@ -638,6 +638,86 @@ 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 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.
#[test]
fn built_in_pattern_libraries_hold_tiling_patterns_with_unique_names() {
use crate::cmd::patternlib::{PATTERN_LIBRARIES, library_doc, pattern_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);
assert!(std::sync::Arc::ptr_eq(&pattern_source(&p.name).unwrap(), &d));
}
}
assert!(pattern_source("No Such Pattern").is_none());
}
/// #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() {