//! Pattern swatches, pattern editing mode and Repeat, driven through `Session::execute`. use serde_json::{Value, json}; use vectorcraft_color::Paint; use vectorcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; use super::*; fn session() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); s } fn id_of(v: &Value) -> NodeId { NodeId(v["id"].as_u64().unwrap()) } fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) } fn node(s: &Session, id: NodeId) -> Node { s.doc().unwrap().doc.node(id).unwrap().clone() } fn sel(s: &mut Session, ids: &[NodeId]) { s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); } fn doc(s: &Session) -> &vectorcraft_doc::Document { &s.doc().unwrap().doc } /// A red 10×10 square made into pattern "Dots" (editing finished). fn make_dots(s: &mut Session) -> NodeId { let r = rect(s, 0.0, 0.0, 10.0, 10.0); s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#ff0000"})).unwrap(); s.execute("paint.setStroke", &json!({"ids": [r.0], "none": true})).unwrap(); sel(s, &[r]); s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); s.execute("object.pattern.done", &json!({})).unwrap(); r } fn close(a: Point, b: Point) -> bool { (a - b).hypot() < 1e-6 } // ---------- patterns ---------- #[test] fn make_creates_definition_swatch_and_edit_mode() { let mut s = session(); let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); sel(&mut s, &[r]); let v = s.execute("object.pattern.make", &json!({})).unwrap(); assert_eq!(v["name"], "New Pattern 1"); let d = doc(&s); let def = d.pattern("New Pattern 1").unwrap(); assert_eq!(def.tile, Rect::new(0.0, 0.0, 10.0, 10.0)); assert!(d.swatch("New Pattern 1").is_some_and(|sw| matches!(sw.paint, Paint::Pattern { .. }))); let pe = d.pattern_edit.as_ref().unwrap(); assert_eq!(d.node(pe.layer).unwrap().name.as_deref(), Some(PATTERN_EDIT_LAYER)); assert_eq!(d.node(pe.layer).unwrap().children().unwrap().len(), 1); // The original art stays in the document; new art goes into the edit layer. assert!(d.node(r).is_some()); assert_eq!(s.doc().unwrap().insertion_parent(), Some(pe.layer)); } #[test] fn done_saves_edited_art_and_leaves_mode() { let mut s = session(); let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); sel(&mut s, &[r]); s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); // Draw another shape inside the pattern. rect(&mut s, 2.0, 2.0, 3.0, 3.0); s.execute("object.pattern.done", &json!({})).unwrap(); let d = doc(&s); assert!(d.pattern_edit.is_none()); assert_eq!(d.pattern("P").unwrap().art.len(), 2); assert!(d.layers.iter().all(|l| l.name.as_deref() != Some(PATTERN_EDIT_LAYER))); assert!(s.doc().unwrap().isolation.is_none()); } #[test] fn cancel_new_pattern_removes_it() { let mut s = session(); let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); sel(&mut s, &[r]); s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); s.execute("object.pattern.cancel", &json!({})).unwrap(); assert!(doc(&s).pattern("P").is_none()); assert!(doc(&s).swatch("P").is_none()); assert_eq!(doc(&s).layers.len(), 1); } #[test] fn edit_then_cancel_restores_definition() { let mut s = session(); make_dots(&mut s); s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap(); s.execute("pattern.options", &json!({"tileType": "hexByRow", "width": 40})).unwrap(); assert_eq!(doc(&s).pattern("Dots").unwrap().tile_type, TileType::HexByRow); s.execute("object.pattern.cancel", &json!({})).unwrap(); let def = doc(&s).pattern("Dots").unwrap(); assert_eq!(def.tile_type, TileType::Grid); assert_eq!(def.tile.width(), 20.0); } #[test] fn edit_defaults_to_selected_objects_pattern() { let mut s = session(); make_dots(&mut s); let big = rect(&mut s, 100.0, 100.0, 100.0, 100.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); sel(&mut s, &[big]); let v = s.execute("object.pattern.edit", &json!({})).unwrap(); assert_eq!(v["name"], "Dots"); assert!(doc(&s).pattern_edit.is_some()); // Edit again while editing is refused. assert!(s.execute("object.pattern.edit", &json!({"name": "Dots"})).is_err()); } #[test] fn set_fill_with_pattern_swatch() { let mut s = session(); make_dots(&mut s); let big = rect(&mut s, 100.0, 100.0, 100.0, 100.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); assert!(matches!(node(&s, big).appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Dots")); } #[test] fn set_fill_with_pattern_name_without_swatch() { let mut s = session(); make_dots(&mut s); Arc::make_mut(&mut s.doc_mut().unwrap().doc).swatches.retain(|sw| sw.name != "Dots"); let big = rect(&mut s, 100.0, 100.0, 100.0, 100.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); assert!(matches!(node(&s, big).appearance.fill_paint(), Paint::Pattern { .. })); } #[test] fn options_set_tile_type_spacing_and_overlap() { let mut s = session(); make_dots(&mut s); s.execute( "pattern.options", &json!({"name": "Dots", "tileType": "brickByColumn", "brickOffset": 0.25, "overlap": {"h": "right", "v": "bottom"}, "copies": 7, "dimCopies": 40}), ) .unwrap(); let def = doc(&s).pattern("Dots").unwrap(); assert_eq!(def.tile_type, TileType::BrickByColumn { offset: 0.25 }); assert!(def.overlap.right_in_front && def.overlap.bottom_in_front); assert_eq!(def.copies, 7); assert_eq!(def.dim_copies, 40.0); // Size tile to art with spacing. s.execute("pattern.options", &json!({"name": "Dots", "sizeTileToArt": true, "hSpacing": 4, "vSpacing": 6})).unwrap(); let def = doc(&s).pattern("Dots").unwrap(); assert!((def.tile.width() - 14.0).abs() < 1e-6 && (def.tile.height() - 16.0).abs() < 1e-6, "{:?}", def.tile); assert!(s.execute("pattern.options", &json!({"name": "Dots", "tileType": "zigzag"})).is_err()); assert!(s.execute("pattern.options", &json!({"name": "Dots", "width": -1})).is_err()); } #[test] fn options_rename_updates_swatch_and_paints() { let mut s = session(); make_dots(&mut s); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); s.execute("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap(); assert!(doc(&s).pattern("Spots").is_some() && doc(&s).pattern("Dots").is_none()); assert!(doc(&s).swatch("Spots").is_some()); assert!(matches!(node(&s, big).appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Spots")); } #[test] fn delete_pattern_unpaints_objects() { let mut s = session(); make_dots(&mut s); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let v = s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap(); assert_eq!(v["unpainted"], 1); assert!(doc(&s).pattern("Dots").is_none() && doc(&s).swatch("Dots").is_none()); assert!(node(&s, big).appearance.fill_paint().is_none()); assert!(s.execute("pattern.delete", &json!({"name": "Dots"})).is_err()); } #[test] fn transform_patterns_only_moves_placement() { let mut s = session(); make_dots(&mut s); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let before = node(&s, big).geometric_bounds(); s.execute("pattern.transform", &json!({"ids": [big.0], "matrix": [2, 0, 0, 2, 5, 0]})).unwrap(); let n = node(&s, big); assert_eq!(n.geometric_bounds(), before); let Paint::Pattern { xf, .. } = n.appearance.fill_paint() else { panic!() }; assert_eq!(xf.as_coeffs(), [2.0, 0.0, 0.0, 2.0, 5.0, 0.0]); } /// #1448, #1460: with Transform Objects off, the transform commands leave the art where it is and /// move only its pattern tiles (Transform Patterns on), or nothing at all; Transform Again repeats /// that. #[test] fn transforms_without_transform_objects_move_only_the_pattern() { let mut s = session(); make_dots(&mut s); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); s.execute("select.set", &json!({"ids": [big.0]})).unwrap(); let before = node(&s, big).geometric_bounds(); let tiles = |s: &Session| { let n = node(s, big); let Paint::Pattern { xf, .. } = n.appearance.fill_paint() else { panic!() }; xf.as_coeffs() }; for (cmd, p, want) in [ ("object.move", json!({"dx": 5, "dy": 0}), [1.0, 0.0, 0.0, 1.0, 5.0, 0.0]), ("object.transformAgain", json!({}), [1.0, 0.0, 0.0, 1.0, 10.0, 0.0]), ("object.reflect", json!({"axis": "vertical", "origin": [0, 0]}), [-1.0, 0.0, 0.0, 1.0, -10.0, 0.0]), ("object.transformEach", json!({"moveH": 10, "reference": 0}), [-1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), ] { // Transform Again repeats the move as it was made, options included. if cmd != "object.transformAgain" { let mut off = p.clone(); off["objects"] = json!(false); off["patterns"] = json!(false); s.execute(cmd, &off).unwrap(); assert_eq!(node(&s, big).geometric_bounds(), before, "{cmd}: neither transforms"); off["patterns"] = json!(true); s.execute(cmd, &off).unwrap(); } else { s.execute(cmd, &p).unwrap(); } assert_eq!(node(&s, big).geometric_bounds(), before, "{cmd}"); assert_eq!(tiles(&s), want, "{cmd}"); } } #[test] fn list_reports_patterns_and_edit_state() { let mut s = session(); make_dots(&mut s); let v = s.execute("pattern.list", &json!({})).unwrap(); assert_eq!(v["patterns"][0]["name"], "Dots"); assert_eq!(v["patterns"][0]["tileType"], "grid"); assert_eq!(v["patterns"][0]["width"], 20.0); assert!(v["editing"].is_null()); s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap(); assert_eq!(s.execute("pattern.list", &json!({})).unwrap()["editing"]["name"], "Dots"); } #[test] fn save_a_copy_while_editing() { let mut s = session(); make_dots(&mut s); s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap(); let v = s.execute("object.pattern.saveCopy", &json!({})).unwrap(); assert_eq!(v["name"], "Dots copy"); assert!(doc(&s).pattern("Dots copy").is_some()); } #[test] fn undo_make_pattern() { let mut s = session(); let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); sel(&mut s, &[r]); s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); assert!(doc(&s).pattern("P").is_none() && doc(&s).pattern_edit.is_none()); } #[test] fn hit_testing_in_edit_mode_only_reaches_tile_layer() { let mut s = session(); let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); let other = rect(&mut s, 200.0, 200.0, 50.0, 50.0); sel(&mut s, &[r]); s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); let d = doc(&s); let opt = vectorcraft_doc::hit::HitOptions::default(); assert!(vectorcraft_doc::hit::hit_test(d, Point::new(220.0, 220.0), opt).is_none()); let h = vectorcraft_doc::hit::hit_test(d, Point::new(5.0, 5.0), opt).unwrap(); assert_ne!(h.leaf, r); let _ = other; } #[test] fn save_load_roundtrip_keeps_patterns_and_repeats() { let mut s = session(); make_dots(&mut s); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let r = rect(&mut s, 300.0, 50.0, 10.0, 10.0); sel(&mut s, &[r]); s.execute("object.repeat.radial", &json!({"instances": 6})).unwrap(); let bytes = vectorcraft_format::save(doc(&s), false); let back = vectorcraft_format::load(&bytes).unwrap(); assert_eq!(back.patterns, doc(&s).patterns); assert_eq!(back.node(big).unwrap().appearance.fill_paint(), node(&s, big).appearance.fill_paint()); assert!(back.layers[0].children().unwrap().iter().any(|n| matches!(n.kind, NodeKind::Repeat(_)))); } #[test] fn svg_export_writes_pattern_def() { let mut s = session(); make_dots(&mut s); let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); let svg = vectorcraft_svg::export(doc(&s), &vectorcraft_svg::ExportOptions::default()); assert!(svg.contains("").unwrap(); assert!(svg[start..end].contains(" 500); } // ---------- repeat ---------- #[test] fn radial_repeat_wraps_selection() { let mut s = session(); let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0); sel(&mut s, &[r]); let v = s.execute("object.repeat.radial", &json!({"instances": 8, "radius": 50})).unwrap(); let n = node(&s, id_of(&v)); let NodeKind::Repeat(spec) = &n.kind else { panic!("{:?}", n.kind) }; assert_eq!(spec.source.len(), 1); let RepeatKind::Radial { instances, radius, center, .. } = spec.kind else { panic!() }; assert_eq!((instances, radius), (8, 50.0)); assert!(close(center, Point::new(200.0, 150.0))); assert_eq!(spec.transforms().len(), 8); assert_eq!(n.kind_label(), "Radial Repeat"); } #[test] fn radial_instance_transforms_rotate_around_center() { let mut s = session(); let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0); sel(&mut s, &[r]); let v = s.execute("object.repeat.radial", &json!({"instances": 4, "radius": 50})).unwrap(); let NodeKind::Repeat(spec) = node(&s, id_of(&v)).kind else { panic!() }; let t = spec.transforms(); let c = Point::new(200.0, 100.0); assert!(close(t[0] * c, Point::new(200.0, 100.0))); assert!(close(t[1] * c, Point::new(250.0, 150.0))); assert!(close(t[2] * c, Point::new(200.0, 200.0))); assert!(close(t[3] * c, Point::new(150.0, 150.0))); } #[test] fn expand_yields_n_copies() { let mut s = session(); let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0); sel(&mut s, &[r]); let v = s.execute("object.repeat.radial", &json!({"instances": 5})).unwrap(); let id = id_of(&v); s.execute("object.repeat.expand", &json!({})).unwrap(); let g = node(&s, id); assert!(matches!(g.kind, NodeKind::Group { .. })); assert_eq!(g.children().unwrap().len(), 5); // Every generated node has a real, unique id. let mut ids = vec![]; g.walk(&mut |n| ids.push(n.id)); assert!(ids.iter().all(|i| i.0 != 0)); let mut u = ids.clone(); u.sort(); u.dedup(); assert_eq!(u.len(), ids.len()); } /// #1756: Object → Expand turns radial, grid and mirror repeats into groups of their instances, in /// one undo step, as Expand Repeat does. #[test] fn object_expand_expands_repeats() { for (cmd, opts) in [("object.repeat.radial", json!({"instances": 5})), ("object.repeat.grid", json!({})), ("object.repeat.mirror", json!({}))] { let mut s = session(); let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0); sel(&mut s, &[r]); let id = id_of(&s.execute(cmd, &opts).unwrap()); let NodeKind::Repeat(spec) = node(&s, id).kind else { panic!("{cmd}") }; let n = spec.expand().len(); assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(true), "{cmd}"); let undo = s.doc().unwrap().history.undo.len(); s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap(); let g = node(&s, id); assert!(matches!(g.kind, NodeKind::Group { .. }), "{cmd}"); assert_eq!(g.children().unwrap().len(), n, "{cmd}"); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{cmd}"); assert_eq!(s.doc().unwrap().selection.objects, vec![id], "{cmd}"); // Nothing left to expand: the instances are plain rectangles. assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(false), "{cmd}"); } } /// Expand reaches repeats inside a selected group, and with every option on also outlines the /// instances' strokes. #[test] fn object_expand_expands_repeats_in_groups() { let mut s = session(); let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0); sel(&mut s, &[r]); let rep = id_of(&s.execute("object.repeat.radial", &json!({"instances": 4})).unwrap()); let other = rect(&mut s, 0.0, 0.0, 10.0, 10.0); sel(&mut s, &[rep, other]); s.execute("object.group", &json!({})).unwrap(); s.execute("object.expand", &json!({})).unwrap(); let mut repeats = 0; for l in &doc(&s).layers { l.walk(&mut |c| repeats += usize::from(matches!(c.kind, NodeKind::Repeat(_)))); } assert_eq!(repeats, 0); let g = node(&s, rep); assert_eq!(g.children().unwrap().len(), 4); assert!(g.children().unwrap().iter().all(|c| c.appearance.stroke_paint().is_none())); } #[test] fn grid_repeat_and_options() { let mut s = session(); let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); sel(&mut s, &[r]); let v = s.execute("object.repeat.grid", &json!({"hSpacing": 5, "vSpacing": 5})).unwrap(); let id = id_of(&v); assert_eq!(node(&s, id).geometric_bounds(), Some(Rect::new(0.0, 0.0, 40.0, 40.0))); s.execute("object.repeat.options", &json!({"rows": 2, "cols": 4, "flipCols": true, "gridType": "brickByRow"})).unwrap(); let NodeKind::Repeat(spec) = node(&s, id).kind else { panic!() }; let RepeatKind::Grid { rows, cols, flip_cols, grid_type, .. } = spec.kind else { panic!() }; assert_eq!((rows, cols, flip_cols), (2, 4, true)); assert_eq!(grid_type, TileType::BrickByRow { offset: 0.5 }); assert_eq!(spec.expand().len(), 8); } #[test] fn mirror_repeat_reflects() { let mut s = session(); let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); sel(&mut s, &[r]); let v = s.execute("object.repeat.mirror", &json!({"offset": 5})).unwrap(); let n = node(&s, id_of(&v)); let b = n.geometric_bounds().unwrap(); assert!((b.x1 - 30.0).abs() < 1e-9 && b.x0.abs() < 1e-9, "{b:?}"); s.execute("object.repeat.options", &json!({"angle": 0})).unwrap(); let b = node(&s, id_of(&v)).geometric_bounds().unwrap(); assert!((b.width() - 10.0).abs() < 1e-9, "{b:?}"); } #[test] fn switching_repeat_kind_keeps_source() { let mut s = session(); let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); sel(&mut s, &[r]); let v = s.execute("object.repeat.radial", &json!({})).unwrap(); let id = id_of(&v); let v2 = s.execute("object.repeat.grid", &json!({})).unwrap(); assert_eq!(id_of(&v2), id); let NodeKind::Repeat(spec) = node(&s, id).kind else { panic!() }; assert!(matches!(spec.kind, RepeatKind::Grid { .. })); assert_eq!(spec.source[0].id, r); } #[test] fn release_restores_source() { let mut s = session(); let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); sel(&mut s, &[a, b]); s.execute("object.repeat.mirror", &json!({})).unwrap(); assert!(doc(&s).layers[0].children().unwrap().len() == 1); let v = s.execute("object.repeat.release", &json!({})).unwrap(); assert_eq!(v["ids"], json!([a.0, b.0])); assert_eq!(node(&s, a).geometric_bounds(), Some(Rect::new(0.0, 0.0, 10.0, 10.0))); assert!(s.execute("object.repeat.release", &json!({})).is_err()); } #[test] fn repeat_moves_with_transform() { let mut s = session(); let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0); sel(&mut s, &[r]); let v = s.execute("object.repeat.radial", &json!({"instances": 4, "radius": 50})).unwrap(); let id = id_of(&v); let before = node(&s, id).geometric_bounds().unwrap(); s.execute("object.transform", &json!({"ids": [id.0], "matrix": [1, 0, 0, 1, 10, 20]})).unwrap(); let after = node(&s, id).geometric_bounds().unwrap(); assert!(after.x0 > before.x0 && after.y0 > before.y0, "{before:?} {after:?}"); } #[test] fn repeat_hit_test_uses_instances() { let mut s = session(); let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0); sel(&mut s, &[r]); s.execute("object.repeat.radial", &json!({"instances": 4, "radius": 50})).unwrap(); let d = doc(&s); let opt = vectorcraft_doc::hit::HitOptions::default(); assert!(vectorcraft_doc::hit::hit_test(d, Point::new(250.0, 150.0), opt).is_some()); // The empty centre of the ring is not the repeat. assert!(vectorcraft_doc::hit::hit_test(d, Point::new(200.0, 150.0), opt).is_none()); } #[test] fn repeat_commands_validate() { let mut s = session(); let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); sel(&mut s, &[r]); assert!(s.execute("object.repeat.radial", &json!({"instances": 0})).is_err()); assert!(s.execute("object.repeat.options", &json!({"instances": 4})).is_err(), "no repeat selected"); s.execute("select.none", &json!({})).ok(); assert!(s.execute("object.pattern.make", &json!({})).is_err()); }