//! Tests for Shape Builder, Live Paint and Image Trace commands and tools. use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind}; use vectorcraft_geom::{Affine, FillRule, PathData, Rect}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; use super::*; fn session() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); s } fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); NodeId(r["id"].as_u64().unwrap()) } fn set_fill(s: &mut Session, id: NodeId, c: Color) { s.edit("t", |d, _| { d.node_mut(id).unwrap().appearance.set_fill(Paint::solid(c)); Ok(()) }) .unwrap(); } fn ids(v: &Value) -> Vec { v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect() } fn node(s: &Session, id: NodeId) -> Node { s.doc().unwrap().doc.node(id).cloned().unwrap() } fn outline(n: &Node) -> (PathData, FillRule) { vectorcraft_tools::builder::node_outline(n).expect("path") } fn area(s: &Session, id: NodeId) -> f64 { let (p, r) = outline(&node(s, id)); vectorcraft_pathops::area(&p, r) } /// Rect A (0..100) and rect B (50..150), both 100 tall, selected. A red, B blue. fn two(s: &mut Session) -> (NodeId, NodeId) { let a = rect(s, 0.0, 0.0, 100.0, 100.0); let b = rect(s, 50.0, 0.0, 100.0, 100.0); set_fill(s, a, Color::rgb(1.0, 0.0, 0.0)); set_fill(s, b, Color::rgb(0.0, 0.0, 1.0)); s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); (a, b) } fn layer_children(s: &Session) -> usize { let d = &s.doc().unwrap().doc; d.children(d.default_layer()).map(|c| c.len()).unwrap_or(0) } // ---------- Shape Builder ---------- #[test] fn shape_builder_drag_merges_touched_regions() { let mut s = session(); two(&mut s); let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap(); assert_eq!(r["regions"], 2); let out = ids(&r); assert_eq!(out.len(), 2, "merged shape + remaining part of B"); let merged = NodeId(r["merged"].as_u64().unwrap()); assert!((area(&s, merged) - 10000.0).abs() < 1.0, "{}", area(&s, merged)); let rest = out.iter().copied().find(|i| *i != merged).unwrap(); assert!((area(&s, rest) - 5000.0).abs() < 1.0); // Merged shape takes the fill of the object under the drag start (A, red). assert_eq!(node(&s, merged).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); assert_eq!(layer_children(&s), 2); } #[test] fn shape_builder_click_splits_out_one_region() { let mut s = session(); two(&mut s); let r = s.execute("shapeBuilder.merge", &json!({"points": [[75, 50]]})).unwrap(); let out = ids(&r); assert_eq!(out.len(), 3); let total: f64 = out.iter().map(|i| area(&s, *i)).sum(); assert!((total - 15000.0).abs() < 1.0, "{total}"); let merged = NodeId(r["merged"].as_u64().unwrap()); // Overlap is covered by B on top → blue. assert_eq!(node(&s, merged).appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0))); let bb = node(&s, merged).geometric_bounds().unwrap(); assert!((bb.x0 - 50.0).abs() < 1e-6 && (bb.x1 - 100.0).abs() < 1e-6); } #[test] fn shape_builder_erase_deletes_regions() { let mut s = session(); two(&mut s); let r = s.execute("shapeBuilder.merge", &json!({"points": [[75, 50]], "erase": true})).unwrap(); assert!(r["merged"].is_null()); let out = ids(&r); assert_eq!(out.len(), 2); for i in out { assert!((area(&s, i) - 5000.0).abs() < 1.0); } } #[test] fn shape_builder_fill_option_and_untouched_shapes_kept() { let mut s = session(); let (a, b) = two(&mut s); let c = rect(&mut s, 300.0, 300.0, 50.0, 50.0); let r = s.execute("shapeBuilder.merge", &json!({"ids": [a.0, b.0, c.0], "points": [[25, 50], [125, 50]], "fill": "#00ff00"})).unwrap(); let out = ids(&r); assert_eq!(out.len(), 2, "one merged + the untouched square"); let merged = NodeId(r["merged"].as_u64().unwrap()); assert!((area(&s, merged) - 15000.0).abs() < 1.0); assert_eq!(node(&s, merged).appearance.fill_paint(), Paint::solid(Color::from_hex("#00ff00").unwrap())); let sq = out.iter().copied().find(|i| *i != merged).unwrap(); assert_eq!(node(&s, sq).geometric_bounds().unwrap(), Rect::new(300.0, 300.0, 350.0, 350.0)); assert_eq!(outline(&node(&s, sq)).0.anchor_count(), 4); } #[test] fn shape_builder_miss_is_an_error_and_undo_restores() { let mut s = session(); let (a, b) = two(&mut s); let undo_before = s.doc().unwrap().history.undo.len(); assert!(s.execute("shapeBuilder.merge", &json!({"points": [[500, 500]]})).is_err()); assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); assert!(s.doc().unwrap().doc.node(a).is_some() && s.doc().unwrap().doc.node(b).is_some()); assert!(s.execute("shapeBuilder.merge", &json!({"points": []})).is_err()); assert!(s.execute("shapeBuilder.merge", &json!({})).is_err()); } #[test] fn shape_builder_regions_query() { let mut s = session(); two(&mut s); let r = s.execute("shapeBuilder.regions", &json!({})).unwrap(); let regs = r["regions"].as_array().unwrap(); assert_eq!(regs.len(), 3); let total: f64 = regs.iter().map(|x| x["area"].as_f64().unwrap()).sum(); assert!((total - 15000.0).abs() < 1.0); assert!(regs.iter().any(|x| x["sources"].as_array().unwrap().len() == 2)); } #[test] fn shape_builder_tool_drives_the_command() { let mut s = session(); two(&mut s); let v = ViewInfo::default(); s.select_tool("shapeBuilder", v).unwrap(); assert_eq!(s.tool_id(), "shapeBuilder"); s.pointer(&PointerEvent::new(PointerKind::Move, 75.0, 50.0), v).unwrap(); assert!(!s.overlays(v).is_empty(), "hover highlight"); s.pointer(&PointerEvent::new(PointerKind::Down, 25.0, 50.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, 75.0, 50.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Up, 75.0, 50.0), v).unwrap(); assert_eq!(layer_children(&s), 2); assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge"); // Alt-click deletes a region. let alt = Mods { alt: true, ..Default::default() }; s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 50.0).with_mods(alt), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0).with_mods(alt), v).unwrap(); assert_eq!(layer_children(&s), 1); } // ---------- Live Paint ---------- fn live(s: &mut Session) -> NodeId { two(s); let r = s.execute("livePaint.make", &json!({})).unwrap(); assert_eq!(r["faces"], 3); assert!(r["edges"].as_u64().unwrap() >= 4); NodeId(r["id"].as_u64().unwrap()) } #[test] fn live_paint_make_builds_group_with_hidden_sources() { let mut s = session(); let g = live(&mut s); let n = node(&s, g); assert!(vectorcraft_tools::builder::is_live_paint(&n)); let src = vectorcraft_tools::builder::sources(&n).unwrap(); assert!(!src.visible); assert_eq!(src.children().unwrap().len(), 2); assert_eq!(layer_children(&s), 1); assert_eq!(s.doc().unwrap().selection.objects, vec![g]); // Faces start with the fill of the top-most covering object. let info = s.execute("livePaint.info", &json!({"group": g.0})).unwrap(); assert_eq!(info["faces"].as_array().unwrap().len(), 3); assert_eq!(info["sources"], 2); } #[test] fn live_paint_fill_assigns_the_face_under_the_point() { let mut s = session(); let g = live(&mut s); let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [75, 50], "color": "#00ff00"})).unwrap(); let face = node(&s, NodeId(r["face"].as_u64().unwrap())); let bb = face.geometric_bounds().unwrap(); assert!((bb.x0 - 50.0).abs() < 1e-6 && (bb.x1 - 100.0).abs() < 1e-6, "{bb:?}"); assert_eq!(face.appearance.fill_paint(), Paint::solid(Color::from_hex("#00ff00").unwrap())); // The other faces are untouched. let gn = node(&s, g); let green = vectorcraft_tools::builder::faces(&gn).iter().filter(|f| f.appearance.fill_paint() == face.appearance.fill_paint()).count(); assert_eq!(green, 1); // Default paint = current fill; a miss is an error. s.paint.fill = Paint::solid(Color::rgb(1.0, 1.0, 0.0)); let r = s.execute("livePaint.fill", &json!({"group": g.0, "point": [10, 10]})).unwrap(); assert_eq!(node(&s, NodeId(r["face"].as_u64().unwrap())).appearance.fill_paint(), s.paint.fill); assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [400, 400]})).is_err()); } #[test] fn live_paint_fill_on_plain_paths_makes_the_group() { let mut s = session(); let (a, b) = two(&mut s); let r = s.execute("livePaint.fill", &json!({"ids": [a.0, b.0], "point": [125, 50], "color": "#123456"})).unwrap(); let g = NodeId(r["group"].as_u64().unwrap()); assert!(vectorcraft_tools::builder::is_live_paint(&node(&s, g))); assert_eq!(layer_children(&s), 1); // One undo step removes both the make and the fill. s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(layer_children(&s), 2); } #[test] fn live_paint_stroke_edge() { let mut s = session(); let g = live(&mut s); let r = s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100, 50], "color": "#ff0000", "width": 3})).unwrap(); let e = node(&s, NodeId(r["edge"].as_u64().unwrap())); assert_eq!(e.appearance.stroke_width(), 3.0); assert!(e.appearance.fill_paint().is_none()); let bb = e.geometric_bounds().unwrap(); assert!((bb.x0 - 100.0).abs() < 1e-6 && (bb.x1 - 100.0).abs() < 1e-6, "the vertical edge at x=100: {bb:?}"); assert!(s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [300, 300]})).is_err()); } #[test] fn live_paint_release_and_expand() { let mut s = session(); let g = live(&mut s); s.execute("livePaint.fill", &json!({"group": g.0, "point": [75, 50], "none": true})).unwrap(); let r = s.execute("livePaint.expand", &json!({})).unwrap(); assert_eq!(ids(&r), vec![g]); let n = node(&s, g); assert!(!vectorcraft_tools::builder::is_live_paint(&n)); // Unpainted face and the hidden sources are gone: 2 faces + stroked edges. let ch = n.children().unwrap(); assert!(ch.iter().all(|c| c.visible)); assert_eq!(ch.iter().filter(|c| c.name.as_deref() == Some("Face")).count(), 2); let mut s = session(); let (a, b) = two(&mut s); live_group_only(&mut s); let r = s.execute("livePaint.release", &json!({})).unwrap(); assert_eq!(ids(&r), vec![a, b]); assert!((area(&s, a) - 10000.0).abs() < 1.0); } fn live_group_only(s: &mut Session) -> NodeId { let r = s.execute("livePaint.make", &json!({})).unwrap(); NodeId(r["id"].as_u64().unwrap()) } #[test] fn live_paint_merge_keeps_paint_and_adds_faces() { let mut s = session(); let g = live(&mut s); s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "color": "#00ff00"})).unwrap(); let c = rect(&mut s, 120.0, 20.0, 100.0, 20.0); s.execute("select.set", &json!({"ids": [g.0, c.0]})).unwrap(); let r = s.execute("livePaint.merge", &json!({})).unwrap(); assert_eq!(NodeId(r["id"].as_u64().unwrap()), g); assert!(r["faces"].as_u64().unwrap() > 3); let gn = node(&s, g); let f = vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(25.0, 50.0)).unwrap(); assert_eq!(f.appearance.fill_paint(), Paint::solid(Color::from_hex("#00ff00").unwrap())); let src = vectorcraft_tools::builder::sources(&gn).unwrap(); assert_eq!(src.children().unwrap().len(), 3, "the rectangle joined the sources"); assert_eq!(s.doc().unwrap().doc.parent_of(c), Some(src.id)); assert_eq!(layer_children(&s), 1); } #[test] fn live_paint_bucket_tool_fills_face() { let mut s = session(); let g = live(&mut s); let v = ViewInfo::default(); s.select_tool("livePaintBucket", v).unwrap(); s.paint.fill = Paint::solid(Color::rgb(0.0, 1.0, 1.0)); s.pointer(&PointerEvent::new(PointerKind::Move, 125.0, 50.0), v).unwrap(); assert!(!s.overlays(v).is_empty(), "red face highlight"); s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 50.0), v).unwrap(); let gn = node(&s, g); let f = vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(125.0, 50.0)).unwrap(); assert_eq!(f.appearance.fill_paint(), s.paint.fill); // Live Paint Selection selects the face. s.select_tool("livePaintSelection", v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 50.0), v).unwrap(); assert_eq!(s.doc().unwrap().selection.objects, vec![f.id]); } // ---------- Image Trace ---------- /// A 100×100 image with a black disc (r = 30 px), placed at (50, 50) scaled ×2. fn image_doc(s: &mut Session) -> NodeId { let r = vectorcraft_trace::Raster::from_fn(100, 100, |x, y| { let (dx, dy) = (x as f64 + 0.5 - 50.0, y as f64 + 0.5 - 50.0); if dx * dx + dy * dy <= 900.0 { [0, 0, 0, 255] } else { [255, 255, 255, 255] } }); add_image(s, &r, Affine::translate((50.0, 50.0)) * Affine::scale(2.0)) } fn add_image(s: &mut Session, r: &vectorcraft_trace::Raster, xf: Affine) -> NodeId { let png = r.encode_png(); let (w, h) = (r.width, r.height); s.edit("img", |d, sel| { d.images.insert("img1".into(), ImageBlob::new("image/png", png)); let id = d.alloc_id(); let l = d.default_layer(); d.insert( l, 0, Node::new(id, NodeKind::Image(ImageObject { key: "img1".into(), width: w, height: h, xf, link: None, placement: Default::default() })), )?; sel.set([id]); Ok(id) }) .unwrap() } fn traced_paths(n: &Node) -> Vec> { n.children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).cloned().collect() } #[test] fn image_trace_make_places_paths_over_the_image() { let mut s = session(); let img = image_doc(&mut s); let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap(); assert_eq!(r["paths"], 1); let g = NodeId(r["id"].as_u64().unwrap()); let n = node(&s, g); assert_eq!(n.name.as_deref(), Some("Image Trace")); let first = &n.children().unwrap()[0]; assert!(matches!(first.kind, NodeKind::Image(_)) && !first.visible && first.id == img); let paths = traced_paths(&n); assert_eq!(paths.len(), 1); let (p, rule) = outline(&paths[0]); let want = std::f64::consts::PI * 60.0 * 60.0; // r = 30 px × 2 let got = vectorcraft_pathops::area(&p, rule); assert!((got - want).abs() / want < 0.03, "{got} vs {want}"); let bb = p.bounds().unwrap(); assert!((bb.center().x - 150.0).abs() < 1.5 && (bb.center().y - 150.0).abs() < 1.5, "{bb:?}"); assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0))); } #[test] fn image_trace_release_and_expand() { let mut s = session(); let img = image_doc(&mut s); s.execute("imageTrace.make", &json!({"preset": "Black and White Logo"})).unwrap(); let r = s.execute("imageTrace.release", &json!({})).unwrap(); assert_eq!(ids(&r), vec![img]); assert!(node(&s, img).visible); assert_eq!(layer_children(&s), 1); let r = s.execute("imageTrace.make", &json!({"id": img.0})).unwrap(); let g = NodeId(r["id"].as_u64().unwrap()); // Re-trace an existing Image Trace group with other parameters. let r2 = s.execute("imageTrace.make", &json!({"id": g.0, "params": {"ignoreWhite": true}})).unwrap(); assert_eq!(r2["paths"], 1); let g = NodeId(r2["id"].as_u64().unwrap()); s.execute("imageTrace.expand", &json!({})).unwrap(); let n = node(&s, g); assert!(n.name.is_none()); assert!(n.children().unwrap().iter().all(|c| !matches!(c.kind, NodeKind::Image(_)))); assert!(s.execute("imageTrace.release", &json!({})).is_err()); } #[test] fn image_trace_make_and_expand_and_color_presets() { let mut s = session(); let r = vectorcraft_trace::Raster::from_fn(60, 60, |x, y| match (x < 30, y < 30) { (true, true) => [230, 20, 20, 255], (false, true) => [20, 200, 30, 255], (true, false) => [20, 30, 220, 255], (false, false) => [250, 230, 10, 255], }); add_image(&mut s, &r, Affine::IDENTITY); let res = s.execute("imageTrace.makeAndExpand", &json!({"preset": "16 Colors", "params": {"colors": 4}})).unwrap(); assert_eq!(res["colors"], 4); assert_eq!(res["paths"], 4); let g = node(&s, NodeId(res["id"].as_u64().unwrap())); assert!(g.name.is_none()); assert_eq!(g.children().unwrap().len(), 4); assert!(s.execute("imageTrace.make", &json!({"preset": "No Such Preset"})).is_err()); assert!(s.execute("imageTrace.make", &json!({"params": {"mode": "rainbow"}})).is_err()); } #[test] fn image_trace_presets_query_and_errors() { let mut s = session(); let r = s.execute("imageTrace.presets", &json!({})).unwrap(); let names: Vec<&str> = r["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect(); assert_eq!(names.len(), 12); assert!(names.contains(&"Technical Drawing") && names.contains(&"Shades of Gray")); let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); assert!(s.execute("imageTrace.make", &json!({"id": a.0})).is_err(), "not an image"); assert!(find_command("imageTrace.make").unwrap().menu == ["Object", "Image Trace"]); assert!(find_command("livePaint.make").unwrap().menu == ["Object", "Live Paint"]); } #[test] fn image_trace_object_remembers_its_settings() { let mut s = session(); image_doc(&mut s); let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors"})).unwrap(); let g = NodeId(r["id"].as_u64().unwrap()); let t = node(&s, g).trace.clone().expect("settings stored"); assert_eq!(t["preset"], "6 Colors"); assert_eq!(t["params"]["colors"], 6); // Changing a parameter makes it Custom; the settings survive save/open. let r = s.execute("imageTrace.make", &json!({"id": g.0, "preset": "6 Colors", "params": {"colors": 9}})).unwrap(); let g = NodeId(r["id"].as_u64().unwrap()); let d = s.doc().unwrap().doc.clone(); let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap(); let t = back.node(g).unwrap().trace.clone().unwrap(); assert_eq!((t["preset"].as_str(), t["params"]["colors"].as_u64()), (Some("Custom"), Some(9))); // Expanded traces are plain groups. s.execute("imageTrace.expand", &json!({})).unwrap(); let st = s.doc().unwrap(); let id = st.selection.in_paint_order(&st.doc)[0]; assert!(st.doc.node(id).unwrap().trace.is_none()); }