mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 17:47:43 +00:00
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.
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
//! 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user