Files
vectorcraft/crates/engine/src/tests_gradspan.rs
T
Gert Pellin 736abd811d paint.setFill / setStroke: span "selection" places one gradient across the targets (#2127)
A gradient given to several objects fits each one's own bounds. With
span: "selection", a linear or radial gradient without its own vector is
placed once on the union of the targets' bounds, at its angle and aspect,
and every target gets that vector in document coordinates, so the ramp
runs on across them. A solid colour, a swatch, a freeform gradient or an
explicit start/end with it is refused before anything changes; the next
object drawn still fits the gradient to itself.
2026-10-16 04:44:50 +07:00

153 lines
6.5 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.
//! `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}");
}
}