mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
Collapse ClickTargetType to a single multi-contour BezPath variant (#4456)
* Collapse ClickTargetType to a single multi-contour BezPath variant * Restrict no-stroke point hits to a path's closed contours
This commit is contained in:
@@ -26,9 +26,10 @@ use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster, Texture};
|
||||
use graphic_types::vector_types::gradient::{Gradient, GradientForm};
|
||||
use graphic_types::vector_types::subpath::Subpath;
|
||||
use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint};
|
||||
use graphic_types::vector_types::vector::misc::dvec2_to_point;
|
||||
use graphic_types::vector_types::vector::style::{RenderMode, StrokeAlign, StrokeCap, StrokeJoin};
|
||||
use graphic_types::{Appearance, Artboard, Cover, Coverage, FillAndStroke, Graphic, Vector};
|
||||
use kurbo::{Affine, BezPath, Cap, Join, Shape, StrokeOpts};
|
||||
use kurbo::{Affine, BezPath, Cap, Join, PathEl, Shape, StrokeOpts};
|
||||
use num_traits::Zero;
|
||||
use skrifa::instance::{LocationRef, NormalizedCoord, Size};
|
||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
@@ -1361,8 +1362,9 @@ impl Render for List<Artboard> {
|
||||
let element_id = layer_path.iter_element_values().next_back().copied();
|
||||
|
||||
if let Some(element_id) = element_id {
|
||||
let subpath = Subpath::new_rectangle(DVec2::ZERO, dimensions);
|
||||
metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.).into()]);
|
||||
metadata
|
||||
.click_targets
|
||||
.insert(element_id, vec![ClickTarget::new_with_path(rectangle_path(DVec2::ZERO, dimensions), 0.).into()]);
|
||||
metadata.upstream_footprints.insert(element_id, footprint);
|
||||
metadata.local_transforms.insert(element_id, DAffine2::from_translation(location));
|
||||
if clip {
|
||||
@@ -1381,8 +1383,7 @@ impl Render for List<Artboard> {
|
||||
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>, _inherited_appearance: Option<&Appearance>) {
|
||||
for index in 0..self.len() {
|
||||
let dimensions: DVec2 = self.attribute_cloned_or_default(ATTR_DIMENSIONS, index);
|
||||
let subpath_rectangle = Subpath::new_rectangle(DVec2::ZERO, dimensions);
|
||||
click_targets.push(ClickTarget::new_with_subpath(subpath_rectangle, 0.));
|
||||
click_targets.push(ClickTarget::new_with_path(rectangle_path(DVec2::ZERO, dimensions), 0.));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2210,20 +2211,20 @@ impl Render for List<Vector> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build one `CompoundPath` (non-zero fill rule, so holes like the inside of an "O" work
|
||||
/// Build one multi-contour `Path` (non-zero fill rule, so holes like the inside of an "O" work
|
||||
/// correctly) plus one `FreePoint` per disconnected anchor, apply the transform, and append.
|
||||
fn extend_targets_from_vector(targets: &mut Vec<ClickTarget>, appearance: Option<&Appearance>, geometry: &Vector, transform: DAffine2) {
|
||||
// A coverage whose paint is `Graphic::None` exists but paints nothing, so it does not close subpaths for hit testing
|
||||
let filled = appearance.is_some_and(|appearance| appearance.has_painted_cover(Cover::Fill));
|
||||
|
||||
let mut subpaths: Vec<Subpath<_>> = geometry.stroke_bezier_paths().collect();
|
||||
let all_subpaths_closed = subpaths.iter().all(|subpath| subpath.closed());
|
||||
let mut bezpaths: Vec<BezPath> = geometry.stroke_bezpath_iter().filter(|bezpath| !bezpath.elements().is_empty()).collect();
|
||||
let all_contours_closed = bezpaths.iter().all(|bezpath| matches!(bezpath.elements().last(), Some(PathEl::ClosePath)));
|
||||
|
||||
// Inside/Outside-aligned strokes reach `weight` from the centerline rather than `weight / 2` per side,
|
||||
// so they need double the click inflation. Alignment is only honored by the renderer for fully-closed paths.
|
||||
let stroke_width = appearance.and_then(|appearance| appearance.first_coverage_of(Cover::Stroke)).map_or(0., |coverage| {
|
||||
let stroke = coverage.stroke_params();
|
||||
if stroke.align.is_not_centered() && all_subpaths_closed {
|
||||
if stroke.align.is_not_centered() && all_contours_closed {
|
||||
stroke.weight * 2.
|
||||
} else {
|
||||
stroke.weight
|
||||
@@ -2231,13 +2232,20 @@ fn extend_targets_from_vector(targets: &mut Vec<ClickTarget>, appearance: Option
|
||||
});
|
||||
|
||||
if filled {
|
||||
for subpath in &mut subpaths {
|
||||
subpath.set_closed(true);
|
||||
for bezpath in &mut bezpaths {
|
||||
if !matches!(bezpath.elements().last(), Some(PathEl::ClosePath)) {
|
||||
bezpath.close_path();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !subpaths.is_empty() {
|
||||
let mut click_target = ClickTarget::new_with_compound_path(subpaths, stroke_width);
|
||||
if !bezpaths.is_empty() {
|
||||
let mut combined_path = BezPath::new();
|
||||
for bezpath in bezpaths {
|
||||
combined_path.extend(bezpath);
|
||||
}
|
||||
|
||||
let mut click_target = ClickTarget::new_with_path(combined_path, stroke_width);
|
||||
click_target.apply_transform(transform);
|
||||
targets.push(click_target);
|
||||
}
|
||||
@@ -2412,9 +2420,9 @@ fn render_raster_cpu_item_to_vello(item: ItemRef<'_, Raster<CPU>>, scene: &mut S
|
||||
/// plus the first item's transform and any merged-layers snapshot when a first item exists.
|
||||
fn collect_raster_metadata<T>(first_row: Option<ItemRef<'_, T>>, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>) {
|
||||
let Some(element_id) = element_id else { return };
|
||||
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
|
||||
|
||||
metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.).into()]);
|
||||
metadata
|
||||
.click_targets
|
||||
.insert(element_id, vec![ClickTarget::new_with_path(rectangle_path(DVec2::ZERO, DVec2::ONE), 0.).into()]);
|
||||
metadata.upstream_footprints.insert(element_id, footprint);
|
||||
// TODO: Find a way to handle more than one item of the `List<Raster<...>>`
|
||||
if let Some(item) = first_row {
|
||||
@@ -2437,10 +2445,10 @@ fn collect_raster_metadata<T>(first_row: Option<ItemRef<'_, T>>, metadata: &mut
|
||||
/// Adds the unit-square click target every raster item presents, placed by the item's transform.
|
||||
fn add_unit_square_click_target(transform: DAffine2, click_targets: &mut Vec<ClickTarget>) {
|
||||
// The unit square is the raster's own space, so its placement only exists in the item transform
|
||||
let mut subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
|
||||
subpath.apply_transform(transform);
|
||||
let mut path = rectangle_path(DVec2::ZERO, DVec2::ONE);
|
||||
path.apply_affine(Affine::new(transform.to_cols_array()));
|
||||
|
||||
click_targets.push(ClickTarget::new_with_subpath(subpath, 0.));
|
||||
click_targets.push(ClickTarget::new_with_path(path, 0.));
|
||||
}
|
||||
|
||||
impl Render for List<Raster<CPU>> {
|
||||
@@ -2630,11 +2638,28 @@ fn render_color_item_to_vello(item: ItemRef<'_, Color>, scene: &mut Scene, rende
|
||||
}
|
||||
}
|
||||
|
||||
/// The closed rectangular path spanning the two opposite corners, used for the box-shaped click targets.
|
||||
fn rectangle_path(corner1: DVec2, corner2: DVec2) -> BezPath {
|
||||
kurbo::Rect::from_points(dvec2_to_point(corner1), dvec2_to_point(corner2)).to_path(kurbo::DEFAULT_ACCURACY)
|
||||
}
|
||||
|
||||
/// A gradient's control geometry in its local space: the unit circle a radial gradient's transform carries to its drawn ellipse, or the (0,0) to (1,0) gradient line for a linear one.
|
||||
fn gradient_control_outline(gradient_form: GradientForm) -> Subpath<graphic_types::vector_types::vector::PointId> {
|
||||
fn gradient_control_outline(gradient_form: GradientForm) -> BezPath {
|
||||
match gradient_form {
|
||||
GradientForm::Linear => Subpath::new_line(DVec2::ZERO, DVec2::X),
|
||||
GradientForm::Radial => Subpath::new_ellipse(DVec2::splat(-1.), DVec2::splat(1.)),
|
||||
GradientForm::Linear => BezPath::from_path_segments(std::iter::once(kurbo::PathSeg::Line(kurbo::Line::new(dvec2_to_point(DVec2::ZERO), dvec2_to_point(DVec2::X))))),
|
||||
GradientForm::Radial => {
|
||||
// Four-cubic kappa circle with anchors on the axes, so the tight bounding box is exactly the unit square
|
||||
// <https://en.wikipedia.org/wiki/Composite_B%C3%A9zier_curve#Using_four_curves>
|
||||
const KAPPA: f64 = 4. / 3. * (std::f64::consts::SQRT_2 - 1.);
|
||||
let mut path = BezPath::new();
|
||||
path.move_to((1., 0.));
|
||||
path.curve_to((1., KAPPA), (KAPPA, 1.), (0., 1.));
|
||||
path.curve_to((-KAPPA, 1.), (-1., KAPPA), (-1., 0.));
|
||||
path.curve_to((-1., -KAPPA), (-KAPPA, -1.), (0., -1.));
|
||||
path.curve_to((KAPPA, -1.), (1., -KAPPA), (1., 0.));
|
||||
path.close_path();
|
||||
path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2867,7 +2892,7 @@ fn collect_gradient_items_metadata<'a>(items: impl Iterator<Item = ItemRef<'a, G
|
||||
// The first item's transform is the reference all targets bake against
|
||||
let item_zero_inverse = *item_zero_inverse.get_or_insert_with(|| if transform_is_invertible(item_transform) { item_transform.inverse() } else { DAffine2::IDENTITY });
|
||||
|
||||
let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.);
|
||||
let mut target = ClickTarget::new_with_path(gradient_control_outline(gradient_form), 0.);
|
||||
target.apply_transform(item_zero_inverse * item_transform);
|
||||
let target = Arc::new(target);
|
||||
|
||||
@@ -2895,7 +2920,7 @@ fn add_gradient_item_click_targets(item: ItemRef<'_, Gradient>, click_targets: &
|
||||
}
|
||||
|
||||
let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||||
let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.);
|
||||
let mut target = ClickTarget::new_with_path(gradient_control_outline(gradient_form), 0.);
|
||||
target.apply_transform(transform);
|
||||
click_targets.push(target);
|
||||
}
|
||||
@@ -2905,7 +2930,7 @@ fn add_gradient_item_outline_targets(item: ItemRef<'_, Gradient>, outlines: &mut
|
||||
let gradient_form: GradientForm = item.attribute_cloned_or_default(ATTR_GRADIENT_FORM);
|
||||
let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||||
|
||||
let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.);
|
||||
let mut target = ClickTarget::new_with_path(gradient_control_outline(gradient_form), 0.);
|
||||
target.apply_transform(transform);
|
||||
outlines.push(target);
|
||||
}
|
||||
@@ -3273,8 +3298,7 @@ fn collect_text_items_metadata<'a>(items: impl Iterator<Item = ItemRef<'a, Strin
|
||||
}
|
||||
|
||||
let Some((size, item_transform)) = text_item_size_and_transform(item) else { continue };
|
||||
let subpath = Subpath::new_rectangle(DVec2::ZERO, size);
|
||||
let mut target = ClickTarget::new_with_subpath(subpath, 0.);
|
||||
let mut target = ClickTarget::new_with_path(rectangle_path(DVec2::ZERO, size), 0.);
|
||||
target.apply_transform(item_zero_inverse * item_transform);
|
||||
accumulated_click_targets.entry(element_id).or_default().push(Arc::new(target));
|
||||
}
|
||||
@@ -3289,8 +3313,7 @@ fn collect_text_items_metadata<'a>(items: impl Iterator<Item = ItemRef<'a, Strin
|
||||
/// Collects one text item's laid-out rectangle as a click target.
|
||||
fn add_text_item_click_targets(item: ItemRef<'_, String>, click_targets: &mut Vec<ClickTarget>) {
|
||||
let Some((size, transform)) = text_item_size_and_transform(item) else { return };
|
||||
let subpath = Subpath::new_rectangle(DVec2::ZERO, size);
|
||||
let mut target = ClickTarget::new_with_subpath(subpath, 0.);
|
||||
let mut target = ClickTarget::new_with_path(rectangle_path(DVec2::ZERO, size), 0.);
|
||||
target.apply_transform(transform);
|
||||
click_targets.push(target);
|
||||
}
|
||||
|
||||
@@ -3,16 +3,42 @@ use std::sync::{Arc, RwLock};
|
||||
use super::algorithms::{bezpath_algorithms::bezpath_is_inside_bezpath, intersection::filtered_segment_intersections};
|
||||
use super::misc::dvec2_to_point;
|
||||
use crate::math::QuadExt;
|
||||
use crate::subpath::Subpath;
|
||||
use crate::vector::PointId;
|
||||
use crate::vector::misc::point_to_dvec2;
|
||||
use core_types::math::quad::Quad;
|
||||
use core_types::transform::Transform;
|
||||
use glam::{DAffine2, DMat2, DVec2};
|
||||
use kurbo::{Affine, BezPath, ParamCurve, PathSeg, Shape};
|
||||
use kurbo::{Affine, BezPath, ParamCurve, PathEl, PathSeg, Shape};
|
||||
|
||||
type BoundingBox = Option<[DVec2; 2]>;
|
||||
|
||||
/// Per-segment tight bounding box union of the transformed path, or None if the path has no segments.
|
||||
fn bezpath_bounding_box_with_transform(bezpath: &BezPath, transform: DAffine2) -> BoundingBox {
|
||||
let affine = Affine::new(transform.to_cols_array());
|
||||
bezpath
|
||||
.segments()
|
||||
.map(|segment| (affine * segment).bounding_box())
|
||||
.reduce(|a, b| a.union(b))
|
||||
.map(|rect| [DVec2::new(rect.min_x(), rect.min_y()), DVec2::new(rect.max_x(), rect.max_y())])
|
||||
}
|
||||
|
||||
/// The explicitly closed contours of the path, which together form its fillable region.
|
||||
fn closed_contours(bezpath: &BezPath) -> BezPath {
|
||||
let elements = bezpath.elements();
|
||||
let mut kept = Vec::new();
|
||||
let mut contour_start = 0;
|
||||
|
||||
for (index, element) in elements.iter().enumerate() {
|
||||
if matches!(element, PathEl::MoveTo(_)) {
|
||||
contour_start = index;
|
||||
}
|
||||
if matches!(element, PathEl::ClosePath) {
|
||||
kept.extend_from_slice(&elements[contour_start..=index]);
|
||||
}
|
||||
}
|
||||
|
||||
BezPath::from_vec(kept)
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct FreePoint {
|
||||
@@ -33,11 +59,10 @@ impl FreePoint {
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum ClickTargetType {
|
||||
Subpath(Subpath<PointId>),
|
||||
FreePoint(FreePoint),
|
||||
/// Multiple subpaths tested as one compound shape using the non-zero fill rule, so holes
|
||||
/// One or more contours tested as one compound shape using the non-zero fill rule, so holes
|
||||
/// (e.g. the inside of an "O") correctly count as outside the fill.
|
||||
CompoundPath(Vec<Subpath<PointId>>),
|
||||
Path(BezPath),
|
||||
FreePoint(FreePoint),
|
||||
}
|
||||
|
||||
/// Fixed-size ring buffer cache for rotated bounding boxes.
|
||||
@@ -93,9 +118,9 @@ impl BoundingBoxCache {
|
||||
}
|
||||
/// Computes and caches bounding box for the given rotation, then applies scale/translation.
|
||||
/// Returns the final transformed bounds.
|
||||
fn add_to_cache(&mut self, subpath: &Subpath<PointId>, rotation: f64, scale: DVec2, translation: DVec2, fingerprint: u8) -> BoundingBox {
|
||||
fn add_to_cache(&mut self, bezpath: &BezPath, rotation: f64, scale: DVec2, translation: DVec2, fingerprint: u8) -> BoundingBox {
|
||||
// Compute bounds for pure rotation (expensive operation we want to cache)
|
||||
let bounds = subpath.bounding_box_with_transform(DAffine2::from_angle(rotation));
|
||||
let bounds = bezpath_bounding_box_with_transform(bezpath, DAffine2::from_angle(rotation));
|
||||
|
||||
if bounds.is_none() {
|
||||
return bounds;
|
||||
@@ -137,23 +162,12 @@ impl PartialEq for ClickTarget {
|
||||
}
|
||||
|
||||
impl ClickTarget {
|
||||
pub fn new_with_subpath(subpath: Subpath<PointId>, stroke_width: f64) -> Self {
|
||||
let bounding_box = subpath.loose_bounding_box();
|
||||
pub fn new_with_path(path: BezPath, stroke_width: f64) -> Self {
|
||||
// The control-point hull serves as the loose bounding box
|
||||
let control_box = path.control_box();
|
||||
let bounding_box = (!path.elements().is_empty()).then(|| [DVec2::new(control_box.min_x(), control_box.min_y()), DVec2::new(control_box.max_x(), control_box.max_y())]);
|
||||
Self {
|
||||
target_type: ClickTargetType::Subpath(subpath),
|
||||
stroke_width,
|
||||
bounding_box,
|
||||
bounding_box_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_compound_path(subpaths: Vec<Subpath<PointId>>, stroke_width: f64) -> Self {
|
||||
let bounding_box = subpaths
|
||||
.iter()
|
||||
.filter_map(|subpath| subpath.loose_bounding_box())
|
||||
.reduce(|[a_min, a_max], [b_min, b_max]| [a_min.min(b_min), a_max.max(b_max)]);
|
||||
Self {
|
||||
target_type: ClickTargetType::CompoundPath(subpaths),
|
||||
target_type: ClickTargetType::Path(path),
|
||||
stroke_width,
|
||||
bounding_box,
|
||||
bounding_box_cache: Default::default(),
|
||||
@@ -190,10 +204,10 @@ impl ClickTarget {
|
||||
|
||||
pub fn bounding_box_with_transform(&self, transform: DAffine2) -> BoundingBox {
|
||||
match self.target_type {
|
||||
ClickTargetType::Subpath(ref subpath) => {
|
||||
ClickTargetType::Path(ref path) => {
|
||||
// Bypass cache for skewed transforms since rotation decomposition isn't valid
|
||||
if transform.has_skew() {
|
||||
return subpath.bounding_box_with_transform(transform);
|
||||
return bezpath_bounding_box_with_transform(path, transform);
|
||||
}
|
||||
|
||||
// Decompose transform into rotation, scale, translation for caching strategy
|
||||
@@ -213,12 +227,8 @@ impl ClickTarget {
|
||||
|
||||
// Cache miss - compute and store new entry
|
||||
let mut write_lock = self.bounding_box_cache.write().unwrap();
|
||||
write_lock.add_to_cache(subpath, rotation, scale, translation, fingerprint)
|
||||
write_lock.add_to_cache(path, rotation, scale, translation, fingerprint)
|
||||
}
|
||||
ClickTargetType::CompoundPath(ref subpaths) => subpaths
|
||||
.iter()
|
||||
.filter_map(|subpath| subpath.bounding_box_with_transform(transform))
|
||||
.reduce(|[a_min, a_max], [b_min, b_max]| [a_min.min(b_min), a_max.max(b_max)]),
|
||||
// TODO: use point for calculation of bbox
|
||||
ClickTargetType::FreePoint(_) => self.bounding_box.map(|[a, b]| [transform.transform_point2(a), transform.transform_point2(b)]),
|
||||
}
|
||||
@@ -226,13 +236,8 @@ impl ClickTarget {
|
||||
|
||||
pub fn apply_transform(&mut self, affine_transform: DAffine2) {
|
||||
match self.target_type {
|
||||
ClickTargetType::Subpath(ref mut subpath) => {
|
||||
subpath.apply_transform(affine_transform);
|
||||
}
|
||||
ClickTargetType::CompoundPath(ref mut subpaths) => {
|
||||
for subpath in subpaths {
|
||||
subpath.apply_transform(affine_transform);
|
||||
}
|
||||
ClickTargetType::Path(ref mut path) => {
|
||||
path.apply_affine(Affine::new(affine_transform.to_cols_array()));
|
||||
}
|
||||
ClickTargetType::FreePoint(ref mut point) => {
|
||||
point.apply_transform(affine_transform);
|
||||
@@ -243,14 +248,8 @@ impl ClickTarget {
|
||||
|
||||
fn update_bbox(&mut self) {
|
||||
match self.target_type {
|
||||
ClickTargetType::Subpath(ref subpath) => {
|
||||
self.bounding_box = subpath.bounding_box();
|
||||
}
|
||||
ClickTargetType::CompoundPath(ref subpaths) => {
|
||||
self.bounding_box = subpaths
|
||||
.iter()
|
||||
.filter_map(|subpath| subpath.bounding_box())
|
||||
.reduce(|[a_min, a_max], [b_min, b_max]| [a_min.min(b_min), a_max.max(b_max)]);
|
||||
ClickTargetType::Path(ref path) => {
|
||||
self.bounding_box = bezpath_bounding_box_with_transform(path, DAffine2::IDENTITY);
|
||||
}
|
||||
ClickTargetType::FreePoint(ref point) => {
|
||||
self.bounding_box = Some([point.position - DVec2::splat(self.stroke_width / 2.), point.position + DVec2::splat(self.stroke_width / 2.)]);
|
||||
@@ -270,43 +269,26 @@ impl ClickTarget {
|
||||
let mut bezier_iter = || bezier_iter().map(|bezier| Affine::new(inverse.to_cols_array()) * bezier);
|
||||
|
||||
match self.target_type() {
|
||||
ClickTargetType::Subpath(subpath) => {
|
||||
// Check if outlines intersect
|
||||
let outline_intersects = |path_segment: PathSeg| bezier_iter().any(|line| !filtered_segment_intersections(path_segment, line, None, None).is_empty());
|
||||
if subpath.iter().any(outline_intersects) {
|
||||
return true;
|
||||
}
|
||||
// Check if selection is entirely within the shape
|
||||
if subpath.closed() && bezier_iter().next().is_some_and(|bezier| subpath.contains_point(point_to_dvec2(bezier.start()))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut selection = BezPath::from_path_segments(bezier_iter());
|
||||
selection.close_path();
|
||||
|
||||
// Check if shape is entirely within selection
|
||||
bezpath_is_inside_bezpath(&subpath.to_bezpath(), &selection, None, None)
|
||||
}
|
||||
ClickTargetType::CompoundPath(subpaths) => {
|
||||
ClickTargetType::Path(path) => {
|
||||
// Outline intersection (catches strokes and both filled/unfilled shapes)
|
||||
let outline_intersects = |path_segment: PathSeg| bezier_iter().any(|line| !filtered_segment_intersections(path_segment, line, None, None).is_empty());
|
||||
if subpaths.iter().flat_map(|subpath| subpath.iter()).any(outline_intersects) {
|
||||
if path.segments().any(outline_intersects) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Selection point inside compound fill (non-zero rule).
|
||||
// Only closed subpaths contribute to the fill region; open segments would otherwise produce spurious winding on one side of the segment.
|
||||
let combined: BezPath = subpaths.iter().filter(|subpath| subpath.closed()).flat_map(|subpath| subpath.to_bezpath()).collect();
|
||||
if !combined.is_empty() && bezier_iter().next().is_some_and(|bezier| combined.contains(bezier.start())) {
|
||||
// Selection point inside the fill (non-zero rule).
|
||||
// Only closed contours contribute to the fill region; open segments would otherwise produce spurious winding on one side of the segment.
|
||||
let fill_region = closed_contours(path);
|
||||
if !fill_region.is_empty() && bezier_iter().next().is_some_and(|segment| fill_region.contains(segment.start())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Build closed selection path, then check if all contours are entirely within it
|
||||
// Build closed selection path, then check if the whole shape is entirely within it
|
||||
let mut selection = BezPath::from_path_segments(bezier_iter());
|
||||
selection.close_path();
|
||||
subpaths.iter().all(|subpath| bezpath_is_inside_bezpath(&subpath.to_bezpath(), &selection, None, None))
|
||||
bezpath_is_inside_bezpath(path, &selection, None, None)
|
||||
}
|
||||
ClickTargetType::FreePoint(point) => bezier_iter().map(|bezier: PathSeg| bezier.winding(dvec2_to_point(point.position))).sum::<i32>() != 0,
|
||||
ClickTargetType::FreePoint(point) => bezier_iter().map(|segment: PathSeg| segment.winding(dvec2_to_point(point.position))).sum::<i32>() != 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,11 +319,7 @@ impl ClickTarget {
|
||||
{
|
||||
// Check if the point is within the shape
|
||||
match self.target_type() {
|
||||
ClickTargetType::Subpath(subpath) => subpath.closed() && subpath.contains_point(point),
|
||||
ClickTargetType::CompoundPath(subpaths) => {
|
||||
let combined: BezPath = subpaths.iter().flat_map(|subpath| subpath.to_bezpath()).collect();
|
||||
combined.contains(dvec2_to_point(point))
|
||||
}
|
||||
ClickTargetType::Path(path) => closed_contours(path).contains(dvec2_to_point(point)),
|
||||
ClickTargetType::FreePoint(free_point) => free_point.position == point,
|
||||
}
|
||||
} else {
|
||||
@@ -353,10 +331,14 @@ impl ClickTarget {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::subpath::Subpath;
|
||||
use glam::DVec2;
|
||||
use kurbo::{DEFAULT_ACCURACY, Rect};
|
||||
use std::f64::consts::PI;
|
||||
|
||||
fn rectangle_path(corner1: DVec2, corner2: DVec2) -> BezPath {
|
||||
Rect::new(corner1.x, corner1.y, corner2.x, corner2.y).to_path(DEFAULT_ACCURACY)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bounding_box_cache_fingerprint_generation() {
|
||||
// Test that fingerprints have MSB set and use only 7 bits for data
|
||||
@@ -386,8 +368,8 @@ mod tests {
|
||||
fn test_bounding_box_cache_basic_operations() {
|
||||
let mut cache = BoundingBoxCache::default();
|
||||
|
||||
// Create a simple rectangle subpath for testing
|
||||
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::new(100., 50.));
|
||||
// Create a simple rectangle path for testing
|
||||
let path = rectangle_path(DVec2::ZERO, DVec2::new(100., 50.));
|
||||
|
||||
let rotation = PI / 4.;
|
||||
let scale = DVec2::new(2., 2.);
|
||||
@@ -398,7 +380,7 @@ mod tests {
|
||||
assert!(cache.try_read(rotation, scale, translation, fingerprint).is_none());
|
||||
|
||||
// Add to cache
|
||||
let result = cache.add_to_cache(&subpath, rotation, scale, translation, fingerprint);
|
||||
let result = cache.add_to_cache(&path, rotation, scale, translation, fingerprint);
|
||||
assert!(result.is_some());
|
||||
|
||||
// Should now be able to read from cache
|
||||
@@ -410,7 +392,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_bounding_box_cache_ring_buffer_behavior() {
|
||||
let mut cache = BoundingBoxCache::default();
|
||||
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::new(10., 10.));
|
||||
let path = rectangle_path(DVec2::ZERO, DVec2::new(10., 10.));
|
||||
let scale = DVec2::ONE;
|
||||
let translation = DVec2::ZERO;
|
||||
|
||||
@@ -419,7 +401,7 @@ mod tests {
|
||||
|
||||
for rotation in &rotations {
|
||||
let fingerprint = BoundingBoxCache::rotation_fingerprint(*rotation);
|
||||
cache.add_to_cache(&subpath, *rotation, scale, translation, fingerprint);
|
||||
cache.add_to_cache(&path, *rotation, scale, translation, fingerprint);
|
||||
}
|
||||
|
||||
// First two entries should be overwritten (cache size is 8)
|
||||
@@ -435,8 +417,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_click_target_bounding_box_caching() {
|
||||
// Create a click target with a simple rectangle
|
||||
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::new(100., 50.));
|
||||
let click_target = ClickTarget::new_with_subpath(subpath, 1.);
|
||||
let path = rectangle_path(DVec2::ZERO, DVec2::new(100., 50.));
|
||||
let click_target = ClickTarget::new_with_path(path, 1.);
|
||||
|
||||
let rotation = PI / 6.;
|
||||
let scale = DVec2::new(1.5, 1.5);
|
||||
@@ -472,8 +454,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_click_target_skew_bypass_cache() {
|
||||
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::new(100., 50.));
|
||||
let click_target = ClickTarget::new_with_subpath(subpath.clone(), 1.);
|
||||
let path = rectangle_path(DVec2::ZERO, DVec2::new(100., 50.));
|
||||
let click_target = ClickTarget::new_with_path(path.clone(), 1.);
|
||||
|
||||
// Create a transform with skew (non-uniform scaling in different directions)
|
||||
let skew_transform = DAffine2::from_cols_array(&[2., 0.5, 0., 1., 10., 20.]);
|
||||
@@ -481,14 +463,14 @@ mod tests {
|
||||
|
||||
// Should bypass cache and compute directly
|
||||
let result = click_target.bounding_box_with_transform(skew_transform);
|
||||
let expected = subpath.bounding_box_with_transform(skew_transform);
|
||||
let expected = bezpath_bounding_box_with_transform(&path, skew_transform);
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_fingerprint_collision_handling() {
|
||||
let mut cache = BoundingBoxCache::default();
|
||||
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::new(10., 10.));
|
||||
let path = rectangle_path(DVec2::ZERO, DVec2::new(10., 10.));
|
||||
let scale = DVec2::ONE;
|
||||
let translation = DVec2::ZERO;
|
||||
|
||||
@@ -501,7 +483,7 @@ mod tests {
|
||||
// If we found a collision, test that exact rotation matching still works
|
||||
if fp1 == fp2 && rotation1 != rotation2 {
|
||||
// Add first rotation
|
||||
cache.add_to_cache(&subpath, rotation1, scale, translation, fp1);
|
||||
cache.add_to_cache(&path, rotation1, scale, translation, fp1);
|
||||
|
||||
// Should find the exact rotation
|
||||
assert!(cache.try_read(rotation1, scale, translation, fp1).is_some());
|
||||
|
||||
Reference in New Issue
Block a user