Files
vectorcraft/crates/engine/src/tests_strokereach.rs
T
bflatasticandClaude Opus 5.5 c1dc860f1d M3.63: visual bounds and clicks take in the whole stroke
Paths and compound paths now measure their visual bounds along their shape:
the body (inside/outside alignment, the width profile's widest point), the
tips of the miter spikes the limit keeps, projecting caps' corners and the
arrowheads' reach. A rectangle's right-angle miters stay within half the
weight (Fit to Selected Art, Rasterize and all-art SVG export get tighter,
exact boxes); Appearance::outset is the conservative reach for anything else.
Clicks hit what the strokes paint: outside strokes outside the path (0.8 x the
weight out), inside ones inside, the profile's width where clicked, caps,
spikes and heads. Click culling keeps a cheap box (hit test 0.34 ms per click
on the 50k-path budget). The subpath/tangent helpers move from the stroke
geometry into vectorcraft_geom::bez so both share them; a cross-check test
holds every painted point of the real stroke geometry inside the bounds and
hittable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:55:23 +07:00

77 lines
3.3 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.
//! Visual bounds and clicks take in the whole stroke: arrowheads, outside alignment, width
//! profiles (through the Selection tool and the commands that use visual bounds).
use serde_json::json;
use vectorcraft_tools::{PointerEvent, PointerKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
fn id_of(v: &serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
/// 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> {
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
}
s.active().unwrap().selection.objects.clone()
}
/// The artboard after Fit to Selected Art (which fits the selection's visual bounds).
fn fitted(s: &mut Session) -> vectorcraft_geom::Rect {
s.execute("artboard.fitToSelection", &json!({})).unwrap();
s.doc().unwrap().doc.artboards[0].rect
}
#[test]
fn a_300_percent_arrowhead_grows_the_visual_bounds() {
let mut s = session();
s.execute("shape.line", &json!({"x1": 100, "y1": 200, "x2": 300, "y2": 200})).unwrap();
s.execute("stroke.set", &json!({"weight": 2, "join": "round", "endArrow": "Triangle"})).unwrap();
let plain = fitted(&mut s);
s.execute("stroke.setAdvanced", &json!({"arrowScale": [100, 300]})).unwrap();
let big = fitted(&mut s);
// The 24 pt head reaches past the end and 12 pt to each side of the line.
assert!(big.x1 > plain.x1 + 15.0 && big.y1 >= 212.0 && big.y0 <= 188.0, "{plain:?} → {big:?}");
assert_eq!(big.x0, plain.x0, "the start has no head");
}
#[test]
fn a_click_outside_an_outside_stroke_selects_it() {
let mut s = session();
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap());
s.execute("paint.setFill", &json!({"none": true})).unwrap();
s.execute("stroke.set", &json!({"weight": 20, "align": "outside"})).unwrap();
s.execute("select.none", &json!({})).unwrap();
// 0.8 × the weight outside the path: past where a centred stroke (plus the tolerance) reaches.
assert_eq!(click(&mut s, 84.0, 200.0), [r]);
s.execute("select.none", &json!({})).unwrap();
assert!(click(&mut s, 116.0, 200.0).is_empty(), "nothing paints inside");
s.execute("stroke.set", &json!({"ids": [r.0], "align": "center"})).unwrap();
s.execute("select.none", &json!({})).unwrap();
assert!(click(&mut s, 84.0, 200.0).is_empty(), "a centred stroke doesn't reach");
}
#[test]
fn a_click_at_the_lens_maximum_selects_its_stroke() {
let mut s = session();
let l = id_of(&s.execute("shape.line", &json!({"x1": 100, "y1": 200, "x2": 300, "y2": 200})).unwrap());
s.execute("stroke.set", &json!({"weight": 20, "profile": "lens"})).unwrap();
s.execute("select.none", &json!({})).unwrap();
assert_eq!(click(&mut s, 200.0, 209.0), [l], "the widest point");
s.execute("select.none", &json!({})).unwrap();
assert!(click(&mut s, 110.0, 209.0).is_empty(), "the thin end");
// Fitted bounds take the profile in too.
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
assert_eq!(fitted(&mut s).y1, 210.0);
}