Files
vectorcraft/crates/engine/src/tests/select_effects.rs
T
dotCooCoo 2a5f8d8d5d Selection: a click on a drop shadow or glow selects the object under it
Clicks, the hover pointer and marquees with the selection tools already
leave out the area that raster effects paint beyond an object. New tests
in crates/engine/src/tests/select_effects.rs hold that in place for drop
shadows, Outer Glow and Gaussian Blur on paths, groups, point type,
symbol art and images, for Shift-click, Direct Selection, the hover
pointer, a drag from a selected object's shadow and a marquee. hit_leaf
gets a doc comment, and docs/target-app-parity.md gets a line citing
https://helpx.adobe.com/illustrator/using/selecting-objects.html.
2026-11-11 18:31:51 -08:00

237 lines
10 KiB
Rust

//! Clicks, hovers and marquees with the selection tools leave out the area that raster effects
//! (drop shadows, glows, blurs) paint beyond an object: there they reach the object under the
//! effect, or nothing.
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_geom::{Point, Rect};
use vectorcraft_tools::{Cursor, Mods, PointerEvent, PointerKind};
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
/// A rectangle filled with `color`.
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, color: &str) -> NodeId {
let id = id_of(&run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})));
run(s, "select.set", json!({"ids": [id.0]}));
run(s, "paint.setFill", json!({"color": color}));
id
}
/// A drop shadow on the whole of `id`, `dx` to the right, with no blur.
fn shadow(s: &mut Session, id: NodeId, dx: f64) {
run(s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [id.0], "params": {"x": dx, "y": 0, "blur": 0}, "item": null}));
}
fn pixel(s: &Session, x: u32, y: u32) -> [u8; 4] {
let doc = s.doc().unwrap().doc.clone();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(x, y)
}
fn press(s: &mut Session, tool: &str, from: (f64, f64), to: (f64, f64), mods: Mods) -> Vec<NodeId> {
let v = ViewInfo::default();
s.select_tool(tool, v).unwrap();
for (k, (x, y)) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
if k == PointerKind::Drag && from == to {
continue;
}
s.pointer(&PointerEvent::new(k, x, y).with_mods(mods), v).unwrap();
}
s.active().unwrap().selection.objects.clone()
}
/// Click with the Selection tool at (x, y) on a 100% view: what is selected afterwards.
fn click(s: &mut Session, x: f64, y: f64) -> Vec<NodeId> {
press(s, "selection", (x, y), (x, y), Mods::default())
}
fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
const WHITE: [u8; 4] = [255; 4];
const BLUE: [u8; 4] = [0, 0, 255, 255];
/// Blue B at x 100 to 160, red A over its left edge's side at x 20 to 80, and A's shadow
/// 40 pt to the right, from x 60 to 120: over the canvas from 80 to 100 and over B from 100 to 120.
fn shadow_over_b(s: &mut Session) -> (NodeId, NodeId) {
let b = rect(s, 100.0, 20.0, 60.0, 60.0, "#0000ff");
let a = rect(s, 20.0, 20.0, 60.0, 60.0, "#ff0000");
shadow(s, a, 40.0);
(a, b)
}
#[test]
fn a_click_on_a_drop_shadow_selects_the_object_under_it() {
let mut s = session();
let (a, b) = shadow_over_b(&mut s);
// The shadow paints over B and over the canvas.
assert_ne!(pixel(&s, 110, 50), BLUE);
assert_ne!(pixel(&s, 90, 50), WHITE);
run(&mut s, "select.none", json!({}));
assert_eq!(click(&mut s, 110.0, 50.0), [b]);
assert_eq!(click(&mut s, 50.0, 50.0), [a]);
}
#[test]
fn a_click_on_a_drop_shadow_over_the_canvas_selects_nothing_and_clears_the_selection() {
let mut s = session();
let (a, _) = shadow_over_b(&mut s);
run(&mut s, "select.set", json!({"ids": [a.0]}));
assert!(click(&mut s, 90.0, 50.0).is_empty());
}
#[test]
fn a_click_on_an_outer_glow_or_a_gaussian_blur_outside_the_path_selects_what_is_under_it() {
let mut s = session();
let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0, "#ff0000");
run(&mut s, "effect.apply", json!({"effect": "stylize.outerGlow", "ids": [a.0], "params": {"blur": 20, "mode": "normal"}, "item": null}));
assert_ne!(pixel(&s, 88, 50), WHITE);
run(&mut s, "select.none", json!({}));
assert!(click(&mut s, 88.0, 50.0).is_empty());
// A red square whose blur reaches over the canvas and over the left edge of a blue one.
let mut s = session();
let b = rect(&mut s, 92.0, 20.0, 60.0, 60.0, "#0000ff");
let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0, "#ff0000");
run(&mut s, "effect.apply", json!({"effect": "blur.gaussian", "ids": [a.0], "params": {"radius": 20}, "item": null}));
assert_ne!(pixel(&s, 98, 50), BLUE);
assert_ne!(pixel(&s, 87, 50), WHITE);
run(&mut s, "select.none", json!({}));
assert_eq!(click(&mut s, 98.0, 50.0), [b]);
// (With B deselected, so that the press does not take its bounding box handle.)
run(&mut s, "select.none", json!({}));
assert!(click(&mut s, 87.0, 50.0).is_empty());
}
/// The first point of `area` (x0, y0, x1, y1) that the canvas paints, scanning rows.
fn painted_in(s: &Session, (x0, y0, x1, y1): (u32, u32, u32, u32)) -> Option<(f64, f64)> {
let doc = s.doc().unwrap().doc.clone();
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let img = vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts);
(y0..y1)
.flat_map(|y| (x0..x1).map(move |x| (x, y)))
.find(|&(x, y)| img.pixel(x, y) != WHITE)
.map(|(x, y)| (f64::from(x) + 0.5, f64::from(y) + 0.5))
}
#[test]
fn a_click_on_the_drop_shadow_of_a_group_or_of_point_type_selects_neither() {
let mut s = session();
let a = rect(&mut s, 20.0, 20.0, 30.0, 30.0, "#ff0000");
let b = rect(&mut s, 60.0, 20.0, 30.0, 30.0, "#ff0000");
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
let g = id_of(&run(&mut s, "object.group", json!({})));
shadow(&mut s, g, 80.0);
// The group's shadow lies from x 100 to 170.
assert_ne!(pixel(&s, 110, 35), WHITE);
run(&mut s, "select.none", json!({}));
assert!(click(&mut s, 110.0, 35.0).is_empty());
assert_eq!(click(&mut s, 35.0, 35.0), [g]);
// Type 40 pt high at y 150 with its shadow 90 pt to the right.
let t = id_of(&run(&mut s, "text.create", json!({"x": 20, "y": 150, "text": "HH", "size": 40})));
shadow(&mut s, t, 90.0);
let right = bounds(&s, t).x1;
assert!(right < 100.0, "{right}");
let at = painted_in(&s, (right.ceil() as u32 + 10, 110, 199, 160)).expect("the type's shadow paints");
run(&mut s, "select.none", json!({}));
assert!(click(&mut s, at.0, at.1).is_empty(), "{at:?}");
}
#[test]
fn a_click_on_the_drop_shadow_of_a_symbols_art_does_not_select_the_instance() {
let mut s = session();
let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0, "#ff0000");
shadow(&mut s, a, 40.0);
run(&mut s, "select.set", json!({"ids": [a.0]}));
let inst = id_of(&run(&mut s, "symbol.new", json!({"name": "Shadowed"})));
assert_ne!(pixel(&s, 100, 50), WHITE);
run(&mut s, "select.none", json!({}));
assert!(click(&mut s, 100.0, 50.0).is_empty());
assert_eq!(click(&mut s, 50.0, 50.0), [inst]);
}
#[test]
fn a_click_on_the_drop_shadow_of_an_image_outside_its_frame_does_not_select_it() {
let mut s = session();
let mut png = vec![];
image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let r = run(&mut s, "file.place", json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [65, 65]}));
let im = NodeId(r["ids"][0].as_u64().unwrap());
// The frame lies from x 50 to 80 and the shadow from 90 to 120.
shadow(&mut s, im, 40.0);
assert_ne!(pixel(&s, 105, 65), WHITE);
run(&mut s, "select.none", json!({}));
assert!(click(&mut s, 105.0, 65.0).is_empty());
assert_eq!(click(&mut s, 65.0, 65.0), [im]);
}
#[test]
fn shift_click_direct_selection_and_hover_on_a_drop_shadow_pass_through_it() {
let mut s = session();
let (a, b) = shadow_over_b(&mut s);
// Shift-click on the shadow over B adds B and keeps A.
run(&mut s, "select.set", json!({"ids": [a.0]}));
let shift = Mods { shift: true, ..Mods::default() };
let mut sel = press(&mut s, "selection", (110.0, 50.0), (110.0, 50.0), shift);
sel.sort_by_key(|n| n.0);
let mut both = vec![a, b];
both.sort_by_key(|n| n.0);
assert_eq!(sel, both);
// Direct Selection takes B through the shadow, and nothing over the canvas.
run(&mut s, "select.none", json!({}));
let sel = press(&mut s, "directSelection", (110.0, 50.0), (110.0, 50.0), Mods::default());
assert!(sel.contains(&b) && !sel.contains(&a), "{sel:?}");
run(&mut s, "select.none", json!({}));
assert!(press(&mut s, "directSelection", (90.0, 50.0), (90.0, 50.0), Mods::default()).is_empty());
// With A selected, the pointer over its shadow is the plain arrow, and over A the move pointer.
run(&mut s, "select.set", json!({"ids": [a.0]}));
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
assert_eq!(s.cursor(Point::new(50.0, 50.0), Mods::default(), v), Cursor::Move);
assert_eq!(s.cursor(Point::new(110.0, 50.0), Mods::default(), v), Cursor::Arrow);
assert_eq!(s.cursor(Point::new(92.0, 50.0), Mods::default(), v), Cursor::Arrow);
}
#[test]
fn a_drag_from_the_drop_shadow_of_a_selected_object_does_not_move_it() {
let mut s = session();
let (a, b) = shadow_over_b(&mut s);
run(&mut s, "select.set", json!({"ids": [a.0]}));
let before = bounds(&s, a);
// From the shadow over the canvas: a marquee that touches nothing.
assert!(press(&mut s, "selection", (92.0, 50.0), (96.0, 60.0), Mods::default()).is_empty());
assert_eq!(bounds(&s, a), before);
// From the shadow over B: B moves, A stays.
run(&mut s, "select.set", json!({"ids": [a.0]}));
let b_before = bounds(&s, b);
assert_eq!(press(&mut s, "selection", (110.0, 50.0), (110.0, 60.0), Mods::default()), [b]);
assert_eq!(bounds(&s, a), before);
assert!((bounds(&s, b).y0 - b_before.y0 - 10.0).abs() < 1e-6, "{:?}", bounds(&s, b));
}
#[test]
fn a_marquee_that_touches_only_a_drop_shadow_does_not_select_its_object() {
let mut s = session();
let (_, b) = shadow_over_b(&mut s);
run(&mut s, "select.none", json!({}));
assert!(press(&mut s, "selection", (85.0, 40.0), (95.0, 60.0), Mods::default()).is_empty());
// From above B, over B and the shadow: B only.
assert_eq!(press(&mut s, "selection", (105.0, 5.0), (115.0, 30.0), Mods::default()), [b]);
}