From 8f1b2bed5ff327af653bf98dca02b66b337f1f6c Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 18 Aug 2026 14:31:52 -0700 Subject: [PATCH] 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 --- .../document/document_message_handler.rs | 24 +-- .../document/overlays/utility_functions.rs | 10 +- .../document/overlays/utility_types_native.rs | 82 +++------ .../document/overlays/utility_types_web.rs | 79 ++++----- .../utility_types/document_metadata.rs | 25 +-- .../utility_types/network_interface.rs | 3 +- .../utility_types/network_interface/caches.rs | 36 ++-- .../network_interface/hit_tests.rs | 35 ++-- .../network_interface/structure.rs | 9 +- .../utility_types/network_interface/types.rs | 32 +++- .../shapes/shape_utility.rs | 21 ++- .../snapping/layer_snapper.rs | 145 +++++++++++++--- .../messages/tool/tool_messages/fill_tool.rs | 23 ++- .../messages/tool/tool_messages/pen_tool.rs | 29 ++-- .../libraries/rendering/src/renderer.rs | 81 +++++---- .../vector-types/src/vector/click_target.rs | 164 ++++++++---------- 16 files changed, 438 insertions(+), 360 deletions(-) diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index aa1e4c1e93..82f9bbfa82 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -42,6 +42,7 @@ use graphene_std::math::quad::Quad; use graphene_std::path_bool_nodes::boolean_intersect; use graphene_std::raster::BlendMode; use graphene_std::subpath::Subpath; +use graphene_std::vector::algorithms::bezpath_algorithms::bezpath_is_inside_bezpath; use graphene_std::vector::click_target::{ClickTarget, ClickTargetType}; use graphene_std::vector::misc::dvec2_to_point; use graphene_std::vector::style::RenderMode; @@ -1876,18 +1877,15 @@ impl DocumentMessageHandler { let layer_click_targets = self.network_interface.document_metadata().click_targets(*layer); let layer_transform = self.network_interface.document_metadata().transform_to_document(*layer); + let viewport_polygon_bezpath = viewport_polygon.to_bezpath(); + layer_click_targets.is_some_and(|targets| { targets.iter().all(|target| match target.target_type() { - ClickTargetType::Subpath(subpath) => { - let mut subpath = subpath.clone(); - subpath.apply_transform(layer_transform); - subpath.is_inside_subpath(&viewport_polygon, None, None) + ClickTargetType::Path(path) => { + let mut path = path.clone(); + path.apply_affine(Affine::new(layer_transform.to_cols_array())); + bezpath_is_inside_bezpath(&path, &viewport_polygon_bezpath, None, None) } - ClickTargetType::CompoundPath(subpaths) => subpaths.iter().all(|subpath| { - let mut subpath = subpath.clone(); - subpath.apply_transform(layer_transform); - subpath.is_inside_subpath(&viewport_polygon, None, None) - }), ClickTargetType::FreePoint(point) => { let mut point = *point; point.apply_transform(layer_transform); @@ -3814,13 +3812,7 @@ fn quad_to_kurbo(quad: Quad) -> BezPath { fn click_targets_to_kurbo<'a>(click_targets: impl Iterator, transform: DAffine2) -> BezPath { let segments = click_targets - .filter_map(|target| { - if let ClickTargetType::Subpath(subpath) = target.target_type() { - Some(subpath.iter()) - } else { - None - } - }) + .filter_map(|target| if let ClickTargetType::Path(path) = target.target_type() { Some(path.segments()) } else { None }) .flatten() .map(|bezier| Affine::new(transform.to_cols_array()) * bezier); BezPath::from_path_segments(segments) diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs index e26b4a73b0..05e91923ab 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs @@ -109,12 +109,10 @@ fn overlay_bezier_handle_specific_point( let not_under_anchor = |position: DVec2, anchor: DVec2| position.distance_squared(anchor) >= HIDE_HANDLE_DISTANCE * HIDE_HANDLE_DISTANCE; match segment_to_handles(&segment) { - BezierHandles::Quadratic { handle } => { - if not_under_anchor(handle, segment_start) && not_under_anchor(handle, segment_end) { - let anchor = if start == point_to_render { segment_start } else { segment_end }; - overlay_context.line(handle, anchor, None, None); - overlay_context.manipulator_handle(handle, is_selected(ManipulatorPointId::PrimaryHandle(segment_id)), None); - } + BezierHandles::Quadratic { handle } if not_under_anchor(handle, segment_start) && not_under_anchor(handle, segment_end) => { + let anchor = if start == point_to_render { segment_start } else { segment_end }; + overlay_context.line(handle, anchor, None, None); + overlay_context.manipulator_handle(handle, is_selected(ManipulatorPointId::PrimaryHandle(segment_id)), None); } BezierHandles::Cubic { handle_start, handle_end } => { if not_under_anchor(handle_start, segment_start) && (point_to_render == start) { diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs index 1781aa9105..702c9dd83f 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs @@ -15,7 +15,6 @@ use glam::{DAffine2, DVec2}; use graphene_std::ATTR_TRANSFORM; use graphene_std::list::List; use graphene_std::math::quad::Quad; -use graphene_std::subpath::{self, Subpath}; use graphene_std::text::{TextAlign, TextContext, TypesettingConfig}; use graphene_std::vector::click_target::ClickTargetType; use graphene_std::vector::misc::point_to_dvec2; @@ -404,14 +403,14 @@ impl OverlayContext { /// Fills the area inside the path. Assumes `color` is in gamma space. /// Used by the Pen tool to show the path being closed. - pub fn fill_path(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &str) { - self.internal().fill_path(subpaths, transform, color); + pub fn fill_path(&mut self, bezpath: &BezPath, transform: DAffine2, color: &str) { + self.internal().fill_path(bezpath, transform, color); } /// Fills the area inside the path with a pattern. Assumes `color` is an sRGB hex string. /// Used by the fill tool to show the area to be filled. - pub fn fill_path_pattern(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &str) { - self.internal().fill_path_pattern(subpaths, transform, color); + pub fn fill_path_pattern(&mut self, bezpath: &BezPath, transform: DAffine2, color: &str) { + self.internal().fill_path_pattern(bezpath, transform, color); } pub fn text(&self, text: &str, font_color: &str, background_color: Option<&str>, transform: DAffine2, padding: f64, pivot: [Pivot; 2]) { @@ -978,49 +977,25 @@ impl OverlayContextInternal { path.push(bezier.as_path_el()); } - fn push_path(&mut self, subpaths: impl Iterator>>, transform: DAffine2) -> BezPath { + fn push_path(&mut self, bezpath: &BezPath, transform: DAffine2) -> BezPath { let mut path = BezPath::new(); - for subpath in subpaths { - let subpath = subpath.borrow(); - let mut curves = subpath.iter().peekable(); + let snap_start = |context: &Self, point: kurbo::Point| { + let snapped = context.snap_to_physical_pixel(transform.transform_point2(point_to_dvec2(point))); + kurbo::Point::new(snapped.x, snapped.y) + }; + let snap_center = |context: &Self, point: kurbo::Point| { + let snapped = context.snap_to_physical_pixel_center(transform.transform_point2(point_to_dvec2(point))); + kurbo::Point::new(snapped.x, snapped.y) + }; - let Some(first) = curves.peek() else { - continue; - }; - - let start_point = transform.transform_point2(point_to_dvec2(first.start())); - let start_point = self.snap_to_physical_pixel(start_point); - path.move_to(kurbo::Point::new(start_point.x, start_point.y)); - - for curve in curves { - match curve { - PathSeg::Line(line) => { - let a = transform.transform_point2(point_to_dvec2(line.p1)); - let a = self.snap_to_physical_pixel_center(a); - path.line_to(kurbo::Point::new(a.x, a.y)); - } - PathSeg::Quad(quad_bez) => { - let a = transform.transform_point2(point_to_dvec2(quad_bez.p1)); - let b = transform.transform_point2(point_to_dvec2(quad_bez.p2)); - let a = self.snap_to_physical_pixel_center(a); - let b = self.snap_to_physical_pixel_center(b); - path.quad_to(kurbo::Point::new(a.x, a.y), kurbo::Point::new(b.x, b.y)); - } - PathSeg::Cubic(cubic_bez) => { - let a = transform.transform_point2(point_to_dvec2(cubic_bez.p1)); - let b = transform.transform_point2(point_to_dvec2(cubic_bez.p2)); - let c = transform.transform_point2(point_to_dvec2(cubic_bez.p3)); - let a = self.snap_to_physical_pixel_center(a); - let b = self.snap_to_physical_pixel_center(b); - let c = self.snap_to_physical_pixel_center(c); - path.curve_to(kurbo::Point::new(a.x, a.y), kurbo::Point::new(b.x, b.y), kurbo::Point::new(c.x, c.y)); - } - } - } - - if subpath.closed() { - path.close_path(); + for element in bezpath.elements() { + match *element { + kurbo::PathEl::MoveTo(point) => path.move_to(snap_start(self, point)), + kurbo::PathEl::LineTo(point) => path.line_to(snap_center(self, point)), + kurbo::PathEl::QuadTo(a, b) => path.quad_to(snap_center(self, a), snap_center(self, b)), + kurbo::PathEl::CurveTo(a, b, c) => path.curve_to(snap_center(self, a), snap_center(self, b), snap_center(self, c)), + kurbo::PathEl::ClosePath => path.close_path(), } } @@ -1029,20 +1004,19 @@ impl OverlayContextInternal { /// Used by the Select tool to outline a path or a free point when selected or hovered. fn outline(&mut self, target_types: impl Iterator>, transform: DAffine2, color: Option<&str>) { - let mut subpaths: Vec> = vec![]; + let mut combined = BezPath::new(); for target_type in target_types { match target_type.borrow() { ClickTargetType::FreePoint(point) => { self.manipulator_anchor(transform.transform_point2(point.position), false, None); } - ClickTargetType::Subpath(subpath) => subpaths.push(subpath.clone()), - ClickTargetType::CompoundPath(compound) => subpaths.extend(compound.iter().cloned()), + ClickTargetType::Path(bezpath) => combined.extend(bezpath.elements().iter().copied()), } } - if !subpaths.is_empty() { - let path = self.push_path(subpaths.iter(), transform); + if !combined.is_empty() { + let path = self.push_path(&combined, transform); let color = color.unwrap_or(COLOR_OVERLAY_BLUE); self.scene.stroke(&kurbo::Stroke::new(1.), self.get_transform(), Self::parse_color(color), None, &path); @@ -1051,15 +1025,15 @@ impl OverlayContextInternal { /// Fills the area inside the path. Assumes `color` is in gamma space. /// Used by the Pen tool to show the path being closed. - fn fill_path(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &str) { - let path = self.push_path(subpaths, transform); + fn fill_path(&mut self, bezpath: &BezPath, transform: DAffine2, color: &str) { + let path = self.push_path(bezpath, transform); self.scene.fill(peniko::Fill::NonZero, self.get_transform(), Self::parse_color(color), None, &path); } /// Fills the area inside the path with a pattern. Assumes `color` is an sRGB hex string. /// Used by the fill tool to show the area to be filled. - fn fill_path_pattern(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &str) { + fn fill_path_pattern(&mut self, bezpath: &BezPath, transform: DAffine2, color: &str) { const PATTERN_WIDTH: u32 = 4; const PATTERN_HEIGHT: u32 = 4; @@ -1096,7 +1070,7 @@ impl OverlayContextInternal { }, }; - let path = self.push_path(subpaths, transform); + let path = self.push_path(bezpath, transform); let brush = peniko::Brush::Image(image); self.scene.fill(peniko::Fill::NonZero, self.get_transform(), &brush, None, &path); diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs index 4abfd842a8..fcc342d05d 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs @@ -13,11 +13,10 @@ use core::borrow::Borrow; use core::f64::consts::{FRAC_PI_2, PI, TAU}; use glam::{DAffine2, DVec2}; use graphene_std::math::quad::Quad; -use graphene_std::subpath::Subpath; use graphene_std::vector::click_target::ClickTargetType; use graphene_std::vector::misc::{dvec2_to_point, point_to_dvec2}; use graphene_std::vector::{PointId, SegmentId, Vector}; -use kurbo::{self, Affine, CubicBez, ParamCurve, PathSeg}; +use kurbo::{self, Affine, BezPath, CubicBez, ParamCurve, PathSeg}; use std::collections::HashMap; use wasm_bindgen::{JsCast, JsValue}; use web_sys::{OffscreenCanvas, OffscreenCanvasRenderingContext2d}; @@ -931,50 +930,33 @@ impl OverlayContext { self.end_dpi_aware_transform(); } - fn push_path(&mut self, subpaths: impl Iterator>>, transform: DAffine2) { + fn push_path(&mut self, bezpath: &BezPath, transform: DAffine2) { self.start_dpi_aware_transform(); self.render_context.begin_path(); - for subpath in subpaths { - let subpath = subpath.borrow(); - let mut curves = subpath.iter().peekable(); - let Some(&first) = curves.peek() else { - continue; - }; + let snap_start = |context: &Self, point: kurbo::Point| context.snap_to_physical_pixel(transform.transform_point2(point_to_dvec2(point))); + let snap_center = |context: &Self, point: kurbo::Point| context.snap_to_physical_pixel_center(transform.transform_point2(point_to_dvec2(point))); - let start_point = transform.transform_point2(point_to_dvec2(first.start())); - let start_point = self.snap_to_physical_pixel(start_point); - self.render_context.move_to(start_point.x, start_point.y); - - for curve in curves { - match curve { - PathSeg::Line(line) => { - let a = transform.transform_point2(point_to_dvec2(line.p1)); - let a = self.snap_to_physical_pixel_center(a); - self.render_context.line_to(a.x, a.y); - } - PathSeg::Quad(quad_bez) => { - let a = transform.transform_point2(point_to_dvec2(quad_bez.p1)); - let b = transform.transform_point2(point_to_dvec2(quad_bez.p2)); - let a = self.snap_to_physical_pixel_center(a); - let b = self.snap_to_physical_pixel_center(b); - self.render_context.quadratic_curve_to(a.x, a.y, b.x, b.y); - } - PathSeg::Cubic(cubic_bez) => { - let a = transform.transform_point2(point_to_dvec2(cubic_bez.p1)); - let b = transform.transform_point2(point_to_dvec2(cubic_bez.p2)); - let c = transform.transform_point2(point_to_dvec2(cubic_bez.p3)); - let a = self.snap_to_physical_pixel_center(a); - let b = self.snap_to_physical_pixel_center(b); - let c = self.snap_to_physical_pixel_center(c); - self.render_context.bezier_curve_to(a.x, a.y, b.x, b.y, c.x, c.y); - } + for element in bezpath.elements() { + match *element { + kurbo::PathEl::MoveTo(point) => { + let point = snap_start(self, point); + self.render_context.move_to(point.x, point.y); } - } - - if subpath.closed() { - self.render_context.close_path(); + kurbo::PathEl::LineTo(point) => { + let point = snap_center(self, point); + self.render_context.line_to(point.x, point.y); + } + kurbo::PathEl::QuadTo(a, b) => { + let (a, b) = (snap_center(self, a), snap_center(self, b)); + self.render_context.quadratic_curve_to(a.x, a.y, b.x, b.y); + } + kurbo::PathEl::CurveTo(a, b, c) => { + let (a, b, c) = (snap_center(self, a), snap_center(self, b), snap_center(self, c)); + self.render_context.bezier_curve_to(a.x, a.y, b.x, b.y, c.x, c.y); + } + kurbo::PathEl::ClosePath => self.render_context.close_path(), } } @@ -983,18 +965,17 @@ impl OverlayContext { /// Used by the Select tool to outline a path or a free point when selected or hovered. pub fn outline(&mut self, target_types: impl Iterator>, transform: DAffine2, color: Option<&str>) { - let mut subpaths: Vec> = vec![]; + let mut combined = BezPath::new(); target_types.for_each(|target_type| match target_type.borrow() { ClickTargetType::FreePoint(point) => { self.manipulator_anchor(transform.transform_point2(point.position), false, None); } - ClickTargetType::Subpath(subpath) => subpaths.push(subpath.clone()), - ClickTargetType::CompoundPath(compound) => subpaths.extend(compound.iter().cloned()), + ClickTargetType::Path(bezpath) => combined.extend(bezpath.elements().iter().copied()), }); - if !subpaths.is_empty() { - self.push_path(subpaths.iter(), transform); + if !combined.is_empty() { + self.push_path(&combined, transform); let color = color.unwrap_or(COLOR_OVERLAY_BLUE); self.render_context.set_stroke_style_str(color); @@ -1005,8 +986,8 @@ impl OverlayContext { /// Fills the area inside the path. Assumes `color` is in gamma space. /// Used by the Pen tool to show the path being closed. - pub fn fill_path(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &str) { - self.push_path(subpaths, transform); + pub fn fill_path(&mut self, bezpath: &BezPath, transform: DAffine2, color: &str) { + self.push_path(bezpath, transform); self.render_context.set_fill_style_str(color); self.render_context.fill(); @@ -1014,7 +995,7 @@ impl OverlayContext { /// Fills the area inside the path with a pattern. Assumes `color` is an sRGB hex string. /// Used by the fill tool to show the area to be filled. - pub fn fill_path_pattern(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &str) { + pub fn fill_path_pattern(&mut self, bezpath: &BezPath, transform: DAffine2, color: &str) { const PATTERN_WIDTH: usize = 4; const PATTERN_HEIGHT: usize = 4; @@ -1047,7 +1028,7 @@ impl OverlayContext { pattern_context.put_image_data(&image_data, 0, 0).unwrap(); let pattern = self.render_context.create_pattern_with_offscreen_canvas(&pattern_canvas, "repeat").unwrap().unwrap(); - self.push_path(subpaths, transform); + self.push_path(bezpath, transform); self.render_context.set_fill_style_canvas_pattern(&pattern); self.render_context.fill(); diff --git a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs index 0e414aba18..9eddfc353f 100644 --- a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs +++ b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs @@ -8,10 +8,10 @@ use glam::{DAffine2, DVec2}; use graph_craft::document::NodeId; use graphene_std::Appearance; use graphene_std::math::quad::Quad; -use graphene_std::subpath; use graphene_std::transform::Footprint; +use graphene_std::vector::Vector; use graphene_std::vector::click_target::{ClickTarget, ClickTargetType}; -use graphene_std::vector::{PointId, Vector}; +use kurbo::{Affine, BezPath}; use std::collections::{HashMap, HashSet}; use std::num::NonZeroU64; use std::sync::Arc; @@ -188,11 +188,13 @@ impl DocumentMetadata { self.visual_targets(layer)? .iter() .filter_map(|click_target| match click_target.target_type() { - ClickTargetType::Subpath(subpath) => subpath.loose_bounding_box_with_transform(transform), - ClickTargetType::CompoundPath(subpaths) => subpaths - .iter() - .filter_map(|subpath| subpath.loose_bounding_box_with_transform(transform)) - .reduce(|[a_min, a_max], [b_min, b_max]| [a_min.min(b_min), a_max.max(b_max)]), + ClickTargetType::Path(path) => { + let mut transformed = path.clone(); + transformed.apply_affine(Affine::new(transform.to_cols_array())); + + let control_box = transformed.control_box(); + (!transformed.is_empty()).then(|| [DVec2::new(control_box.min_x(), control_box.min_y()), DVec2::new(control_box.max_x(), control_box.max_y())]) + } ClickTargetType::FreePoint(_) => click_target.bounding_box_with_transform(transform), }) .reduce(Quad::combine_bounds) @@ -251,11 +253,10 @@ impl DocumentMetadata { self.all_layers().filter_map(|layer| self.bounding_box_viewport(layer)).reduce(Quad::combine_bounds) } - pub fn layer_outline(&self, layer: LayerNodeIdentifier) -> impl Iterator> { - self.visual_targets(layer).unwrap_or(&[]).iter().flat_map(|target| match target.target_type() { - ClickTargetType::Subpath(subpath) => std::slice::from_ref(subpath), - ClickTargetType::CompoundPath(subpaths) => subpaths.as_slice(), - ClickTargetType::FreePoint(_) => &[], + pub fn layer_outline(&self, layer: LayerNodeIdentifier) -> impl Iterator { + self.visual_targets(layer).unwrap_or(&[]).iter().filter_map(|target| match target.target_type() { + ClickTargetType::Path(path) => Some(path), + ClickTargetType::FreePoint(_) => None, }) } diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index 729557e17b..50a11fb034 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -43,10 +43,9 @@ use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, No use graphene_std::Appearance; use graphene_std::ContextDependencies; use graphene_std::math::quad::Quad; -use graphene_std::subpath::Subpath; use graphene_std::transform::Footprint; use graphene_std::vector::click_target::{ClickTarget, ClickTargetType, FreePoint}; -use graphene_std::vector::{PointId, Vector, VectorModificationType}; +use graphene_std::vector::{Vector, VectorModificationType}; use kurbo::BezPath; use memo_network::MemoNetwork; use serde_json::{Value, json}; diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs index 79fd124104..6a96ec85bb 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs @@ -397,12 +397,12 @@ impl NodeNetworkInterface { if *import_index == 0 { let remove_import_center = reorder_import_center + DVec2::new(-4., 0.); - let remove_import = ClickTarget::new_with_subpath(Subpath::new_rectangle(remove_import_center - DVec2::new(8., 8.), remove_import_center + DVec2::new(8., 8.)), 0.); + let remove_import = ClickTarget::new_with_path(rectangle_path(remove_import_center - DVec2::new(8., 8.), remove_import_center + DVec2::new(8., 8.)), 0.); remove_imports_exports.insert_custom_output_port(*import_index, remove_import); } else { let remove_import_center = reorder_import_center + DVec2::new(-12., 0.); - let reorder_import = ClickTarget::new_with_subpath(Subpath::new_rectangle(reorder_import_center - DVec2::new(3., 4.), reorder_import_center + DVec2::new(3., 4.)), 0.); - let remove_import = ClickTarget::new_with_subpath(Subpath::new_rectangle(remove_import_center - DVec2::new(8., 8.), remove_import_center + DVec2::new(8., 8.)), 0.); + let reorder_import = ClickTarget::new_with_path(rectangle_path(reorder_import_center - DVec2::new(3., 4.), reorder_import_center + DVec2::new(3., 4.)), 0.); + let remove_import = ClickTarget::new_with_path(rectangle_path(remove_import_center - DVec2::new(8., 8.), remove_import_center + DVec2::new(8., 8.)), 0.); reorder_imports_exports.insert_custom_output_port(*import_index, reorder_import); remove_imports_exports.insert_custom_output_port(*import_index, remove_import); } @@ -417,12 +417,12 @@ impl NodeNetworkInterface { if *export_index == 0 { let remove_export_center = reorder_export_center + DVec2::new(4., 0.); - let remove_export = ClickTarget::new_with_subpath(Subpath::new_rectangle(remove_export_center - DVec2::new(8., 8.), remove_export_center + DVec2::new(8., 8.)), 0.); + let remove_export = ClickTarget::new_with_path(rectangle_path(remove_export_center - DVec2::new(8., 8.), remove_export_center + DVec2::new(8., 8.)), 0.); remove_imports_exports.insert_custom_input_port(*export_index, remove_export); } else { let remove_export_center = reorder_export_center + DVec2::new(12., 0.); - let reorder_export = ClickTarget::new_with_subpath(Subpath::new_rectangle(reorder_export_center - DVec2::new(3., 4.), reorder_export_center + DVec2::new(3., 4.)), 0.); - let remove_export = ClickTarget::new_with_subpath(Subpath::new_rectangle(remove_export_center - DVec2::new(8., 8.), remove_export_center + DVec2::new(8., 8.)), 0.); + let reorder_export = ClickTarget::new_with_path(rectangle_path(reorder_export_center - DVec2::new(3., 4.), reorder_export_center + DVec2::new(3., 4.)), 0.); + let remove_export = ClickTarget::new_with_path(rectangle_path(remove_export_center - DVec2::new(8., 8.), remove_export_center + DVec2::new(8., 8.)), 0.); reorder_imports_exports.insert_custom_input_port(*export_index, reorder_export); remove_imports_exports.insert_custom_input_port(*export_index, remove_export); } @@ -1001,8 +1001,8 @@ impl NodeNetworkInterface { let node_click_target_bottom_right = node_click_target_top_left + DVec2::new(width as f64, height as f64); let radius = 3.; - let subpath = Subpath::new_rounded_rectangle(node_click_target_top_left, node_click_target_bottom_right, [radius; 4]); - let node_click_target = ClickTarget::new_with_subpath(subpath, 0.); + let path = rounded_rectangle_path(node_click_target_top_left, node_click_target_bottom_right, [radius; 4]); + let node_click_target = ClickTarget::new_with_path(path, 0.); DocumentNodeClickTargets { node_click_target, @@ -1032,22 +1032,22 @@ impl NodeNetworkInterface { // Update visibility button click target let visibility_offset = node_top_left + DVec2::new(width as f64, LAYER_VERTICAL_CENTER); - let subpath = Subpath::new_rounded_rectangle( + let path = rounded_rectangle_path( DVec2::new(-ICON_HALF_EXTENT, -ICON_HALF_EXTENT) + visibility_offset, DVec2::new(ICON_HALF_EXTENT, ICON_HALF_EXTENT) + visibility_offset, [3.; 4], ); - let visibility_click_target = ClickTarget::new_with_subpath(subpath, 0.); + let visibility_click_target = ClickTarget::new_with_path(path, 0.); // Update lock button click target, positioned one grid unit to the left of the visibility button (only when locked) let lock_click_target = if locked { let lock_offset = node_top_left + DVec2::new(width as f64 - GRID_SIZE as f64, LAYER_VERTICAL_CENTER); - let subpath = Subpath::new_rounded_rectangle( + let path = rounded_rectangle_path( DVec2::new(-ICON_HALF_EXTENT, -ICON_HALF_EXTENT) + lock_offset, DVec2::new(ICON_HALF_EXTENT, ICON_HALF_EXTENT) + lock_offset, [3.; 4], ); - Some(ClickTarget::new_with_subpath(subpath, 0.)) + Some(ClickTarget::new_with_path(path, 0.)) } else { None }; @@ -1057,12 +1057,12 @@ impl NodeNetworkInterface { const GRIP_WIDTH: f64 = 8.; let icons_width = if locked { GRID_SIZE as f64 } else { 0. }; let grip_offset_right_edge = node_top_left + DVec2::new(width as f64 - ICON_HALF_EXTENT - icons_width, LAYER_VERTICAL_CENTER); - let subpath = Subpath::new_rounded_rectangle( + let path = rounded_rectangle_path( DVec2::new(-GRIP_WIDTH, -ICON_HALF_EXTENT) + grip_offset_right_edge, DVec2::new(0., ICON_HALF_EXTENT) + grip_offset_right_edge, [0.; 4], ); - let grip_click_target = ClickTarget::new_with_subpath(subpath, 0.); + let grip_click_target = ClickTarget::new_with_path(path, 0.); // Update display-name text click target, used to detect double-click rename. Sized to the text bounds // (not the surrounding `.details` area) so the rest of the layer still drills into the subgraph on double-click. @@ -1091,8 +1091,8 @@ impl NodeNetworkInterface { // The 1-grid-tall name strip is centered vertically in the 2-grid-tall layer. let name_top = node_top_left.y + HALF_GRID_SIZE as f64; let name_bottom = node_top_left.y + GRID_SIZE as f64 + HALF_GRID_SIZE as f64; - let subpath = Subpath::new_rounded_rectangle(DVec2::new(name_left, name_top), DVec2::new(name_right, name_bottom), [3.; 4]); - Some(ClickTarget::new_with_subpath(subpath, 0.)) + let path = rounded_rectangle_path(DVec2::new(name_left, name_top), DVec2::new(name_right, name_bottom), [3.; 4]); + Some(ClickTarget::new_with_path(path, 0.)) } else { None } @@ -1104,8 +1104,8 @@ impl NodeNetworkInterface { let node_bottom_right = node_top_left + DVec2::new(width as f64, height as f64); let chain_top_left = node_top_left - DVec2::new((chain_width_grid_spaces * GRID_SIZE) as f64, 0.); const CORNER_RADIUS: f64 = 10.; - let subpath = Subpath::new_rounded_rectangle(chain_top_left, node_bottom_right, [CORNER_RADIUS; 4]); - let node_click_target = ClickTarget::new_with_subpath(subpath, 0.); + let path = rounded_rectangle_path(chain_top_left, node_bottom_right, [CORNER_RADIUS; 4]); + let node_click_target = ClickTarget::new_with_path(path, 0.); DocumentNodeClickTargets { node_click_target, diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/hit_tests.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/hit_tests.rs index 4c93827d6b..6fa49111b9 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/hit_tests.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/hit_tests.rs @@ -33,8 +33,8 @@ impl NodeNetworkInterface { let nodes = network_metadata.persistent_metadata.node_metadata.keys().copied().collect::>(); self.with_import_export_ports(network_path, |import_export_click_targets| { for port in import_export_click_targets.click_targets() { - if let ClickTargetType::Subpath(subpath) = port.target_type() { - connector_click_targets.push(subpath.to_bezpath().to_svg()); + if let ClickTargetType::Path(path) = port.target_type() { + connector_click_targets.push(path.to_svg()); } } }); @@ -42,29 +42,29 @@ impl NodeNetworkInterface { self.with_node_click_targets(&node_id, network_path, |node_click_targets| { let mut node_path = String::new(); - if let ClickTargetType::Subpath(subpath) = node_click_targets.node_click_target.target_type() { - node_path.push_str(subpath.to_bezpath().to_svg().as_str()) + if let ClickTargetType::Path(path) = node_click_targets.node_click_target.target_type() { + node_path.push_str(path.to_svg().as_str()) } all_node_click_targets.push((node_id, node_path)); for port in node_click_targets.port_click_targets.click_targets() { - if let ClickTargetType::Subpath(subpath) = port.target_type() { - connector_click_targets.push(subpath.to_bezpath().to_svg()); + if let ClickTargetType::Path(path) = port.target_type() { + connector_click_targets.push(path.to_svg()); } } if let NodeTypeClickTargets::Layer(layer_metadata) = &node_click_targets.node_type_metadata { // Visibility button (eye icon) - if let ClickTargetType::Subpath(subpath) = layer_metadata.visibility_click_target.target_type() { - icon_click_targets.push(subpath.to_bezpath().to_svg()); + if let ClickTargetType::Path(path) = layer_metadata.visibility_click_target.target_type() { + icon_click_targets.push(path.to_svg()); } // Lock button (padlock icon), only when the layer is locked if let Some(lock_click_target) = &layer_metadata.lock_click_target - && let ClickTargetType::Subpath(subpath) = lock_click_target.target_type() + && let ClickTargetType::Path(path) = lock_click_target.target_type() { - icon_click_targets.push(subpath.to_bezpath().to_svg()); + icon_click_targets.push(path.to_svg()); } // Drag grip (dotted symbol) - if let ClickTargetType::Subpath(subpath) = layer_metadata.grip_click_target.target_type() { - icon_click_targets.push(subpath.to_bezpath().to_svg()); + if let ClickTargetType::Path(path) = layer_metadata.grip_click_target.target_type() { + icon_click_targets.push(path.to_svg()); } } }); @@ -80,8 +80,7 @@ impl NodeNetworkInterface { }); let bounds = self.all_nodes_bounding_box(network_path).unwrap_or([DVec2::ZERO, DVec2::ZERO]); - let rect = Subpath::::new_rectangle(bounds[0], bounds[1]); - let all_nodes_bounding_box = rect.to_bezpath().to_svg(); + let all_nodes_bounding_box = rectangle_path(bounds[0], bounds[1]).to_svg(); let mut modify_import_export = Vec::new(); self.with_modify_import_export(network_path, |modify_import_export_click_targets| { @@ -90,8 +89,8 @@ impl NodeNetworkInterface { .click_targets() .chain(modify_import_export_click_targets.reorder_imports_exports.click_targets()) { - if let ClickTargetType::Subpath(subpath) = click_target.target_type() { - modify_import_export.push(subpath.to_bezpath().to_svg()); + if let ClickTargetType::Path(path) = click_target.target_type() { + modify_import_export.push(path.to_svg()); } } }); @@ -342,8 +341,8 @@ impl NodeNetworkInterface { return None; }; - let bounding_box_subpath = Subpath::::new_rectangle(bounds[0], bounds[1]); - bounding_box_subpath.bounding_box_with_transform(network_metadata.persistent_metadata.navigation_metadata.node_graph_to_viewport) + let node_graph_to_viewport = network_metadata.persistent_metadata.navigation_metadata.node_graph_to_viewport; + Some((node_graph_to_viewport * Quad::from_box(bounds)).bounding_box()) } pub fn collect_layer_widths(&self, network_path: &[NodeId]) -> (HashMap, HashMap, HashMap) { diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/structure.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/structure.rs index 3391c685fd..4ead3314cb 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/structure.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/structure.rs @@ -38,9 +38,12 @@ impl NodeNetworkInterface { let vector = self.upstream_path_node_vector(layer)?; let mut targets = Vec::new(); - let subpaths: Vec> = vector.stroke_bezier_paths().collect(); - if !subpaths.is_empty() { - targets.push(ClickTargetType::CompoundPath(subpaths)); + let mut combined = BezPath::new(); + for subpath in vector.stroke_bezier_paths() { + combined.extend(subpath.to_bezpath().elements().iter().copied()); + } + if !combined.is_empty() { + targets.push(ClickTargetType::Path(combined)); } for &point_id in vector.point_domain.ids() { diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/types.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/types.rs index 74361697b7..726b96ea31 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/types.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/types.rs @@ -1,5 +1,29 @@ use super::*; use graphene_std::ParameterRef; +use graphene_std::vector::misc::dvec2_to_point; +use kurbo::{DEFAULT_ACCURACY, Shape}; + +/// Axis-aligned rectangle spanning the two opposite corners, used for node graph chrome click targets. +pub fn rectangle_path(corner1: DVec2, corner2: DVec2) -> BezPath { + kurbo::Rect::from_points(dvec2_to_point(corner1), dvec2_to_point(corner2)).to_path(DEFAULT_ACCURACY) +} + +/// Same as [`rectangle_path`] but with per-corner radii ordered top left, top right, bottom right, bottom left. +pub fn rounded_rectangle_path(corner1: DVec2, corner2: DVec2, radii: [f64; 4]) -> BezPath { + let rect = kurbo::Rect::from_points(dvec2_to_point(corner1), dvec2_to_point(corner2)); + if radii.iter().all(|radius| *radius == 0.) { + return rect.to_path(DEFAULT_ACCURACY); + } + + let [top_left, top_right, bottom_right, bottom_left] = radii; + kurbo::RoundedRect::from_rect(rect, kurbo::RoundedRectRadii::new(top_left, top_right, bottom_right, bottom_left)).to_path(DEFAULT_ACCURACY) +} + +/// Ellipse inscribed in the box spanning the two opposite corners. +pub fn ellipse_path(corner1: DVec2, corner2: DVec2) -> BezPath { + let rect = kurbo::Rect::from_points(dvec2_to_point(corner1), dvec2_to_point(corner2)); + kurbo::Ellipse::new(rect.center(), (rect.width() / 2., rect.height() / 2.), 0.).to_path(DEFAULT_ACCURACY) +} #[derive(PartialEq)] pub enum FlowType { @@ -227,8 +251,8 @@ impl Ports { } pub(crate) fn insert_input_port_at_center(&mut self, input_index: usize, center: DVec2) { - let subpath = Subpath::new_ellipse(center - DVec2::new(8., 8.), center + DVec2::new(8., 8.)); - self.insert_custom_input_port(input_index, ClickTarget::new_with_subpath(subpath, 0.)); + let path = ellipse_path(center - DVec2::new(8., 8.), center + DVec2::new(8., 8.)); + self.insert_custom_input_port(input_index, ClickTarget::new_with_path(path, 0.)); } pub(crate) fn insert_custom_input_port(&mut self, input_index: usize, click_target: ClickTarget) { @@ -236,8 +260,8 @@ impl Ports { } pub(crate) fn insert_output_port_at_center(&mut self, output_index: usize, center: DVec2) { - let subpath = Subpath::new_ellipse(center - DVec2::new(8., 8.), center + DVec2::new(8., 8.)); - self.insert_custom_output_port(output_index, ClickTarget::new_with_subpath(subpath, 0.)); + let path = ellipse_path(center - DVec2::new(8., 8.), center + DVec2::new(8., 8.)); + self.insert_custom_output_port(output_index, ClickTarget::new_with_path(path, 0.)); } pub(crate) fn insert_custom_output_port(&mut self, output_index: usize, click_target: ClickTarget) { diff --git a/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs b/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs index c1c2bd4525..c1e4ab22d9 100644 --- a/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs +++ b/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs @@ -17,6 +17,7 @@ use glam::{DAffine2, DMat2, DVec2}; use graph_craft::document::NodeInput; use graph_craft::document::value::TaggedValue; use graphene_std::subpath::Subpath; +use graphene_std::vector::PointId; use graphene_std::vector::click_target::ClickTargetType; use graphene_std::vector::misc::{ArcType, GridType, SpiralType, dvec2_to_point}; use kurbo::{BezPath, PathEl, Shape}; @@ -445,9 +446,11 @@ pub fn star_outline(layer: Option, document: &DocumentMessa let diameter: f64 = radius1 * 2.; let inner_diameter = radius2 * 2.; - let subpath: Vec = vec![ClickTargetType::Subpath(Subpath::new_star_polygon(DVec2::splat(-diameter), points, diameter, inner_diameter))]; + let targets: Vec = vec![ClickTargetType::Path( + Subpath::::new_star_polygon(DVec2::splat(-diameter), points, diameter, inner_diameter).to_bezpath(), + )]; - overlay_context.outline(subpath.iter(), viewport, None); + overlay_context.outline(targets.iter(), viewport, None); } /// Outlines the geometric shape made by polygon-node @@ -462,9 +465,9 @@ pub fn polygon_outline(layer: Option, document: &DocumentMe let points = sides as u64; let radius: f64 = radius * 2.; - let subpath: Vec = vec![ClickTargetType::Subpath(Subpath::new_regular_polygon(DVec2::splat(-radius), points, radius))]; + let targets: Vec = vec![ClickTargetType::Path(Subpath::::new_regular_polygon(DVec2::splat(-radius), points, radius).to_bezpath())]; - overlay_context.outline(subpath.iter(), viewport, None); + overlay_context.outline(targets.iter(), viewport, None); } /// Outlines the geometric shape made by an Arc node @@ -475,15 +478,11 @@ pub fn arc_outline(layer: Option, document: &DocumentMessag return; }; - let subpath: Vec = vec![ClickTargetType::Subpath(Subpath::new_arc( - radius, - start_angle / 360. * std::f64::consts::TAU, - sweep_angle / 360. * std::f64::consts::TAU, - arc_type, - ))]; + let arc = Subpath::::new_arc(radius, start_angle / 360. * std::f64::consts::TAU, sweep_angle / 360. * std::f64::consts::TAU, arc_type); + let targets: Vec = vec![ClickTargetType::Path(arc.to_bezpath())]; let viewport = document.metadata().transform_to_viewport(layer); - overlay_context.outline(subpath.iter(), viewport, None); + overlay_context.outline(targets.iter(), viewport, None); } /// Check if the the cursor is inside the geometric star shape made by the Star node without any upstream node modifications diff --git a/editor/src/messages/tool/common_functionality/snapping/layer_snapper.rs b/editor/src/messages/tool/common_functionality/snapping/layer_snapper.rs index 5d7546c71c..b0d5e9d5b8 100644 --- a/editor/src/messages/tool/common_functionality/snapping/layer_snapper.rs +++ b/editor/src/messages/tool/common_functionality/snapping/layer_snapper.rs @@ -13,7 +13,7 @@ use graphene_std::vector::algorithms::bezpath_algorithms::{pathseg_normals_to_po use graphene_std::vector::algorithms::intersection::filtered_segment_intersections; use graphene_std::vector::misc::dvec2_to_point; use graphene_std::vector::misc::point_to_dvec2; -use kurbo::{Affine, ParamCurve, PathSeg}; +use kurbo::{Affine, BezPath, ParamCurve, PathEl, PathSeg}; #[derive(Clone, Debug, Default)] pub struct LayerSnapper { @@ -74,26 +74,20 @@ impl LayerSnapper { } if document.snapping_state.target_enabled(SnapTarget::Path(PathSnapTarget::IntersectionPoint)) || document.snapping_state.target_enabled(SnapTarget::Path(PathSnapTarget::AlongPath)) { - let mut push_candidates = |subpath: &Subpath, transform: DAffine2| { - for (start_index, curve) in subpath.iter().enumerate() { - let document_curve = Affine::new(transform.to_cols_array()) * curve; - let start = subpath.manipulator_groups()[start_index].id; - if snap_data.ignore_manipulator(layer, start) || snap_data.ignore_manipulator(layer, subpath.manipulator_groups()[(start_index + 1) % subpath.len()].id) { - continue; + // Post-solidified outline (the layer's recorded geometry). Its anchors carry no point IDs, + // so while this layer's manipulators are being dragged the whole outline is skipped instead of filtering per manipulator. + if !snap_data.ignore_bounds(layer) { + for bezpath in document.metadata().layer_outline(layer) { + for curve in bezpath.segments() { + self.paths_to_snap.push(SnapCandidatePath { + document_curve: Affine::new(transform.to_cols_array()) * curve, + layer, + start: PointId::new(), + target: SnapTarget::Path(PathSnapTarget::AlongPath), + bounds: None, + }); } - self.paths_to_snap.push(SnapCandidatePath { - document_curve, - layer, - start, - target: SnapTarget::Path(PathSnapTarget::AlongPath), - bounds: None, - }); } - }; - - // Post-solidified outline (the layer's recorded geometry) - for subpath in document.metadata().layer_outline(layer) { - push_candidates(subpath, transform); } // Pre-solidified centerline (the Path tool's view) when an upstream Path node exists, @@ -102,7 +96,20 @@ impl LayerSnapper { let path_aware_transform = document.metadata().transform_to_document_if_feeds(layer, &document.network_interface); if path_aware_transform.is_finite() { for subpath in vector.stroke_bezier_paths() { - push_candidates(&subpath, path_aware_transform); + for (start_index, curve) in subpath.iter().enumerate() { + let start = subpath.manipulator_groups()[start_index].id; + let end = subpath.manipulator_groups()[(start_index + 1) % subpath.len()].id; + if snap_data.ignore_manipulator(layer, start) || snap_data.ignore_manipulator(layer, end) { + continue; + } + self.paths_to_snap.push(SnapCandidatePath { + document_curve: Affine::new(path_aware_transform.to_cols_array()) * curve, + layer, + start, + target: SnapTarget::Path(PathSnapTarget::AlongPath), + bounds: None, + }); + } } } } @@ -608,6 +615,96 @@ fn subpath_anchor_snap_points(layer: LayerNodeIdentifier, subpath: &Subpath, to_document: DAffine2) { + let document = snap_data.document; + + // Split the path into contours so endpoint and wraparound handling stays per-subpath + let mut contours = Vec::new(); + let mut current = BezPath::new(); + for element in bezpath.elements() { + if matches!(element, PathEl::MoveTo(_)) && !current.elements().is_empty() { + contours.push(std::mem::take(&mut current)); + } + current.push(*element); + } + if !current.elements().is_empty() { + contours.push(current); + } + + for contour in contours { + let closed = matches!(contour.elements().last(), Some(PathEl::ClosePath)); + let segments: Vec = contour.segments().collect(); + if segments.is_empty() { + continue; + } + + // Midpoints of linear segments + if document.snapping_state.target_enabled(SnapTarget::Path(PathSnapTarget::LineMidpoint)) { + for &segment in &segments { + if points.len() >= crate::consts::MAX_LAYER_SNAP_POINTS { + return; + } + + let curve = pathseg_points(segment); + let in_handle = curve.p1.map(|handle| handle - curve.p0).filter(handle_not_under(to_document)); + let out_handle = curve.p2.map(|handle| handle - curve.p3).filter(handle_not_under(to_document)); + if in_handle.is_none() && out_handle.is_none() { + points.push(SnapCandidatePoint::new( + to_document.transform_point2(curve.p0 * 0.5 + curve.p3 * 0.5), + SnapSource::Path(PathSnapSource::LineMidpoint), + SnapTarget::Path(PathSnapTarget::LineMidpoint), + Some(layer), + )); + } + } + } + + // Anchors + let anchor_count = segments.len() + if closed { 0 } else { 1 }; + for index in 0..anchor_count { + if points.len() >= crate::consts::MAX_LAYER_SNAP_POINTS { + return; + } + + let anchor = if index < segments.len() { + pathseg_points(segments[index]).p0 + } else { + pathseg_points(segments[index - 1]).p3 + }; + + let in_handle = if index > 0 { + pathseg_points(segments[index - 1]).p2 + } else if closed { + pathseg_points(segments[segments.len() - 1]).p2 + } else { + None + }; + let out_handle = if index < segments.len() { pathseg_points(segments[index]).p1 } else { None }; + + let handle_in = in_handle.map(|handle| anchor - handle).filter(handle_not_under(to_document)); + let handle_out = out_handle.map(|handle| handle - anchor).filter(handle_not_under(to_document)); + let anchor_is_endpoint = !closed && (index == 0 || index == anchor_count - 1); + let colinear = !anchor_is_endpoint && handle_in.is_some_and(|handle_in| handle_out.is_some_and(|handle_out| handle_in.angle_to(handle_out) < 1e-5)); + + if colinear && document.snapping_state.target_enabled(SnapTarget::Path(PathSnapTarget::AnchorPointWithColinearHandles)) { + points.push(SnapCandidatePoint::new( + to_document.transform_point2(anchor), + SnapSource::Path(PathSnapSource::AnchorPointWithColinearHandles), + SnapTarget::Path(PathSnapTarget::AnchorPointWithColinearHandles), + Some(layer), + )); + } else if !colinear && document.snapping_state.target_enabled(SnapTarget::Path(PathSnapTarget::AnchorPointWithFreeHandles)) { + points.push(SnapCandidatePoint::new( + to_document.transform_point2(anchor), + SnapSource::Path(PathSnapSource::AnchorPointWithFreeHandles), + SnapTarget::Path(PathSnapTarget::AnchorPointWithFreeHandles), + Some(layer), + )); + } + } + } +} + pub fn are_manipulator_handles_colinear(manipulators: &ManipulatorGroup, to_document: DAffine2, subpath: &Subpath, index: usize) -> bool { let anchor = manipulators.anchor; let handle_in = manipulators.in_handle.map(|handle| anchor - handle).filter(handle_not_under(to_document)); @@ -633,11 +730,11 @@ pub fn get_layer_snap_points(layer: LayerNodeIdentifier, snap_data: &SnapData, p get_layer_snap_points(child, snap_data, points); } } else { - // Post-solidified outline (the layer's recorded geometry) - if document.metadata().layer_outline(layer).next().is_some() { + // Post-solidified outline (the layer's recorded geometry), skipped wholesale while this layer's manipulators are being dragged since the outline carries no point IDs + if !snap_data.ignore_bounds(layer) { let to_document = document.metadata().transform_to_document(layer); - for subpath in document.metadata().layer_outline(layer) { - subpath_anchor_snap_points(layer, subpath, snap_data, points, to_document); + for bezpath in document.metadata().layer_outline(layer) { + bezpath_anchor_snap_points(layer, bezpath, snap_data, points, to_document); } } diff --git a/editor/src/messages/tool/tool_messages/fill_tool.rs b/editor/src/messages/tool/tool_messages/fill_tool.rs index 63847b8258..e0755f0d6c 100644 --- a/editor/src/messages/tool/tool_messages/fill_tool.rs +++ b/editor/src/messages/tool/tool_messages/fill_tool.rs @@ -6,9 +6,9 @@ use crate::messages::tool::common_functionality::color_selector::solid; use crate::messages::tool::common_functionality::graph_modification_utils::{NodeGraphLayer, get_upstream_color_value_node_id, gradient_chain_target_input, replaceable_paint_chain}; use graphene_std::color::SRGBA8; use graphene_std::raster::color::Color; -use graphene_std::subpath::Subpath; -use graphene_std::vector::PointId; +use graphene_std::vector::misc::dvec2_to_point; use graphene_std::vector::style::FillChoice; +use kurbo::{BezPath, DEFAULT_ACCURACY, Rect, Shape}; #[derive(Default, ExtractField)] pub struct FillTool { @@ -150,9 +150,13 @@ impl Fsm for FillToolFsmState { if paints_whole_expanse(layer, &document.network_interface) { let expanse = whole_expanse_rect(layer, document, overlay_context.viewport.size().into_dvec2()); - overlay_context.fill_path_pattern(std::iter::once(expanse), DAffine2::IDENTITY, &color_hex); + overlay_context.fill_path_pattern(&expanse, DAffine2::IDENTITY, &color_hex); } else { - overlay_context.fill_path_pattern(document.metadata().layer_outline(layer), document.metadata().transform_to_viewport(layer), &color_hex); + let mut outline = BezPath::new(); + for path in document.metadata().layer_outline(layer) { + outline.extend(path.elements().iter().copied()); + } + overlay_context.fill_path_pattern(&outline, document.metadata().transform_to_viewport(layer), &color_hex); } } @@ -244,15 +248,16 @@ fn paints_whole_expanse(layer: LayerNodeIdentifier, network_interface: &NodeNetw /// The viewport-space area a whole-expanse color paints: the artboard containing the layer, since the color fills it, /// or else the visible viewport for a layer living outside any artboard. -fn whole_expanse_rect(layer: LayerNodeIdentifier, document: &DocumentMessageHandler, viewport_size: DVec2) -> Subpath { +fn whole_expanse_rect(layer: LayerNodeIdentifier, document: &DocumentMessageHandler, viewport_size: DVec2) -> BezPath { let containing_artboard = layer .ancestors(document.metadata()) .find(|&ancestor| ancestor != LayerNodeIdentifier::ROOT_PARENT && document.network_interface.is_artboard(&ancestor.to_node(), &[])); - match containing_artboard.and_then(|artboard| document.metadata().bounding_box_viewport(artboard)) { - Some([min, max]) => Subpath::new_rectangle(min, max), - None => Subpath::new_rectangle(DVec2::ZERO, viewport_size), - } + let [min, max] = containing_artboard + .and_then(|artboard| document.metadata().bounding_box_viewport(artboard)) + .unwrap_or([DVec2::ZERO, viewport_size]); + + Rect::from_points(dvec2_to_point(min), dvec2_to_point(max)).to_path(DEFAULT_ACCURACY) } #[cfg(test)] diff --git a/editor/src/messages/tool/tool_messages/pen_tool.rs b/editor/src/messages/tool/tool_messages/pen_tool.rs index fc3ef8603a..4607ae0e42 100644 --- a/editor/src/messages/tool/tool_messages/pen_tool.rs +++ b/editor/src/messages/tool/tool_messages/pen_tool.rs @@ -22,7 +22,7 @@ use graphene_std::subpath::pathseg_points; use graphene_std::vector::misc::{HandleId, ManipulatorPointId, dvec2_to_point}; use graphene_std::vector::style::FillChoice; use graphene_std::vector::{NoHashBuilder, PointId, SegmentId, StrokeId, Vector, VectorModificationType}; -use kurbo::{CubicBez, PathSeg}; +use kurbo::{BezPath, CubicBez, PathSeg}; #[derive(Default, ExtractField)] pub struct PenTool { @@ -1857,21 +1857,22 @@ impl Fsm for PenToolFsmState { let grouped_segments = vector.auto_join_paths(); let closed_paths = grouped_segments.iter().filter(|path| path.is_closed() && path.contains(segment_id)); - let subpaths: Vec<_> = closed_paths - .filter_map(|path| { - let segments = path.edges.iter().filter_map(|edge| { - vector - .segment_domain - .iter() - .find(|(id, _, _, _)| id == &edge.id) - .map(|(_, start, end, bezier)| if start == edge.start { (bezier, start, end) } else { (bezier.reversed(), end, start) }) - }); - vector.subpath_from_segments_ignore_discontinuities(segments) - }) - .collect(); + let mut fill_region = BezPath::new(); + for path in closed_paths { + let segments = path.edges.iter().filter_map(|edge| { + vector + .segment_domain + .iter() + .find(|(id, _, _, _)| id == &edge.id) + .map(|(_, start, end, bezier)| if start == edge.start { (bezier, start, end) } else { (bezier.reversed(), end, start) }) + }); + if let Some(subpath) = vector.subpath_from_segments_ignore_discontinuities(segments) { + fill_region.extend(subpath.to_bezpath().elements().iter().copied()); + } + } let fill_color = COLOR_OVERLAY_BLUE_05; - overlay_context.fill_path(subpaths.iter(), transform, fill_color); + overlay_context.fill_path(&fill_region, transform, fill_color); } } } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index f817eae7f3..6834e7d065 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -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 { 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 { fn add_upstream_click_targets(&self, click_targets: &mut Vec, _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 { } } -/// 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, 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> = geometry.stroke_bezier_paths().collect(); - let all_subpaths_closed = subpaths.iter().all(|subpath| subpath.closed()); + let mut bezpaths: Vec = 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, 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>, scene: &mut S /// plus the first item's transform and any merged-layers snapshot when a first item exists. fn collect_raster_metadata(first_row: Option>, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) { 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>` if let Some(item) = first_row { @@ -2437,10 +2445,10 @@ fn collect_raster_metadata(first_row: Option>, 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) { // 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> { @@ -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 { +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 + // + 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, 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(items: impl Iterator, click_targets: &mut Vec) { 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); } diff --git a/node-graph/libraries/vector-types/src/vector/click_target.rs b/node-graph/libraries/vector-types/src/vector/click_target.rs index e97ea4be8b..ddb1d8b157 100644 --- a/node-graph/libraries/vector-types/src/vector/click_target.rs +++ b/node-graph/libraries/vector-types/src/vector/click_target.rs @@ -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), - 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>), + 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, 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, 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>, 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::() != 0, + ClickTargetType::FreePoint(point) => bezier_iter().map(|segment: PathSeg| segment.winding(dvec2_to_point(point.position))).sum::() != 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());