diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3aa8821cb..082619a62 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1894,6 +1894,8 @@ mod tests_gradient; #[cfg(test)] mod tests_gradpanel; #[cfg(test)] +mod tests_gradspan; +#[cfg(test)] mod tests_halftone; #[cfg(test)] mod tests_history_mem; diff --git a/crates/engine/src/tests_gradspan.rs b/crates/engine/src/tests_gradspan.rs new file mode 100644 index 000000000..eed185b3d --- /dev/null +++ b/crates/engine/src/tests_gradspan.rs @@ -0,0 +1,151 @@ +//! `paint.setFill` / `paint.setStroke` `span: "selection"`: one gradient placed on the union of +//! the targets' bounds, so the ramp runs across them (#2127). + +use serde_json::{Value, json}; +use vectorcraft_color::{GradientPaint, Paint}; +use vectorcraft_geom::{Affine, Point}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200})).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 fill_gradient(s: &Session, id: NodeId) -> GradientPaint { + match s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() { + Paint::Gradient(g) => *g, + p => panic!("expected a gradient fill, got {p:?}"), + } +} + +fn stroke_gradient(s: &Session, id: NodeId) -> GradientPaint { + match &s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().paint { + Paint::Gradient(g) => (**g).clone(), + p => panic!("expected a gradient stroke, got {p:?}"), + } +} + +fn near(a: Point, b: Point) -> bool { + (a - b).hypot() < 1e-6 +} + +fn black_to_white() -> Value { + json!({"kind": "linear", "stops": [{"offset": 0, "color": "#000000"}, {"offset": 1, "color": "#ffffff"}]}) +} + +/// The red channel of the rendered document at (x, y). +fn red_at(s: &Session, x: u32, y: u32) -> u8 { + let doc = s.doc().unwrap().doc.clone(); + let img = vectorcraft_render::Renderer::new().render(&doc, 400, 200, Affine::IDENTITY, &Default::default()); + img.pixel(x, y)[0] +} + +#[test] +fn a_linear_gradient_spans_the_union_of_the_targets() { + let mut s = session(); + let a = rect(&mut s, 0.0, 50.0, 100.0, 100.0); + let b = rect(&mut s, 100.0, 0.0, 100.0, 200.0); + let c = rect(&mut s, 300.0, 50.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"ids": [a.0, b.0, c.0], "gradient": black_to_white(), "span": "selection"})).unwrap(); + // One vector for all three, across the union 0..400 × 0..200 at angle 0. + for id in [a, b, c] { + let g = fill_gradient(&s, id); + let geom = g.geom.expect("placed in document coordinates"); + assert!(near(geom.start, Point::new(0.0, 100.0)) && near(geom.end, Point::new(400.0, 100.0)), "{id:?}: {geom:?}"); + } + // The ramp runs on across the objects: dark on the left one, light on the right one, and the + // edge the first two share is the same colour on both sides. + let (left, right) = (red_at(&s, 5, 100), red_at(&s, 395, 100)); + assert!(left < 20 && right > 235, "{left} {right}"); + let (edge_a, edge_b) = (i32::from(red_at(&s, 98, 100)), i32::from(red_at(&s, 102, 100))); + assert!((edge_a - edge_b).abs() <= 4 && (50..80).contains(&edge_a), "{edge_a} {edge_b}"); +} + +#[test] +fn without_span_each_object_fits_its_own_box() { + let mut s = session(); + let a = rect(&mut s, 0.0, 50.0, 100.0, 100.0); + let b = rect(&mut s, 100.0, 50.0, 100.0, 100.0); + for span in [json!(null), json!("object")] { + let mut p = json!({"ids": [a.0, b.0], "gradient": black_to_white()}); + if !span.is_null() { + p["span"] = span; + } + s.execute("paint.setFill", &p).unwrap(); + assert_eq!(fill_gradient(&s, a).geom, None); + // Each ramp starts dark again on its own object. + assert!(red_at(&s, 102, 100) < 20, "{p}"); + } +} + +#[test] +fn the_selection_is_the_default_target_and_angle_and_radial_are_kept() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 200.0, 100.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let mut g = black_to_white(); + g["angle"] = json!(-90); + s.execute("paint.setFill", &json!({"gradient": g, "span": "selection"})).unwrap(); + // Vertical, top to bottom, over the union 0..300 × 0..200. + let geom = fill_gradient(&s, b).geom.unwrap(); + assert!(near(geom.start, Point::new(150.0, 0.0)) && near(geom.end, Point::new(150.0, 200.0)), "{geom:?}"); + assert_eq!(fill_gradient(&s, a).geom, Some(geom)); + // Radial: centred on the union, radius half its larger side, aspect kept. + s.execute("paint.setFill", &json!({"gradient": {"kind": "radial", "aspect": 50, "stops": black_to_white()["stops"]}, "span": "selection"})).unwrap(); + let geom = fill_gradient(&s, a).geom.unwrap(); + assert!(near(geom.start, Point::new(150.0, 100.0)), "{geom:?}"); + assert!(((geom.end - geom.start).hypot() - 150.0).abs() < 1e-6, "{geom:?}"); + assert!((geom.aspect - 0.5).abs() < 1e-9, "{geom:?}"); + assert_eq!(fill_gradient(&s, b).geom, Some(geom)); +} + +#[test] +fn strokes_span_the_targets_too() { + let mut s = session(); + let a = rect(&mut s, 0.0, 50.0, 100.0, 100.0); + let b = rect(&mut s, 300.0, 50.0, 100.0, 100.0); + s.execute("paint.setStroke", &json!({"ids": [a.0, b.0], "gradient": black_to_white(), "span": "selection"})).unwrap(); + let (ga, gb) = (stroke_gradient(&s, a).geom.unwrap(), stroke_gradient(&s, b).geom.unwrap()); + assert_eq!(ga, gb); + assert!(ga.start.x < 0.0 && ga.end.x > 400.0, "the stroke's box, half its weight out: {ga:?}"); +} + +#[test] +fn the_new_art_default_stays_unplaced() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"ids": [a.0], "gradient": black_to_white(), "span": "selection"})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + let n = rect(&mut s, 200.0, 0.0, 50.0, 50.0); + assert_eq!(fill_gradient(&s, n).geom, None, "the next object fits the gradient to itself"); +} + +#[test] +fn bad_spans_are_refused_and_change_nothing() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0); + let before = s.doc().unwrap().doc.clone(); + let mut with_vector = black_to_white(); + with_vector["start"] = json!([0, 0]); + with_vector["end"] = json!([10, 0]); + for bad in [ + json!({"ids": [a.0, b.0], "gradient": black_to_white(), "span": "page"}), + json!({"ids": [a.0, b.0], "gradient": black_to_white(), "span": true}), + json!({"ids": [a.0, b.0], "color": "#ff0000", "span": "selection"}), + json!({"ids": [a.0, b.0], "gradient": {"kind": "freeform"}, "span": "selection"}), + json!({"ids": [a.0, b.0], "gradient": with_vector, "span": "selection"}), + ] { + let e = s.execute("paint.setFill", &bad).unwrap_err().to_string(); + assert!(e.contains("span"), "{bad}: {e}"); + assert_eq!(s.doc().unwrap().doc, before, "{bad}"); + } +}