//! Clipping sets with any clip shape (M3.26): Make Clipping Mask takes a compound path or text on //! top and Release gives it back; compound holes, even-odd fills, text outlines and imported //! multi-shape clip paths clip on screen, and the SVG and PDF output clip the same way. use serde_json::{Value, json}; use vectorcraft_render::Rendered; use super::*; pub(crate) fn session() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); s } pub(crate) fn id_of(v: &Value) -> NodeId { NodeId(v["id"].as_u64().unwrap()) } fn ellipse(s: &mut Session, x: f64, w: f64) -> NodeId { id_of(&s.execute("shape.ellipse", &json!({"x": x, "y": x, "width": w, "height": w})).unwrap()) } pub(crate) fn select(s: &mut Session, ids: &[NodeId]) { s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); } /// The active document's 100×100 artboard, rendered transparent. pub(crate) fn render(s: &Session) -> Rendered { vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 100, 100, Affine::IDENTITY, &Default::default()) } pub(crate) fn opaque(r: &Rendered, x: u32, y: u32) -> bool { r.pixel(x, y)[3] > 128 } /// The document exported as `format` and opened again, rendered. fn reopened(s: &mut Session, format: &str) -> Rendered { let data = s.execute("document.export", &json!({"format": format})).unwrap()["dataBase64"].clone(); let mut o = Session::new(); o.execute("document.open", &json!({"name": format!("clip.{format}"), "dataBase64": data})).unwrap(); render(&o) } /// The same probes are opaque on screen and in the SVG and PDF output. pub(crate) fn assert_outputs_agree(s: &mut Session, probes: &[(u32, u32)]) { let screen = render(s); for format in ["svg", "pdf"] { let out = reopened(s, format); for &(x, y) in probes { assert_eq!(opaque(&out, x, y), opaque(&screen, x, y), "{format} at ({x}, {y})"); } } } /// The leaf a click at (`x`, `y`) hits. pub(crate) fn hit(s: &Session, x: f64, y: f64) -> Option { vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, vectorcraft_geom::Point::new(x, y), Default::default()).map(|h| h.leaf) } /// Art under the whole artboard, then a donut (compound path with a real hole) on top. fn art_and_donut(s: &mut Session) -> (NodeId, NodeId) { let art = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); let (outer, inner) = (ellipse(s, 10.0, 80.0), ellipse(s, 35.0, 30.0)); select(s, &[inner]); s.execute("path.reverse", &json!({})).unwrap(); select(s, &[outer, inner]); s.execute("object.compoundPath.make", &json!({})).unwrap(); let donut = s.doc().unwrap().selection.objects[0]; (art, donut) } #[test] fn compound_clip_keeps_its_hole_and_release_gives_the_compound_back() { let mut s = session(); let (art, donut) = art_and_donut(&mut s); select(&mut s, &[art, donut]); let g = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()); let d = &s.doc().unwrap().doc; let NodeKind::Group { children, clip: true } = &d.node(g).unwrap().kind else { panic!("a clip group") }; assert_eq!(children[0].id, donut); assert!(matches!(children[0].kind, NodeKind::Compound { .. }) && children[0].appearance.fill_paint().is_none()); let r = render(&s); assert!(opaque(&r, 20, 50), "the ring shows the art"); assert!(!opaque(&r, 50, 50), "the hole stays transparent"); assert!(!opaque(&r, 3, 3), "outside the ring"); assert_outputs_agree(&mut s, &[(20, 50), (50, 50), (3, 3), (80, 50)]); // Clicks hit the art only where it shows. assert_eq!(hit(&s, 20.0, 50.0), Some(art)); assert_eq!(hit(&s, 50.0, 50.0), None, "the hole"); // Release: a plain group, the compound path back on top of the art, unpainted. select(&mut s, &[g]); s.execute("object.clippingMask.release", &json!({})).unwrap(); let d = &s.doc().unwrap().doc; let NodeKind::Group { children, clip: false } = &d.node(g).unwrap().kind else { panic!("a plain group") }; assert!(matches!(children[0].kind, NodeKind::Compound { .. }) && children[0].id == donut); assert!(opaque(&render(&s), 50, 50), "nothing clips any more"); } #[test] fn text_on_top_clips_by_its_glyphs() { let mut s = session(); let art = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); let t = id_of(&s.execute("text.create", &json!({"x": 5, "y": 80, "text": "O", "size": 90})).unwrap()); let b = s.doc().unwrap().doc.node(t).unwrap().geometric_bounds().unwrap(); select(&mut s, &[art, t]); let g = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()); let d = &s.doc().unwrap().doc; let NodeKind::Text(text) = &d.node(t).unwrap().kind else { panic!("still text") }; assert!(text.runs.iter().all(|r| r.style.fill.is_none() && r.style.stroke.is_none()), "the clipping path loses its paint"); // Across the middle of the "O": clear, ring, the counter (clear), ring, clear. let cy = ((b.y0 + b.y1) / 2.0) as u32; let r = render(&s); let row: Vec = (0..100).map(|x| opaque(&r, x, cy)).collect(); assert_eq!(row.windows(2).filter(|w| w[0] != w[1]).count(), 4, "{row:?}"); let probes: Vec<(u32, u32)> = (0..100).step_by(3).map(|x| (x, cy)).collect(); assert_outputs_agree(&mut s, &probes); // Text clips clicks by its frame. assert_eq!(hit(&s, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0), Some(art)); assert!(b.x1 < 95.0, "{b:?}"); assert_eq!(hit(&s, b.x1 + 3.0, (b.y0 + b.y1) / 2.0), None, "beside the text"); // Release gives the text back. select(&mut s, &[g]); s.execute("object.clippingMask.release", &json!({})).unwrap(); assert!(matches!(s.doc().unwrap().doc.node(t).unwrap().kind, NodeKind::Text(_))); } #[test] fn make_refuses_a_top_object_that_cannot_clip() { let mut s = session(); let art = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); let (a, b) = (ellipse(&mut s, 10.0, 20.0), ellipse(&mut s, 50.0, 20.0)); select(&mut s, &[a, b]); s.execute("object.group", &json!({})).unwrap(); let group = s.doc().unwrap().selection.objects[0]; select(&mut s, &[art, group]); let err = s.execute("object.clippingMask.make", &json!({})).unwrap_err(); assert!(err.to_string().contains("path, compound path or text"), "{err}"); } /// An SVG clip path made of several shapes (imported as one multi-subpath clipping path) and an /// even-odd star clip path. const SVG: &str = r##" "##; #[test] fn imported_multi_shape_and_even_odd_clip_paths_clip() { let mut s = Session::new(); s.execute("document.open", &json!({"name": "clips.svg", "dataBase64": vectorcraft_format::base64_encode(SVG.as_bytes())})).unwrap(); let d = &s.doc().unwrap().doc; let mut kinds = vec![]; d.walk(|n| { if let NodeKind::Group { children, clip: true } = &n.kind { kinds.push(children[0].kind_label()); } }); assert_eq!(kinds.len(), 2, "two clip groups"); let r = render(&s); assert!(opaque(&r, 15, 20) && opaque(&r, 75, 20), "inside both shapes of the clip path"); assert!(!opaque(&r, 45, 20), "between them"); assert!(!opaque(&r, 15, 43), "below them"); // The star's arms cross: the centre of the crossing is outside under even-odd. assert!(opaque(&r, 70, 56), "an arm"); assert!(!opaque(&r, 70, 75), "the even-odd centre"); assert_outputs_agree(&mut s, &[(15, 20), (75, 20), (45, 20), (15, 43), (70, 56), (70, 75)]); }