mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 13:33:19 +00:00
Merge pull request #6758 from dotCooCoo/test/select-skips-raster-effects
Selection: a click on a drop shadow or glow selects the object under it
This commit is contained in:
@@ -795,6 +795,10 @@ thread_local! {
|
||||
static LEAVES_TESTED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// What a click at `p` hits on leaf object `n`: its outline, strokes and fills, its frame or its
|
||||
/// bounds, by kind. Raster effects (drop shadows, glows, blurs) do not take clicks: the area they
|
||||
/// paint beyond the object is left out here and in [`cull_reach`], so a click there reaches the
|
||||
/// object under it.
|
||||
fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
|
||||
#[cfg(test)]
|
||||
LEAVES_TESTED.with(|c| c.set(c.get() + 1));
|
||||
|
||||
@@ -161,6 +161,7 @@ mod script_include;
|
||||
mod script_io;
|
||||
mod script_run;
|
||||
mod scripts;
|
||||
mod select_effects;
|
||||
mod select_ids_first;
|
||||
mod shaper;
|
||||
mod shapetools;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -597,6 +597,7 @@ since 2026-10-01.
|
||||
- **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`.
|
||||
- **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and ignores its picked anchors, on every contour, as Clear does; an anchor left without a surviving segment is removed with them. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`.
|
||||
- **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`.
|
||||
- **Clicks pass through raster effects:** clicks with the Selection and Direct Selection tools, and the Selection tool's pointer and marquee, leave out the area that a drop shadow, a glow, a blur or another raster effect paints beyond an object. A click on a shadow selects the object under it, or nothing over an empty canvas, and a press on the shadow of a selected object does not move it ([the help page](https://helpx.adobe.com/illustrator/using/selecting-objects.html)). No setting changes this. This covers paths, groups, type, symbol instances and images. Regression tests: `crates/engine/src/tests/select_effects.rs`.
|
||||
- **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`.
|
||||
- **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`.
|
||||
- **Global Edit's enabled check on large paths:** the check that decides whether Select › Start Global Edit can run on the selected path, compound path or symbol instance (which the Control bar makes every frame) measures the box of its anchors and handles instead of building and normalizing a copy of every anchor, with the same answer. On a 200,000-anchor path the check takes 0.7 ms instead of 2.2 to 3.0 ms. Regression tests: `support_*` in `crates/engine/src/cmd/globaledit.rs`.
|
||||
|
||||
Reference in New Issue
Block a user