diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index d324c4931d..fe89b38a1b 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -32,7 +32,7 @@ use graph_craft::document::{NodeId, NodeInput, NodeNetwork, OldNodeNetwork}; use graphene_core::raster::BlendMode; use graphene_core::raster::image::ImageFrameTable; use graphene_core::vector::style::ViewMode; -use graphene_std::renderer::{ClickTarget, Quad}; +use graphene_std::renderer::{ClickTarget, ClickTargetGroup, Quad}; use graphene_std::vector::{PointId, path_bool_lib}; use std::time::Duration; @@ -1603,10 +1603,17 @@ impl DocumentMessageHandler { let layer_transform = self.network_interface.document_metadata().transform_to_document(*layer); layer_click_targets.is_some_and(|targets| { - targets.iter().all(|target| { - let mut subpath = target.subpath().clone(); - subpath.apply_transform(layer_transform); - subpath.is_inside_subpath(&viewport_polygon, None, None) + targets.iter().all(|target| match target.target_group() { + ClickTargetGroup::Subpath(subpath) => { + let mut subpath = subpath.clone(); + subpath.apply_transform(layer_transform); + subpath.is_inside_subpath(&viewport_polygon, None, None) + } + ClickTargetGroup::PointGroup(point) => { + let mut point = point.clone(); + point.apply_transform(layer_transform); + viewport_polygon.contains_point(point.anchor) + } }) }) } @@ -2733,7 +2740,18 @@ fn click_targets_to_path_lib_segments<'a>(click_targets: impl Iterator path_bool_lib::PathSegment::Cubic(bezier.start, handle_start, handle_end, bezier.end), }; click_targets - .flat_map(|target| target.subpath().iter()) + .filter(|target| match target.target_group() { + ClickTargetGroup::Subpath(_) => true, + _ => false, + }) + .flat_map(|target| { + let subpath = if let ClickTargetGroup::Subpath(subpath) = target.target_group() { + subpath + } else { + panic!("Expected a subpath target group"); + }; + subpath.iter() + }) .map(|bezier| segment(bezier.apply_transformation(|x| transform.transform_point2(x)))) .collect() } diff --git a/editor/src/messages/portfolio/document/overlays/utility_types.rs b/editor/src/messages/portfolio/document/overlays/utility_types.rs index 2bc4830c1d..190560e0a5 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types.rs @@ -656,6 +656,24 @@ impl OverlayContext { self.render_context.stroke(); } + pub fn outline_free_floating_anchors(&mut self, vector_data: VectorData, transform: DAffine2) { + const SINGLE_ANCHOR_SELECTION_RADIUS: f64 = 3.5; + + for &point_id in vector_data.point_domain.ids() { + // Check if the point in the layer is not part of a segment + if vector_data.connected_count(point_id) == 0 { + if let Some(position) = vector_data.point_domain.position_from_id(point_id) { + self.circle( + transform.transform_point2(position), + SINGLE_ANCHOR_SELECTION_RADIUS, + Some(COLOR_OVERLAY_WHITE), + Some(COLOR_OVERLAY_BLUE), + ); + } + } + } + } + /// 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) { 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 52509903c0..597777abd2 100644 --- a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs +++ b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs @@ -3,8 +3,7 @@ use crate::messages::portfolio::document::graph_operation::transform_utils; use crate::messages::portfolio::document::graph_operation::utility_types::ModifyInputsContext; use glam::{DAffine2, DVec2}; use graph_craft::document::NodeId; -use graphene_core::renderer::ClickTarget; -use graphene_core::renderer::Quad; +use graphene_core::renderer::{ClickTarget, ClickTargetGroup, Quad}; use graphene_core::transform::Footprint; use graphene_std::vector::{PointId, VectorData}; use std::collections::{HashMap, HashSet}; @@ -134,7 +133,10 @@ impl DocumentMetadata { pub fn bounding_box_with_transform(&self, layer: LayerNodeIdentifier, transform: DAffine2) -> Option<[DVec2; 2]> { self.click_targets(layer)? .iter() - .filter_map(|click_target| click_target.subpath().bounding_box_with_transform(transform)) + .filter_map(|click_target| match click_target.target_group() { + ClickTargetGroup::Subpath(subpath) => subpath.bounding_box_with_transform(transform), + ClickTargetGroup::PointGroup(_) => click_target.bounding_box_with_transform(transform), + }) .reduce(Quad::combine_bounds) } @@ -177,7 +179,16 @@ impl DocumentMetadata { pub fn layer_outline(&self, layer: LayerNodeIdentifier) -> impl Iterator> { static EMPTY: Vec = Vec::new(); let click_targets = self.click_targets.get(&layer).unwrap_or(&EMPTY); - click_targets.iter().map(ClickTarget::subpath) + click_targets + .iter() + .filter(|target| match target.target_group() { + ClickTargetGroup::Subpath(_) => true, + _ => false, + }) + .map(|target| match target.target_group() { + ClickTargetGroup::Subpath(subpath) => subpath, + _ => unreachable!(), + }) } pub fn is_clip(&self, node: NodeId) -> bool { 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 db083e5409..db7b0c6a57 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -12,7 +12,7 @@ use glam::{DAffine2, DVec2, IVec2}; use graph_craft::document::value::TaggedValue; use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput, NodeNetwork, OldDocumentNodeImplementation, OldNodeNetwork}; use graph_craft::{Type, concrete}; -use graphene_std::renderer::{ClickTarget, Quad}; +use graphene_std::renderer::{ClickTarget, ClickTargetGroup, Quad}; use graphene_std::transform::Footprint; use graphene_std::vector::{PointId, VectorData, VectorModificationType}; use interpreted_executor::dynamic_executor::ResolvedDocumentNodeTypes; @@ -2120,7 +2120,7 @@ impl NodeNetworkInterface { let bounding_box_top_right = DVec2::new((all_nodes_bounding_box[1].x / 24. + 0.5).floor() * 24., (all_nodes_bounding_box[0].y / 24. + 0.5).floor() * 24.) + offset_from_top_right; let export_top_right: DVec2 = DVec2::new(viewport_top_right.x.max(bounding_box_top_right.x), viewport_top_right.y.min(bounding_box_top_right.y)); let add_export_center = export_top_right + DVec2::new(0., network.exports.len() as f64 * 24.); - let add_export = ClickTarget::new( + let add_export = ClickTarget::new_with_subpath( Subpath::new_rounded_rect(add_export_center - DVec2::new(12., 12.), add_export_center + DVec2::new(12., 12.), [3.; 4]), 0., ); @@ -2146,7 +2146,7 @@ impl NodeNetworkInterface { let bounding_box_top_left = DVec2::new((all_nodes_bounding_box[0].x / 24. + 0.5).floor() * 24., (all_nodes_bounding_box[0].y / 24. + 0.5).floor() * 24.) + offset_from_top_left; let import_top_left = DVec2::new(viewport_top_left.x.min(bounding_box_top_left.x), viewport_top_left.y.min(bounding_box_top_left.y)); let add_import_center = import_top_left + DVec2::new(0., self.number_of_displayed_imports(network_path) as f64 * 24.); - let add_import = ClickTarget::new( + let add_import = ClickTarget::new_with_subpath( Subpath::new_rounded_rect(add_import_center - DVec2::new(12., 12.), add_import_center + DVec2::new(12., 12.), [3.; 4]), 0., ); @@ -2165,8 +2165,8 @@ impl NodeNetworkInterface { let reorder_import_center = (import_bounding_box[0] + import_bounding_box[1]) / 2. + DVec2::new(-12., 0.); let remove_import_center = reorder_import_center + DVec2::new(-12., 0.); - let reorder_import = ClickTarget::new(Subpath::new_rect(reorder_import_center - DVec2::new(3., 4.), reorder_import_center + DVec2::new(3., 4.)), 0.); - let remove_import = ClickTarget::new(Subpath::new_rect(remove_import_center - DVec2::new(8., 8.), remove_import_center + DVec2::new(8., 8.)), 0.); + let reorder_import = ClickTarget::new_with_subpath(Subpath::new_rect(reorder_import_center - DVec2::new(3., 4.), reorder_import_center + DVec2::new(3., 4.)), 0.); + let remove_import = ClickTarget::new_with_subpath(Subpath::new_rect(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); @@ -2180,8 +2180,8 @@ impl NodeNetworkInterface { let reorder_export_center = (export_bounding_box[0] + export_bounding_box[1]) / 2. + DVec2::new(12., 0.); let remove_export_center = reorder_export_center + DVec2::new(12., 0.); - let reorder_export = ClickTarget::new(Subpath::new_rect(reorder_export_center - DVec2::new(3., 4.), reorder_export_center + DVec2::new(3., 4.)), 0.); - let remove_export = ClickTarget::new(Subpath::new_rect(remove_export_center - DVec2::new(8., 8.), remove_export_center + DVec2::new(8., 8.)), 0.); + let reorder_export = ClickTarget::new_with_subpath(Subpath::new_rect(reorder_export_center - DVec2::new(3., 4.), reorder_export_center + DVec2::new(3., 4.)), 0.); + let remove_export = ClickTarget::new_with_subpath(Subpath::new_rect(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); @@ -2572,7 +2572,7 @@ impl NodeNetworkInterface { let radius = 3.; let subpath = bezier_rs::Subpath::new_rounded_rect(node_click_target_top_left, node_click_target_bottom_right, [radius; 4]); - let node_click_target = ClickTarget::new(subpath, 0.); + let node_click_target = ClickTarget::new_with_subpath(subpath, 0.); DocumentNodeClickTargets { node_click_target, @@ -2597,12 +2597,12 @@ impl NodeNetworkInterface { // Update visibility button click target let visibility_offset = node_top_left + DVec2::new(width as f64, 24.); let subpath = Subpath::new_rounded_rect(DVec2::new(-12., -12.) + visibility_offset, DVec2::new(12., 12.) + visibility_offset, [3.; 4]); - let visibility_click_target = ClickTarget::new(subpath, 0.); + let visibility_click_target = ClickTarget::new_with_subpath(subpath, 0.); // Update grip button click target, which is positioned to the left of the left most icon let grip_offset_right_edge = node_top_left + DVec2::new(width as f64 - (GRID_SIZE as f64) / 2., 24.); let subpath = Subpath::new_rounded_rect(DVec2::new(-8., -12.) + grip_offset_right_edge, DVec2::new(0., 12.) + grip_offset_right_edge, [0.; 4]); - let grip_click_target = ClickTarget::new(subpath, 0.); + let grip_click_target = ClickTarget::new_with_subpath(subpath, 0.); // Create layer click target, which is contains the layer and the chain background let chain_width_grid_spaces = self.chain_width(node_id, network_path); @@ -2611,7 +2611,7 @@ impl NodeNetworkInterface { let chain_top_left = node_top_left - DVec2::new((chain_width_grid_spaces * crate::consts::GRID_SIZE) as f64, 0.); let radius = 10.; let subpath = bezier_rs::Subpath::new_rounded_rect(chain_top_left, node_bottom_right, [radius; 4]); - let node_click_target = ClickTarget::new(subpath, 0.); + let node_click_target = ClickTarget::new_with_subpath(subpath, 0.); DocumentNodeClickTargets { node_click_target, @@ -2804,20 +2804,29 @@ impl NodeNetworkInterface { if let (Some(import_export_click_targets), Some(node_click_targets)) = (self.import_export_ports(network_path).cloned(), self.node_click_targets(&node_id, network_path)) { let mut node_path = String::new(); - let _ = node_click_targets.node_click_target.subpath().subpath_to_svg(&mut node_path, DAffine2::IDENTITY); + if let ClickTargetGroup::Subpath(subpath) = node_click_targets.node_click_target.target_group() { + let _ = subpath.subpath_to_svg(&mut node_path, DAffine2::IDENTITY); + } all_node_click_targets.push((node_id, node_path)); for port in node_click_targets.port_click_targets.click_targets().chain(import_export_click_targets.click_targets()) { - let mut port_path = String::new(); - let _ = port.subpath().subpath_to_svg(&mut port_path, DAffine2::IDENTITY); - port_click_targets.push(port_path); + if let ClickTargetGroup::Subpath(subpath) = port.target_group() { + let mut port_path = String::new(); + let _ = subpath.subpath_to_svg(&mut port_path, DAffine2::IDENTITY); + port_click_targets.push(port_path); + } } if let NodeTypeClickTargets::Layer(layer_metadata) = &node_click_targets.node_type_metadata { - let mut port_path = String::new(); - let _ = layer_metadata.visibility_click_target.subpath().subpath_to_svg(&mut port_path, DAffine2::IDENTITY); - icon_click_targets.push(port_path); - let mut port_path = String::new(); - let _ = layer_metadata.grip_click_target.subpath().subpath_to_svg(&mut port_path, DAffine2::IDENTITY); - icon_click_targets.push(port_path); + if let ClickTargetGroup::Subpath(subpath) = layer_metadata.visibility_click_target.target_group() { + let mut port_path = String::new(); + let _ = subpath.subpath_to_svg(&mut port_path, DAffine2::IDENTITY); + icon_click_targets.push(port_path); + } + + if let ClickTargetGroup::Subpath(subpath) = layer_metadata.grip_click_target.target_group() { + let mut port_path = String::new(); + let _ = subpath.subpath_to_svg(&mut port_path, DAffine2::IDENTITY); + icon_click_targets.push(port_path); + } } } }); @@ -2872,9 +2881,11 @@ impl NodeNetworkInterface { .chain(modify_import_export_click_targets.remove_imports_exports.click_targets()) .chain(modify_import_export_click_targets.reorder_imports_exports.click_targets()) { - let mut remove_string = String::new(); - let _ = click_target.subpath().subpath_to_svg(&mut remove_string, DAffine2::IDENTITY); - modify_import_export.push(remove_string); + if let ClickTargetGroup::Subpath(subpath) = click_target.target_group() { + let mut remove_string = String::new(); + let _ = subpath.subpath_to_svg(&mut remove_string, DAffine2::IDENTITY); + modify_import_export.push(remove_string); + } } } FrontendClickTargets { @@ -3174,8 +3185,8 @@ impl NodeNetworkInterface { self.document_metadata .click_targets .get(&layer) - .map(|click| click.iter().map(ClickTarget::subpath)) - .map(|subpaths| VectorData::from_subpaths(subpaths, true)) + .map(|click| click.iter().map(ClickTarget::target_group)) + .map(|target_groups| VectorData::from_target_groups(target_groups, true)) } /// Loads the structure of layer nodes from a node graph. @@ -5884,7 +5895,7 @@ impl Ports { 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(subpath, 0.)); + self.insert_custom_input_port(input_index, ClickTarget::new_with_subpath(subpath, 0.)); } fn insert_custom_input_port(&mut self, input_index: usize, click_target: ClickTarget) { @@ -5893,7 +5904,7 @@ impl Ports { 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(subpath, 0.)); + self.insert_custom_output_port(output_index, ClickTarget::new_with_subpath(subpath, 0.)); } fn insert_custom_output_port(&mut self, output_index: usize, click_target: ClickTarget) { diff --git a/editor/src/messages/tool/common_functionality/shape_editor.rs b/editor/src/messages/tool/common_functionality/shape_editor.rs index 7fa761acdc..ddd633ce6a 100644 --- a/editor/src/messages/tool/common_functionality/shape_editor.rs +++ b/editor/src/messages/tool/common_functionality/shape_editor.rs @@ -83,7 +83,14 @@ impl SelectedLayerState { self.selected_points.clear(); } pub fn selected_points_count(&self) -> usize { - self.selected_points.len() + let count = self.selected_points.iter().fold(0, |acc, point| { + if (point.as_handle().is_some() && self.ignore_handles) || (point.as_anchor().is_some() && self.ignore_anchors) { + acc + } else { + acc + 1 + } + }); + count } } diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs index 041476ed5c..751aa0819a 100644 --- a/editor/src/messages/tool/tool_messages/select_tool.rs +++ b/editor/src/messages/tool/tool_messages/select_tool.rs @@ -527,12 +527,17 @@ impl Fsm for SelectToolFsmState { .selected_visible_and_unlocked_layers(&document.network_interface) .filter(|layer| !document.network_interface.is_artboard(&layer.to_node(), &[])) { - overlay_context.outline(document.metadata().layer_outline(layer), document.metadata().transform_to_viewport(layer), None); + let layer_to_viewport = document.metadata().transform_to_viewport(layer); + overlay_context.outline(document.metadata().layer_outline(layer), layer_to_viewport, None); if is_layer_fed_by_node_of_name(layer, &document.network_interface, "Text") { - let transformed_quad = document.metadata().transform_to_viewport(layer) * text_bounding_box(layer, document, font_cache); + let transformed_quad = layer_to_viewport * text_bounding_box(layer, document, font_cache); overlay_context.dashed_quad(transformed_quad, None, None, Some(7.), Some(5.), None); } + + if let Some(vector_data) = document.network_interface.compute_modified_vector(layer) { + overlay_context.outline_free_floating_anchors(vector_data, layer_to_viewport); + } } } @@ -573,13 +578,17 @@ impl Fsm for SelectToolFsmState { let not_selected_click = click.filter(|&hovered_layer| !document.network_interface.selected_nodes().selected_layers_contains(hovered_layer, document.metadata())); if let Some(layer) = not_selected_click { if overlay_context.visibility_settings.hover_outline() { + let layer_to_viewport = document.metadata().transform_to_viewport(layer); let mut hover_overlay_draw = |layer: LayerNodeIdentifier, color: Option<&str>| { if layer.has_children(document.metadata()) { if let Some(bounds) = document.metadata().bounding_box_viewport(layer) { overlay_context.quad(Quad::from_box(bounds), color, None); } } else { - overlay_context.outline(document.metadata().layer_outline(layer), document.metadata().transform_to_viewport(layer), color); + overlay_context.outline(document.metadata().layer_outline(layer), layer_to_viewport, color); + if let Some(vector_data) = document.network_interface.compute_modified_vector(layer) { + overlay_context.outline_free_floating_anchors(vector_data, layer_to_viewport); + } } }; let layer = match tool_data.nested_selection_behavior { @@ -817,7 +826,12 @@ impl Fsm for SelectToolFsmState { if overlay_context.visibility_settings.selection_outline() { // Draws a temporary outline on the layers that will be selected by the current box/lasso area for layer in layers_to_outline { - overlay_context.outline(document.metadata().layer_outline(layer), document.metadata().transform_to_viewport(layer), None); + let layer_to_viewport = document.metadata().transform_to_viewport(layer); + overlay_context.outline(document.metadata().layer_outline(layer), layer_to_viewport, None); + + if let Some(vector_data) = document.network_interface.compute_modified_vector(layer) { + overlay_context.outline_free_floating_anchors(vector_data, layer_to_viewport); + } } } diff --git a/node-graph/gcore/src/graphic_element/renderer.rs b/node-graph/gcore/src/graphic_element/renderer.rs index 6b1f4f436f..54403e9df2 100644 --- a/node-graph/gcore/src/graphic_element/renderer.rs +++ b/node-graph/gcore/src/graphic_element/renderer.rs @@ -9,7 +9,7 @@ use crate::vector::style::{Fill, Stroke, ViewMode}; use crate::vector::{PointId, VectorDataTable}; use crate::{Artboard, ArtboardGroupTable, Color, GraphicElement, GraphicGroupTable, RasterFrame}; use base64::Engine; -use bezier_rs::Subpath; +use bezier_rs::{ManipulatorGroup, Subpath}; use dyn_any::DynAny; use glam::{DAffine2, DMat2, DVec2}; use num_traits::Zero; @@ -20,22 +20,43 @@ use std::fmt::Write; #[cfg(feature = "vello")] use vello::*; +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum ClickTargetGroup { + Subpath(bezier_rs::Subpath), + PointGroup(ManipulatorGroup), +} + /// Represents a clickable target for the layer #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct ClickTarget { - subpath: bezier_rs::Subpath, + target_group: ClickTargetGroup, stroke_width: f64, bounding_box: Option<[DVec2; 2]>, } impl ClickTarget { - pub fn new(subpath: bezier_rs::Subpath, stroke_width: f64) -> Self { + pub fn new_with_subpath(subpath: bezier_rs::Subpath, stroke_width: f64) -> Self { let bounding_box = subpath.loose_bounding_box(); - Self { subpath, stroke_width, bounding_box } + Self { + target_group: ClickTargetGroup::Subpath(subpath), + stroke_width, + bounding_box, + } + } + + pub fn new_with_point_group(point: ManipulatorGroup) -> Self { + let stroke_width = 10.; + let bounding_box = Some([point.anchor - DVec2::splat(stroke_width / 2.), point.anchor + DVec2::splat(stroke_width / 2.)]); + + Self { + target_group: ClickTargetGroup::PointGroup(point), + stroke_width, + bounding_box, + } } - pub fn subpath(&self) -> &bezier_rs::Subpath { - &self.subpath + pub fn target_group(&self) -> &ClickTargetGroup { + &self.target_group } pub fn bounding_box(&self) -> Option<[DVec2; 2]> { @@ -47,12 +68,26 @@ impl ClickTarget { } pub fn apply_transform(&mut self, affine_transform: DAffine2) { - self.subpath.apply_transform(affine_transform); + match self.target_group { + ClickTargetGroup::Subpath(ref mut subpath) => { + subpath.apply_transform(affine_transform); + } + ClickTargetGroup::PointGroup(ref mut point_group) => { + point_group.apply_transform(affine_transform); + } + } self.update_bbox(); } fn update_bbox(&mut self) { - self.bounding_box = self.subpath.bounding_box(); + match self.target_group { + ClickTargetGroup::Subpath(ref subpath) => { + self.bounding_box = subpath.bounding_box(); + } + ClickTargetGroup::PointGroup(ref point_group) => { + self.bounding_box = Some([point_group.anchor - DVec2::splat(self.stroke_width / 2.), point_group.anchor + DVec2::splat(self.stroke_width / 2.)]); + } + } } /// Does the click target intersect the path @@ -66,19 +101,27 @@ impl ClickTarget { let inverse = layer_transform.inverse(); let mut bezier_iter = || bezier_iter().map(|bezier| bezier.apply_transformation(|point| inverse.transform_point2(point))); - // 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 self.subpath.iter().any(outline_intersects) { - return true; - } - // Check if selection is entirely within the shape - if self.subpath.closed() && bezier_iter().next().is_some_and(|bezier| self.subpath.contains_point(bezier.start)) { - return true; - } + match self.target_group() { + ClickTargetGroup::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; + } - // Check if shape is entirely within selection - let any_point_from_subpath = self.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::() != 0) + // Check if shape is entirely within selection + let any_point_from_subpath = subpath.manipulator_groups().first().map(|group| group.anchor); + return any_point_from_subpath.is_some_and(|shape_point| bezier_iter().map(|bezier| bezier.winding(shape_point)).sum::() != 0); + } + ClickTargetGroup::PointGroup(point_group) => { + let point = point_group.anchor; + bezier_iter().map(|bezier| bezier.winding(point)).sum::() != 0 + } + } } /// Does the click target intersect the point (accounting for stroke size) @@ -107,7 +150,10 @@ impl ClickTarget { .is_some_and(|bbox| bbox[0].x <= point.x && point.x <= bbox[1].x && bbox[0].y <= point.y && point.y <= bbox[1].y) { // Check if the point is within the shape - self.subpath.closed() && self.subpath.contains_point(point) + match self.target_group() { + ClickTargetGroup::Subpath(subpath) => subpath.closed() && subpath.contains_point(point), + ClickTargetGroup::PointGroup(point_group) => point_group.anchor == point, + } } else { false } @@ -652,10 +698,22 @@ impl GraphicElementRendered for VectorDataTable { subpath }; + // For free-floating anchors, we need to add a click target for each + let single_anchors = instance.point_domain.ids().iter().filter(|&&point_id| instance.connected_count(point_id) == 0); + let single_anchors_targets = single_anchors + .map(|&point_id| { + let anchor = instance.point_domain.position_from_id(point_id).unwrap_or_default(); + let group = ManipulatorGroup::new_anchor_with_id(anchor, point_id); + + ClickTarget::new_with_point_group(group) + }) + .collect::>(); + let click_targets = instance .stroke_bezier_paths() .map(fill) - .map(|subpath| ClickTarget::new(subpath, stroke_width)) + .map(|subpath| ClickTarget::new_with_subpath(subpath, stroke_width)) + .chain(single_anchors_targets.into_iter()) .collect::>(); metadata.click_targets.insert(element_id, click_targets); @@ -670,6 +728,18 @@ impl GraphicElementRendered for VectorDataTable { fn add_upstream_click_targets(&self, click_targets: &mut Vec) { for instance in self.instance_ref_iter() { + // For free-floating anchors, we need to add a click target for each + let single_anchors = instance.instance.point_domain.ids().iter().filter(|&&point_id| instance.instance.connected_count(point_id) == 0); + let single_anchors_targets = single_anchors + .map(|&point_id| { + let anchor = instance.instance.point_domain.position_from_id(point_id).unwrap_or_default(); + let group = ManipulatorGroup::new_anchor_with_id(anchor, point_id); + let mut click_target = ClickTarget::new_with_point_group(group); + click_target.apply_transform(*instance.transform); + click_target + }) + .collect::>(); + let stroke_width = instance.instance.style.stroke().as_ref().map_or(0., Stroke::weight); let filled = instance.instance.style.fill() != &Fill::None; let fill = |mut subpath: bezier_rs::Subpath<_>| { @@ -679,10 +749,11 @@ impl GraphicElementRendered for VectorDataTable { subpath }; click_targets.extend(instance.instance.stroke_bezier_paths().map(fill).map(|subpath| { - let mut click_target = ClickTarget::new(subpath, stroke_width); + let mut click_target = ClickTarget::new_with_subpath(subpath, stroke_width); click_target.apply_transform(*instance.transform); click_target })); + click_targets.extend(single_anchors_targets); } } @@ -784,7 +855,7 @@ impl GraphicElementRendered for Artboard { fn collect_metadata(&self, metadata: &mut RenderMetadata, mut footprint: Footprint, element_id: Option) { 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(subpath, 0.)]); + metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.)]); metadata.upstream_footprints.insert(element_id, footprint); metadata.local_transforms.insert(element_id, DAffine2::from_translation(self.location.as_dvec2())); if self.clip { @@ -797,7 +868,7 @@ impl GraphicElementRendered for Artboard { fn add_upstream_click_targets(&self, click_targets: &mut Vec) { let subpath_rectangle = Subpath::new_rect(DVec2::ZERO, self.dimensions.as_dvec2()); - click_targets.push(ClickTarget::new(subpath_rectangle, 0.)); + click_targets.push(ClickTarget::new_with_subpath(subpath_rectangle, 0.)); } fn contains_artboard(&self) -> bool { @@ -910,14 +981,14 @@ impl GraphicElementRendered for ImageFrameTable { let Some(element_id) = element_id else { return }; let subpath = Subpath::new_rect(DVec2::ZERO, DVec2::ONE); - metadata.click_targets.insert(element_id, vec![ClickTarget::new(subpath, 0.)]); + metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.)]); metadata.upstream_footprints.insert(element_id, footprint); metadata.local_transforms.insert(element_id, instance_transform); } fn add_upstream_click_targets(&self, click_targets: &mut Vec) { let subpath = Subpath::new_rect(DVec2::ZERO, DVec2::ONE); - click_targets.push(ClickTarget::new(subpath, 0.)); + click_targets.push(ClickTarget::new_with_subpath(subpath, 0.)); } } @@ -986,14 +1057,14 @@ impl GraphicElementRendered for RasterFrame { let Some(element_id) = element_id else { return }; let subpath = Subpath::new_rect(DVec2::ZERO, DVec2::ONE); - metadata.click_targets.insert(element_id, vec![ClickTarget::new(subpath, 0.)]); + metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.)]); metadata.upstream_footprints.insert(element_id, footprint); metadata.local_transforms.insert(element_id, self.transform()); } fn add_upstream_click_targets(&self, click_targets: &mut Vec) { let subpath = Subpath::new_rect(DVec2::ZERO, DVec2::ONE); - click_targets.push(ClickTarget::new(subpath, 0.)); + click_targets.push(ClickTarget::new_with_subpath(subpath, 0.)); } } diff --git a/node-graph/gcore/src/vector/vector_data.rs b/node-graph/gcore/src/vector/vector_data.rs index aba89db824..d1079c7c41 100644 --- a/node-graph/gcore/src/vector/vector_data.rs +++ b/node-graph/gcore/src/vector/vector_data.rs @@ -5,6 +5,7 @@ mod modification; use super::misc::point_to_dvec2; use super::style::{PathStyle, Stroke}; use crate::instances::Instances; +use crate::renderer::ClickTargetGroup; use crate::{AlphaBlending, Color, GraphicGroupTable}; pub use attributes::*; use bezier_rs::{BezierHandles, ManipulatorGroup}; @@ -114,11 +115,6 @@ impl VectorData { } } - /// Construct some new vector data from a single subpath with an identity transform and black fill. - pub fn from_subpath(subpath: impl Borrow>) -> Self { - Self::from_subpaths([subpath], false) - } - /// Push a subpath to the vector data pub fn append_subpath(&mut self, subpath: impl Borrow>, preserve_id: bool) { let subpath: &bezier_rs::Subpath = subpath.borrow(); @@ -134,6 +130,7 @@ impl VectorData { let mut segment_id = self.segment_domain.next_id(); let mut last_point = None; let mut first_point = None; + // Constructs a bezier segment from the two manipulators on the subpath. for pair in subpath.manipulator_groups().windows(2) { let start = last_point.unwrap_or_else(|| { let id = if preserve_id && !self.point_domain.ids().contains(&pair[0].id) { @@ -177,6 +174,17 @@ impl VectorData { } } + pub fn append_point_group(&mut self, point_group: &ManipulatorGroup, preserve_id: bool) { + let mut point_id = self.point_domain.next_id(); + // Use the current point id if it is not already in the domain else generate a new one + let id = if preserve_id && !self.point_domain.ids().contains(&point_group.id) { + point_group.id + } else { + point_id.next_id() + }; + self.point_domain.push(id, point_group.anchor); + } + /// Appends a Kurbo BezPath to the vector data. pub fn append_bezpath(&mut self, bezpath: kurbo::BezPath) { let mut first_point_index = None; @@ -241,6 +249,11 @@ impl VectorData { } } + /// Construct some new vector data from a single subpath with an identity transform and black fill. + pub fn from_subpath(subpath: impl Borrow>) -> Self { + Self::from_subpaths([subpath], false) + } + /// Construct some new vector data from subpaths with an identity transform and black fill. pub fn from_subpaths(subpaths: impl IntoIterator>>, preserve_id: bool) -> Self { let mut vector_data = Self::empty(); @@ -252,6 +265,18 @@ impl VectorData { vector_data } + pub fn from_target_groups(target_groups: impl IntoIterator>, preserve_id: bool) -> Self { + let mut vector_data = Self::empty(); + for target_group in target_groups.into_iter() { + let target_group = target_group.borrow(); + match target_group { + ClickTargetGroup::Subpath(subpath) => vector_data.append_subpath(subpath, preserve_id), + ClickTargetGroup::PointGroup(point_group) => vector_data.append_point_group(point_group, preserve_id), + } + } + vector_data + } + /// Compute the bounding boxes of the subpaths without any transform pub fn bounding_box(&self) -> Option<[DVec2; 2]> { self.bounding_box_with_transform(DAffine2::IDENTITY)