//! Graphic style libraries: the built-in libraries, adding to the document (deduped, one undo step, //! applied, patterns brought along), saving and loading `.vcstyles` and user libraries. use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Paint, Swatch}; use vectorcraft_doc::{GraphicStyle, Node, NodeId, PatternDef}; use vectorcraft_geom::{Point, Rect, shapes}; use super::*; fn session() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); s } fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) } fn doc(s: &Session) -> &vectorcraft_doc::Document { &s.doc().unwrap().doc } fn undo_len(s: &Session) -> usize { s.doc().unwrap().history.undo.len() } fn names(v: &Value) -> Vec<&str> { v.as_array().unwrap().iter().map(|n| n.as_str().unwrap()).collect() } fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { NodeId(run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap()) } #[test] fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() { let mut s = Session::new(); let list = run(&mut s, "graphicStyle.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); assert!(libs.len() >= 6 && libs.iter().all(|l| l["category"] == "builtIn"), "{libs:?}"); for l in libs { let id = l["id"].as_str().unwrap(); let g = run(&mut s, "graphicStyle.library", json!({"library": id})); let styles = g["styles"].as_array().unwrap(); assert!(!styles.is_empty() && l["count"].as_u64() == Some(styles.len() as u64), "{id}"); let (_, lib) = cmd::stylelib::library(&s, id).unwrap(); for st in &lib.styles { let ap = &st.appearance; let item_fx = ap.items.iter().flat_map(|i| i.effects()); for e in ap.effects.iter().chain(item_fx) { assert!(vectorcraft_render::effects::effect_info(&e.id).is_some(), "{id} / {}: unknown effect {}", st.name, e.id); } } } // By name in any case. assert_eq!(run(&mut s, "graphicStyle.library", json!({"library": "shadows and glows"}))["id"], "shadows-glows"); assert!(s.execute("graphicStyle.library", &json!({"library": "nope"})).is_err()); } #[test] fn add_dedupes_names_as_one_undo_step() { let mut s = session(); let before = (undo_len(&s), doc(&s).graphic_styles.len()); let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "names": ["Halo", "Ember", "Halo"]})); assert_eq!((names(&r["added"]), names(&r["existing"])), (vec!["Halo", "Ember"], vec![])); assert_eq!(undo_len(&s), before.0 + 1, "one undo step"); let d = doc(&s); assert_eq!(d.graphic_styles.len(), before.1 + 2); let halo = d.graphic_style("Halo").unwrap(); assert!(halo.id != 0 && d.graphic_styles.iter().filter(|g| g.id == halo.id).count() == 1, "a fresh id"); // Adding again finds them and changes nothing (no undo step). let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "name": "Halo"})); assert_eq!((names(&r["added"]), names(&r["existing"])), (vec![], vec!["Halo"])); assert_eq!(undo_len(&s), before.0 + 1); // A name taken by another look gets a number. let id = rect(&mut s, 0.0, 0.0, 20.0, 20.0); run(&mut s, "graphicStyle.new", json!({"name": "Feathered", "id": id.0})); let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "shadows-glows", "names": ["Feathered"]})); assert_eq!(names(&r["added"]), ["Feathered 2"]); assert_eq!(doc(&s).graphic_style("Feathered 2").unwrap().appearance.effects[0].id, "stylize.feather"); // The whole library; undo removes a whole add. let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "hand-drawn"})); let all = cmd::stylelib::library(&s, "hand-drawn").unwrap().1.len(); assert_eq!(r["added"].as_array().unwrap().len(), all); run(&mut s, "edit.undo", json!({})); assert!(doc(&s).graphic_style("Sketchy Line").is_none() && doc(&s).graphic_style("Feathered 2").is_some()); assert!(s.execute("graphicStyle.addFromLibrary", &json!({"library": "hand-drawn", "name": "Nope"})).is_err()); } #[test] fn add_with_apply_styles_the_selection_in_the_same_undo_step() { let mut s = session(); let id = rect(&mut s, 10.0, 20.0, 100.0, 50.0); let before = undo_len(&s); let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "gradient-finishes", "name": "Spotlight", "apply": true})); assert_eq!(r["applied"], "Spotlight"); assert_eq!(undo_len(&s), before + 1, "added and applied as one step"); let (g, linked) = s.selection_graphic_style().unwrap(); assert_eq!((g.name.as_str(), linked), ("Spotlight", true)); // The unit-box gradient lands at the same place relative to the object's bounds. let Paint::Gradient(gp) = doc(&s).node(id).unwrap().appearance.fill_paint() else { panic!("a gradient") }; let geom = gp.geom.unwrap(); assert!((geom.start - Point::new(10.0 + 0.35 * 100.0, 20.0 + 0.3 * 50.0)).hypot() < 1e-6, "{geom:?}"); run(&mut s, "edit.undo", json!({})); assert!(doc(&s).graphic_style("Spotlight").is_none()); assert_eq!(doc(&s).node(id).unwrap().graphic_style, None); // Without a selection it only adds; `ids` targets objects; an existing style is applied as is. run(&mut s, "select.none", json!({})); let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "blends-transparency", "name": "Ghosted", "apply": true})); assert!(r.get("applied").is_none() && doc(&s).graphic_style("Ghosted").is_some()); let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "blends-transparency", "name": "Ghosted", "apply": true, "ids": [id.0]})); assert_eq!((names(&r["existing"]), r["applied"].as_str()), (vec!["Ghosted"], Some("Ghosted"))); assert_eq!(doc(&s).node(id).unwrap().opacity, 0.4); } /// A document with pattern `name` (a dot) and its swatch. fn add_pattern(s: &mut Session, name: &str, radius: f64) { s.edit("Pattern", |d, _| { let dot = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(0.0, 0.0, radius, radius)), vectorcraft_doc::Appearance::default_art()); d.patterns.push(PatternDef { tile: Rect::new(0.0, 0.0, 10.0, 10.0), ..PatternDef::new(name, vec![Arc::new(dot)]) }); d.swatches.push(Swatch { name: name.into(), paint: pattern(name), global: false, spot: false }); Ok(()) }) .unwrap(); } fn pattern(name: &str) -> Paint { Paint::Pattern { pattern: name.into(), xf: Default::default() } } #[test] fn save_and_load_round_trip_with_patterns_and_unlinked_swatches() { let mut s = session(); add_pattern(&mut s, "Dots", 4.0); run(&mut s, "swatch.edit", json!({"name": "Red", "global": true})); let id = rect(&mut s, 0.0, 0.0, 40.0, 40.0); run(&mut s, "paint.setFill", json!({"swatch": "Dots"})); run(&mut s, "paint.setStroke", json!({"swatch": "Red"})); run(&mut s, "transparency.set", json!({"opacity": 50, "blend": "Multiply"})); run(&mut s, "graphicStyle.new", json!({"name": "Dotted", "id": id.0})); let r = run(&mut s, "graphicStyle.saveLibrary", json!({"names": ["Dotted", "Sunshine"], "name": "Kit"})); assert_eq!(r["count"], 2); let data = r["data"].as_str().unwrap().to_string(); assert!(data.contains("\"vcstyles\"")); let r = run(&mut s, "graphicStyle.loadLibrary", json!({"data": data, "name": "kit.vcstyles"})); assert_eq!((r["name"].as_str(), r["count"].as_u64()), (Some("Kit"), Some(2))); let lib_id = r["library"].as_str().unwrap().to_string(); let (_, lib) = cmd::stylelib::library(&s, &lib_id).unwrap(); let dotted = lib.style("Dotted").unwrap(); let mine = doc(&s).graphic_style("Dotted").unwrap(); assert!(dotted.same_look(&mine.standalone()) && (dotted.opacity, dotted.id) == (0.5, 0)); let red = doc(&s).swatch("Red").and_then(|w| w.paint.color()).unwrap(); assert_eq!(mine.appearance.stroke_paint(), Paint::Solid { color: red, swatch: Some("Red".into()), tint: 1.0 }); assert_eq!(dotted.appearance.stroke_paint(), Paint::solid(red), "unlinked from the swatch"); assert_eq!(lib.patterns.len(), 1); let list = run(&mut s, "graphicStyle.libraries", json!({})); assert!(list["libraries"].as_array().unwrap().iter().any(|l| l["id"] == lib_id.as_str() && l["category"] == "loaded")); // Into another document: the pattern comes along (with its swatch), once. run(&mut s, "file.new", json!({"width": 100, "height": 100})); run(&mut s, "graphicStyle.addFromLibrary", json!({"library": lib_id, "name": "Dotted"})); let d = doc(&s); assert_eq!(d.pattern("Dots"), lib.patterns.first()); assert!(d.swatch("Dots").is_some()); assert_eq!(d.graphic_style("Dotted").unwrap().appearance.fill_paint(), pattern("Dots")); // A document whose "Dots" is another pattern gets the library's under a free name. run(&mut s, "file.new", json!({"width": 100, "height": 100})); add_pattern(&mut s, "Dots", 8.0); run(&mut s, "graphicStyle.addFromLibrary", json!({"library": lib_id})); let d = doc(&s); assert_eq!(d.graphic_style("Dotted").unwrap().appearance.fill_paint(), pattern("Dots 2")); assert_eq!(d.patterns.len(), 2); assert!(s.execute("graphicStyle.saveLibrary", &json!({"names": ["Nope"]})).is_err()); assert!(s.execute("graphicStyle.saveLibrary", &json!({"user": true})).is_err(), "no user folder in a headless session"); } #[test] fn user_libraries_and_other_documents_styles() { let dir = std::env::temp_dir().join(format!("vc-stylelib-user-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let mut s = session(); s.style_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); let r = run(&mut s, "graphicStyle.saveLibrary", json!({"user": true, "name": "My: Looks"})); assert_eq!(r["library"], "user/My- Looks.vcstyles"); let path = r["path"].as_str().unwrap().to_string(); let list = run(&mut s, "graphicStyle.libraries", json!({})); let user: Vec<&Value> = list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "user").collect(); assert_eq!(user.len(), 1); assert_eq!((user[0]["name"].as_str(), user[0]["count"].as_u64()), (Some("My: Looks"), Some(doc(&s).graphic_styles.len() as u64))); assert_eq!(list["userFolder"].as_str(), Some(dir.to_string_lossy().as_ref())); // Opening a file of the user folder gives its User Defined library. assert_eq!(run(&mut s, "graphicStyle.loadLibrary", json!({ "path": path }))["library"], "user/My- Looks.vcstyles"); let _ = std::fs::remove_dir_all(dir); // Another document's graphic styles load as a library. let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0); run(&mut s, "graphicStyle.new", json!({"name": "Poster Look", "id": id.0})); let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string(); let r = run(&mut s, "graphicStyle.loadLibrary", json!({"dataBase64": b64, "name": "Poster.vectorcraft"})); assert_eq!(r["name"], "Poster"); let (_, lib) = cmd::stylelib::library(&s, r["library"].as_str().unwrap()).unwrap(); assert!(lib.style("Poster Look").is_some_and(|g: &GraphicStyle| g.id == 0)); assert!(s.execute("graphicStyle.loadLibrary", &json!({"data": "hello", "name": "x.txt"})).is_err()); } #[test] fn every_builtin_style_paints_visibly_on_a_rectangle() { let mut s = session(); let mut renderer = vectorcraft_render::Renderer::new(); for b in vectorcraft_doc::style_libs::STYLE_LIBRARIES { let (_, lib) = cmd::stylelib::library(&s, b.id).unwrap(); for g in &lib.styles { run(&mut s, "file.new", json!({"width": 100, "height": 100})); let id = rect(&mut s, 25.0, 30.0, 50.0, 40.0); run(&mut s, "graphicStyle.addFromLibrary", json!({"library": b.id, "name": g.name, "apply": true})); assert_eq!(doc(&s).node(id).unwrap().graphic_style, doc(&s).graphic_style(&g.name).map(|x| x.id), "{}", g.name); let img = renderer.render_region(doc(&s), Rect::new(0.0, 0.0, 100.0, 100.0), 0.5, true); let painted = img.pixels.as_chunks::<4>().0.iter().filter(|p| p[..3] != [255, 255, 255]).count(); assert!(painted > 40, "{} / {} paints {painted} pixels", b.id, g.name); } } }