vectorcraft/crates/engine/src/tests_clip.rs

173 lines
8.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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::<Vec<_>>()})).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<NodeId> {
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<bool> = (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##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
<defs>
<clipPath id="two"><rect x="0" y="0" width="30" height="40"/><rect x="60" y="0" width="30" height="40"/></clipPath>
<clipPath id="star"><path clip-rule="evenodd" d="M70 49 L84.7 94.2 L46.2 66.3 L93.8 66.3 L55.3 94.2 Z"/></clipPath>
</defs>
<g clip-path="url(#two)"><rect x="0" y="0" width="100" height="45" fill="#000"/></g>
<g clip-path="url(#star)"><rect x="40" y="45" width="60" height="55" fill="#000"/></g>
</svg>"##;
#[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)]);
}