refactor click targets

This commit is contained in:
indierusty
2025-08-06 14:14:29 +05:30
parent ca32238548
commit c9e7c1253a
18 changed files with 288 additions and 220 deletions

View File

@@ -1,8 +1,10 @@
use crate::math::math_ext::QuadExt;
use crate::math::quad::Quad;
use crate::vector::PointId;
use bezier_rs::Subpath;
use crate::vector::misc::rect_with_size;
use glam::{DAffine2, DMat2, DVec2};
use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, PathSeg, Rect, Shape};
use super::algorithms::intersection::bezpath_and_segment_intersections;
use super::misc::{bezpath_loose_bounding_box, dvec2_to_point, is_bezpath_closed, pathseg_to_points, point_to_dvec2, rect_to_minmax, transform_rect};
#[derive(Copy, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FreePoint {
@@ -22,7 +24,7 @@ impl FreePoint {
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum ClickTargetType {
Subpath(Subpath<PointId>),
BezPath(BezPath),
FreePoint(FreePoint),
}
@@ -31,14 +33,14 @@ pub enum ClickTargetType {
pub struct ClickTarget {
target_type: ClickTargetType,
stroke_width: f64,
bounding_box: Option<[DVec2; 2]>,
bounding_box: Option<Rect>,
}
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_bezpath(bezpath: BezPath, stroke_width: f64) -> Self {
let bounding_box = bezpath_loose_bounding_box(&bezpath);
Self {
target_type: ClickTargetType::Subpath(subpath),
target_type: ClickTargetType::BezPath(bezpath),
stroke_width,
bounding_box,
}
@@ -47,10 +49,8 @@ impl ClickTarget {
pub fn new_with_free_point(point: FreePoint) -> Self {
const MAX_LENGTH_FOR_NO_WIDTH_OR_HEIGHT: f64 = 1e-4 / 2.;
let stroke_width = 10.;
let bounding_box = Some([
point.position - DVec2::splat(MAX_LENGTH_FOR_NO_WIDTH_OR_HEIGHT),
point.position + DVec2::splat(MAX_LENGTH_FOR_NO_WIDTH_OR_HEIGHT),
]);
let bounding_box = Some(rect_with_size(point.position, MAX_LENGTH_FOR_NO_WIDTH_OR_HEIGHT));
Self {
target_type: ClickTargetType::FreePoint(point),
@@ -64,21 +64,26 @@ impl ClickTarget {
}
pub fn bounding_box(&self) -> Option<[DVec2; 2]> {
self.bounding_box
self.bounding_box.map(|bbox| rect_to_minmax(bbox))
}
pub fn bounding_box_center(&self) -> Option<DVec2> {
self.bounding_box.map(|bbox| bbox[0] + (bbox[1] - bbox[0]) / 2.)
self.bounding_box.map(|bbox| point_to_dvec2(bbox.center()))
}
pub fn bounding_box_with_transform(&self, transform: DAffine2) -> Option<[DVec2; 2]> {
self.bounding_box.map(|[a, b]| [transform.transform_point2(a), transform.transform_point2(b)])
self.bounding_box.map(|bbox| {
[
transform.transform_point2(DVec2::new(bbox.min_x(), bbox.min_y())),
transform.transform_point2(DVec2::new(bbox.max_x(), bbox.max_y())),
]
})
}
pub fn apply_transform(&mut self, affine_transform: DAffine2) {
match self.target_type {
ClickTargetType::Subpath(ref mut subpath) => {
subpath.apply_transform(affine_transform);
ClickTargetType::BezPath(ref mut subpath) => {
subpath.apply_affine(Affine::new(affine_transform.to_cols_array()));
}
ClickTargetType::FreePoint(ref mut point) => {
point.apply_transform(affine_transform);
@@ -89,17 +94,17 @@ impl ClickTarget {
fn update_bbox(&mut self) {
match self.target_type {
ClickTargetType::Subpath(ref subpath) => {
self.bounding_box = subpath.bounding_box();
ClickTargetType::BezPath(ref subpath) => {
self.bounding_box = Some(subpath.bounding_box());
}
ClickTargetType::FreePoint(ref point) => {
self.bounding_box = Some([point.position - DVec2::splat(self.stroke_width / 2.), point.position + DVec2::splat(self.stroke_width / 2.)]);
self.bounding_box = Some(rect_with_size(point.position, self.stroke_width));
}
}
}
/// Does the click target intersect the path
pub fn intersect_path<It: Iterator<Item = bezier_rs::Bezier>>(&self, mut bezier_iter: impl FnMut() -> It, layer_transform: DAffine2) -> bool {
pub fn intersect_path(&self, mut selection_bezpath: BezPath, layer_transform: DAffine2) -> bool {
// Check if the matrix is not invertible
let mut layer_transform = layer_transform;
if layer_transform.matrix2.determinant().abs() <= f64::EPSILON {
@@ -107,56 +112,39 @@ impl ClickTarget {
}
let inverse = layer_transform.inverse();
let mut bezier_iter = || bezier_iter().map(|bezier| bezier.apply_transformation(|point| inverse.transform_point2(point)));
selection_bezpath.apply_affine(Affine::new(inverse.to_cols_array()));
match self.target_type() {
ClickTargetType::Subpath(subpath) => {
// Check if outlines intersect
let outline_intersects = |path_segment: bezier_rs::Bezier| bezier_iter().any(|line| !path_segment.intersections(&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(bezier.start)) {
return true;
}
ClickTargetType::BezPath(click_target_bezpath) => {
let inside = |segment: PathSeg| pathseg_to_points(segment).iter().filter_map(|point| *point).all(|point| selection_bezpath.contains(point));
let intersects = |segment: PathSeg| !bezpath_and_segment_intersections(&selection_bezpath, segment, None, None).is_empty();
// Check if shape is entirely within selection
let any_point_from_subpath = subpath.manipulator_groups().first().map(|group| group.anchor);
any_point_from_subpath.is_some_and(|shape_point| bezier_iter().map(|bezier| bezier.winding(shape_point)).sum::<i32>() != 0)
click_target_bezpath.segments().any(|target_segment| inside(target_segment)) || click_target_bezpath.segments().any(|target_segment| intersects(target_segment))
}
ClickTargetType::FreePoint(point) => bezier_iter().map(|bezier: bezier_rs::Bezier| bezier.winding(point.position)).sum::<i32>() != 0,
ClickTargetType::FreePoint(point) => selection_bezpath.contains(dvec2_to_point(point.position)),
}
}
/// Does the click target intersect the point (accounting for stroke size)
pub fn intersect_point(&self, point: DVec2, layer_transform: DAffine2) -> bool {
let target_bounds = [point - DVec2::splat(self.stroke_width / 2.), point + DVec2::splat(self.stroke_width / 2.)];
let intersects = |a: [DVec2; 2], b: [DVec2; 2]| a[0].x <= b[1].x && a[1].x >= b[0].x && a[0].y <= b[1].y && a[1].y >= b[0].y;
let target_bounds = rect_with_size(point, self.stroke_width);
// This bounding box is not very accurate as it is the axis aligned version of the transformed bounding box. However it is fast.
if !self
.bounding_box
.is_some_and(|loose| (loose[0] - loose[1]).abs().cmpgt(DVec2::splat(1e-4)).any() && intersects((layer_transform * Quad::from_box(loose)).bounding_box(), target_bounds))
{
if !self.bounding_box.is_some_and(|bbox| (transform_rect(bbox, layer_transform)).overlaps(target_bounds)) {
return false;
}
// Allows for selecting lines
// TODO: actual intersection of stroke
let inflated_quad = Quad::from_box(target_bounds);
self.intersect_path(|| inflated_quad.bezier_lines(), layer_transform)
self.intersect_path(target_bounds.to_path(DEFAULT_ACCURACY), layer_transform)
}
/// Does the click target intersect the point (not accounting for stroke size)
pub fn intersect_point_no_stroke(&self, point: DVec2) -> bool {
// Check if the point is within the bounding box
if self
.bounding_box
.is_some_and(|bbox| bbox[0].x <= point.x && point.x <= bbox[1].x && bbox[0].y <= point.y && point.y <= bbox[1].y)
{
if self.bounding_box.is_some_and(|bbox| bbox.contains(dvec2_to_point(point))) {
// Check if the point is within the shape
match self.target_type() {
ClickTargetType::Subpath(subpath) => subpath.closed() && subpath.contains_point(point),
ClickTargetType::BezPath(bezpath) => is_bezpath_closed(bezpath) && bezpath.contains(dvec2_to_point(point)),
ClickTargetType::FreePoint(free_point) => free_point.position == point,
}
} else {

View File

@@ -3,8 +3,8 @@ use super::algorithms::offset_subpath::MAX_ABSOLUTE_DIFFERENCE;
use crate::vector::{SegmentId, Vector};
use bezier_rs::{BezierHandles, ManipulatorGroup, Subpath};
use dyn_any::DynAny;
use glam::DVec2;
use kurbo::{BezPath, CubicBez, Line, ParamCurve, PathSeg, Point, QuadBez, Rect};
use glam::{DAffine2, DVec2};
use kurbo::{BezPath, CubicBez, Line, ParamCurve, PathEl, PathSeg, Point, QuadBez, Rect};
use std::ops::Sub;
/// Represents different ways of calculating the centroid.
@@ -204,7 +204,7 @@ pub fn bezpath_to_manipulator_groups(bezpath: &BezPath) -> (Vec<ManipulatorGroup
(manipulator_groups, is_closed)
}
fn pathseg_to_points(segment: PathSeg) -> [Option<Point>; 4] {
pub fn pathseg_to_points(segment: PathSeg) -> [Option<Point>; 4] {
match segment {
PathSeg::Line(line) => [Some(line.p0), None, None, Some(line.p1)],
PathSeg::Quad(quad_bez) => [Some(quad_bez.p0), None, Some(quad_bez.p1), Some(quad_bez.p2)],
@@ -224,6 +224,32 @@ pub fn bezpath_loose_bounding_box(bezpath: &BezPath) -> Option<Rect> {
.reduce(|bbox1, bbox2| combine(bbox1, bbox2))
}
pub fn is_bezpath_closed(bezpath: &BezPath) -> bool {
bezpath.elements().last().is_some_and(|el| *el == PathEl::ClosePath)
}
pub fn combine_rect(r1: Rect, r2: Rect) -> Rect {
Rect::new(r1.x0.min(r2.x0), r1.y0.min(r2.y0), r1.x1.max(r2.x1), r1.y1.max(r2.y1))
}
pub fn rect_from_minmax(minmax: [DVec2; 2]) -> Rect {
Rect::new(minmax[0].x, minmax[0].y, minmax[1].x, minmax[1].y)
}
pub fn rect_to_minmax(rect: Rect) -> [DVec2; 2] {
[DVec2::new(rect.min_x(), rect.min_y()), DVec2::new(rect.max_x(), rect.max_y())]
}
pub fn transform_rect(rect: Rect, transform: DAffine2) -> Rect {
let min = transform.transform_point2(DVec2::new(rect.x0, rect.y0));
let max = transform.transform_point2(DVec2::new(rect.x1, rect.y1));
Rect::new(min.x, min.y, max.x, max.y)
}
pub fn rect_with_size(point: DVec2, size: f64) -> Rect {
Rect::new(point.x - size / 2., point.y - size / 2., point.x + size / 2., point.y + size / 2.)
}
/// Returns true if the [`PathSeg`] is equivalent to a line.
///
/// This is different from simply checking if the segment is [`PathSeg::Line`] or [`PathSeg::Quad`] or [`PathSeg::Cubic`]. Bezier curve can also be a line if the control points are colinear to the start and end points. Therefore if the handles exceed the start and end point, it will still be considered as a line.

View File

@@ -157,7 +157,7 @@ impl Vector {
for target_type in target_types.into_iter() {
match target_type.borrow() {
ClickTargetType::Subpath(subpath) => vector.append_subpath(subpath, preserve_id),
ClickTargetType::BezPath(bezpath) => vector.append_bezpath(bezpath.clone()),
ClickTargetType::FreePoint(point) => vector.append_free_point(point, preserve_id),
}
}

View File

@@ -1,6 +1,5 @@
use crate::render_ext::RenderExt;
use crate::to_peniko::BlendModeExt;
use bezier_rs::Subpath;
use dyn_any::DynAny;
use glam::{DAffine2, DVec2};
use graphene_core::blending::BlendMode;
@@ -15,6 +14,7 @@ use graphene_core::transform::{Footprint, Transform};
use graphene_core::uuid::{NodeId, generate_uuid};
use graphene_core::vector::Vector;
use graphene_core::vector::click_target::{ClickTarget, FreePoint};
use graphene_core::vector::misc::is_bezpath_closed;
use graphene_core::vector::style::{Fill, Stroke, StrokeAlign, ViewMode};
use graphene_core::{Artboard, Graphic};
use num_traits::Zero;
@@ -23,6 +23,7 @@ use std::fmt::Write;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::ops::Deref;
use std::sync::{Arc, LazyLock};
use vello::kurbo::{BezPath, DEFAULT_ACCURACY, Rect, Shape};
#[cfg(feature = "vello")]
use vello::*;
@@ -735,11 +736,11 @@ impl Render for Table<Vector> {
if let Some(element_id) = element_id {
let stroke_width = vector.style.stroke().as_ref().map_or(0., Stroke::weight);
let filled = vector.style.fill() != &Fill::None;
let fill = |mut subpath: Subpath<_>| {
if filled {
subpath.set_closed(true);
let fill = |mut bezpath: BezPath| {
if filled && !is_bezpath_closed(&bezpath) {
bezpath.close_path();
}
subpath
bezpath
};
// For free-floating anchors, we need to add a click target for each
@@ -755,9 +756,9 @@ impl Render for Table<Vector> {
});
let click_targets = vector
.stroke_bezier_paths()
.stroke_bezpath_iter()
.map(fill)
.map(|subpath| ClickTarget::new_with_subpath(subpath, stroke_width))
.map(|bezpath| ClickTarget::new_with_bezpath(bezpath, stroke_width))
.chain(single_anchors_targets.into_iter())
.collect::<Vec<ClickTarget>>();
@@ -775,14 +776,14 @@ impl Render for Table<Vector> {
for row in self.iter() {
let stroke_width = row.element.style.stroke().as_ref().map_or(0., Stroke::weight);
let filled = row.element.style.fill() != &Fill::None;
let fill = |mut subpath: Subpath<_>| {
if filled {
subpath.set_closed(true);
let fill = |mut bezpath: BezPath| {
if filled && !is_bezpath_closed(&bezpath) {
bezpath.close_path();
}
subpath
bezpath
};
click_targets.extend(row.element.stroke_bezier_paths().map(fill).map(|subpath| {
let mut click_target = ClickTarget::new_with_subpath(subpath, stroke_width);
click_targets.extend(row.element.stroke_bezpath_iter().map(fill).map(|subpath| {
let mut click_target = ClickTarget::new_with_bezpath(subpath, stroke_width);
click_target.apply_transform(*row.transform);
click_target
}));
@@ -885,8 +886,8 @@ impl Render for Artboard {
fn collect_metadata(&self, metadata: &mut RenderMetadata, mut footprint: Footprint, element_id: Option<NodeId>) {
if let Some(element_id) = element_id {
let subpath = Subpath::new_rect(DVec2::ZERO, self.dimensions.as_dvec2());
metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.)]);
let bezpath = Rect::new(0., 0., self.dimensions.x as f64, self.dimensions.y as f64).to_path(DEFAULT_ACCURACY);
metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_bezpath(bezpath, 0.)]);
metadata.upstream_footprints.insert(element_id, footprint);
metadata.local_transforms.insert(element_id, DAffine2::from_translation(self.location.as_dvec2()));
if self.clip {
@@ -898,8 +899,8 @@ impl Render for Artboard {
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
let subpath_rectangle = Subpath::new_rect(DVec2::ZERO, self.dimensions.as_dvec2());
click_targets.push(ClickTarget::new_with_subpath(subpath_rectangle, 0.));
let bezpath_rectangle = Rect::new(0., 0., self.dimensions.x as f64, self.dimensions.y as f64).to_path(DEFAULT_ACCURACY);
click_targets.push(ClickTarget::new_with_bezpath(bezpath_rectangle, 0.));
}
fn contains_artboard(&self) -> bool {
@@ -1063,9 +1064,9 @@ impl Render for Table<Raster<CPU>> {
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>) {
let Some(element_id) = element_id else { return };
let subpath = Subpath::new_rect(DVec2::ZERO, DVec2::ONE);
let bezpath = Rect::new(0., 0., 1., 1.).to_path(DEFAULT_ACCURACY);
metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.)]);
metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_bezpath(bezpath, 0.)]);
metadata.upstream_footprints.insert(element_id, footprint);
// TODO: Find a way to handle more than one row of the graphical data table
if let Some(image) = self.iter().next() {
@@ -1074,8 +1075,8 @@ impl Render for Table<Raster<CPU>> {
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
let subpath = Subpath::new_rect(DVec2::ZERO, DVec2::ONE);
click_targets.push(ClickTarget::new_with_subpath(subpath, 0.));
let bezpath = Rect::new(0., 0., 1., 1.).to_path(DEFAULT_ACCURACY);
click_targets.push(ClickTarget::new_with_bezpath(bezpath, 0.));
}
}
@@ -1121,9 +1122,9 @@ impl Render for Table<Raster<GPU>> {
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>) {
let Some(element_id) = element_id else { return };
let subpath = Subpath::new_rect(DVec2::ZERO, DVec2::ONE);
let bezpath = Rect::new(0., 0., 1., 1.).to_path(DEFAULT_ACCURACY);
metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.)]);
metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_bezpath(bezpath, 0.)]);
metadata.upstream_footprints.insert(element_id, footprint);
// TODO: Find a way to handle more than one row of the graphical data table
if let Some(image) = self.iter().next() {
@@ -1132,8 +1133,8 @@ impl Render for Table<Raster<GPU>> {
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
let subpath = Subpath::new_rect(DVec2::ZERO, DVec2::ONE);
click_targets.push(ClickTarget::new_with_subpath(subpath, 0.));
let bezpath = Rect::new(0., 0., 1., 1.).to_path(DEFAULT_ACCURACY);
click_targets.push(ClickTarget::new_with_bezpath(bezpath, 0.));
}
}