From 6802365e145063be1777204caed33df9671583d3 Mon Sep 17 00:00:00 2001 From: Priyanshu <129358934+indierusty@users.noreply.github.com> Date: Fri, 31 Jan 2025 14:07:49 +0530 Subject: [PATCH] Add lasso selection mode (#2235) * add document method to check intersection and insideness with polygon * impl lasso selection * impl lasso select to always add to selection * add hint for lasso selection * fix calculating selection mode based on direction on each pointer move * fix lasso polygon lagging behing mouse position * add overlay to draw lasso polygon with fill color same as quad * fix comment * change removing from selection key binding from Sift + Ctrl to just Alt * impl Alt to shrink selection for quad in path tool * refactor rename SelectionType to SelectionShape * impl lasso overlay for path tool * impl selecting anchors and handles intersection lasso in path tool * add keys hint info * fix converting lasso polygon to closed subpath which is has less than two points * Code review * impl preferences-based selection mode to the Path tool for only for overlays, --------- Co-authored-by: Keavon Chambers --- editor/src/consts.rs | 1 + .../messages/input_mapper/input_mappings.rs | 10 +- .../document/document_message_handler.rs | 37 +++- .../document/overlays/utility_types.rs | 20 ++- .../tool/common_functionality/shape_editor.rs | 65 ++++++- .../messages/tool/tool_messages/path_tool.rs | 151 +++++++++++++--- .../tool/tool_messages/select_tool.rs | 162 +++++++++++++----- 7 files changed, 365 insertions(+), 81 deletions(-) diff --git a/editor/src/consts.rs b/editor/src/consts.rs index 0e70d4cc10..ec247089c9 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -52,6 +52,7 @@ pub const DEFAULT_STROKE_WIDTH: f64 = 2.; // SELECT TOOL pub const SELECTION_TOLERANCE: f64 = 5.; +pub const DRAG_DIRECTION_MODE_DETERMINATION_THRESHOLD: f64 = 15.; pub const SELECTION_DRAG_ANGLE: f64 = 90.; pub const PIVOT_CROSSHAIR_THICKNESS: f64 = 1.; pub const PIVOT_CROSSHAIR_LENGTH: f64 = 9.; diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index a8839fd9db..afd581873f 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -98,8 +98,8 @@ pub fn input_mappings() -> Mapping { // // SelectToolMessage entry!(PointerMove; refresh_keys=[Control, Alt, Shift], action_dispatch=SelectToolMessage::PointerMove(SelectToolPointerKeys { axis_align: Shift, snap_angle: Control, center: Alt, duplicate: Alt })), - entry!(KeyDown(MouseLeft); action_dispatch=SelectToolMessage::DragStart { extend_selection: Shift, select_deepest: Accel }), - entry!(KeyUp(MouseLeft); action_dispatch=SelectToolMessage::DragStop { remove_from_selection: Shift, negative_box_selection: Control }), + entry!(KeyDown(MouseLeft); action_dispatch=SelectToolMessage::DragStart { extend_selection: Shift, remove_from_selection: Alt, select_deepest: Accel, lasso_select: Control }), + entry!(KeyUp(MouseLeft); action_dispatch=SelectToolMessage::DragStop { remove_from_selection: Alt }), entry!(KeyDown(Enter); action_dispatch=SelectToolMessage::Enter), entry!(DoubleClick(MouseButton::Left); action_dispatch=SelectToolMessage::EditLayer), entry!(KeyDown(MouseRight); action_dispatch=SelectToolMessage::Abort), @@ -213,7 +213,7 @@ pub fn input_mappings() -> Mapping { entry!(KeyDown(Delete); modifiers=[Shift], action_dispatch=PathToolMessage::BreakPath), entry!(KeyDown(Backspace); modifiers=[Shift], action_dispatch=PathToolMessage::BreakPath), entry!(KeyDown(Tab); action_dispatch=PathToolMessage::SwapSelectedHandles), - entry!(KeyDown(MouseLeft); action_dispatch=PathToolMessage::MouseDown { direct_insert_without_sliding: Control, extend_selection: Shift }), + entry!(KeyDown(MouseLeft); action_dispatch=PathToolMessage::MouseDown { direct_insert_without_sliding: Control, extend_selection: Shift, lasso_select: Control }), entry!(KeyDown(MouseRight); action_dispatch=PathToolMessage::RightClick), entry!(KeyDown(Escape); action_dispatch=PathToolMessage::Escape), entry!(KeyDown(KeyG); action_dispatch=PathToolMessage::GRS { key: KeyG }), @@ -224,8 +224,8 @@ pub fn input_mappings() -> Mapping { entry!(KeyDown(KeyA); modifiers=[Accel], action_dispatch=PathToolMessage::SelectAllAnchors), entry!(KeyDown(KeyA); modifiers=[Accel, Shift], action_dispatch=PathToolMessage::DeselectAllPoints), entry!(KeyDown(Backspace); action_dispatch=PathToolMessage::Delete), - entry!(KeyUp(MouseLeft); action_dispatch=PathToolMessage::DragStop { extend_selection: Shift }), - entry!(KeyDown(Enter); action_dispatch=PathToolMessage::Enter { extend_selection: Shift }), + entry!(KeyUp(MouseLeft); action_dispatch=PathToolMessage::DragStop { extend_selection: Shift, shrink_selection: Alt }), + entry!(KeyDown(Enter); action_dispatch=PathToolMessage::Enter { extend_selection: Shift, shrink_selection: Alt }), entry!(DoubleClick(MouseButton::Left); action_dispatch=PathToolMessage::FlipSmoothSharp), entry!(KeyDown(ArrowRight); action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: NUDGE_AMOUNT, delta_y: 0. }), entry!(KeyDown(ArrowRight); modifiers=[Shift], action_dispatch=PathToolMessage::NudgeSelectedPoints { delta_x: BIG_NUDGE_AMOUNT, delta_y: 0. }), diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index ce4d7ab540..2f92dac954 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -25,13 +25,14 @@ use crate::messages::tool::tool_messages::tool_prelude::Key; use crate::messages::tool::utility_types::ToolType; use crate::node_graph_executor::NodeGraphExecutor; +use bezier_rs::Subpath; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeNetwork, OldNodeNetwork}; use graphene_core::raster::image::ImageFrame; use graphene_core::raster::BlendMode; use graphene_core::vector::style::ViewMode; use graphene_std::renderer::{ClickTarget, Quad}; -use graphene_std::vector::path_bool_lib; +use graphene_std::vector::{path_bool_lib, PointId}; use glam::{DAffine2, DVec2, IVec2}; @@ -1405,6 +1406,19 @@ impl DocumentMessageHandler { self.intersect_quad(viewport_quad, ipp).filter(|layer| !self.network_interface.is_artboard(&layer.to_node(), &[])) } + /// Runs an intersection test with all layers and a viewport space subpath + pub fn intersect_polygon<'a>(&'a self, mut viewport_polygon: Subpath, ipp: &InputPreprocessorMessageHandler) -> impl Iterator + 'a { + let document_to_viewport = self.navigation_handler.calculate_offset_transform(ipp.viewport_bounds.center(), &self.document_ptz); + viewport_polygon.apply_transform(document_to_viewport.inverse()); + + ClickXRayIter::new(&self.network_interface, XRayTarget::Polygon(viewport_polygon)) + } + + /// Runs an intersection test with all layers and a viewport space subpath; ignoring artboards + pub fn intersect_polygon_no_artboards<'a>(&'a self, viewport_polygon: Subpath, ipp: &InputPreprocessorMessageHandler) -> impl Iterator + 'a { + self.intersect_polygon(viewport_polygon, ipp).filter(|layer| !self.network_interface.is_artboard(&layer.to_node(), &[])) + } + pub fn is_layer_fully_inside(&self, layer: &LayerNodeIdentifier, quad: graphene_core::renderer::Quad) -> bool { // Get the bounding box of the layer in document space let Some(bounding_box) = self.metadata().bounding_box_viewport(*layer) else { return false }; @@ -1428,6 +1442,22 @@ impl DocumentMessageHandler { layer_left >= quad_left && layer_right <= quad_right && layer_top <= quad_top && layer_bottom >= quad_bottom } + pub fn is_layer_fully_inside_polygon(&self, layer: &LayerNodeIdentifier, ipp: &InputPreprocessorMessageHandler, mut viewport_polygon: Subpath) -> bool { + let document_to_viewport = self.navigation_handler.calculate_offset_transform(ipp.viewport_bounds.center(), &self.document_ptz); + viewport_polygon.apply_transform(document_to_viewport.inverse()); + + let layer_click_targets = self.network_interface.document_metadata().click_targets(*layer); + 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) + }) + }) + } + /// Find all of the layers that were clicked on from a viewport space location pub fn click_xray(&self, ipp: &InputPreprocessorMessageHandler) -> impl Iterator + '_ { let document_to_viewport = self.navigation_handler.calculate_offset_transform(ipp.viewport_bounds.center(), &self.document_ptz); @@ -2172,6 +2202,7 @@ enum XRayTarget { Point(DVec2), Quad(Quad), Path(Vec), + Polygon(Subpath), } /// The result for the [`ClickXRayIter`] on the layer @@ -2275,6 +2306,10 @@ impl<'a> ClickXRayIter<'a> { } XRayTarget::Quad(quad) => self.check_layer_area_target(click_targets, clip, layer, quad_to_path_lib_segments(*quad), transform), XRayTarget::Path(path) => self.check_layer_area_target(click_targets, clip, layer, path.clone(), transform), + XRayTarget::Polygon(polygon) => { + let polygon = polygon.iter_closed().map(|line| path_bool_lib::PathSegment::Line(line.start, line.end)).collect(); + self.check_layer_area_target(click_targets, clip, layer, polygon, transform) + } } } } diff --git a/editor/src/messages/portfolio/document/overlays/utility_types.rs b/editor/src/messages/portfolio/document/overlays/utility_types.rs index b93f296f54..7c1f7bf12d 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types.rs @@ -37,10 +37,22 @@ impl core::hash::Hash for OverlayContext { impl OverlayContext { pub fn quad(&mut self, quad: Quad, color_fill: Option<&str>) { - self.dashed_quad(quad, color_fill, None, None, None); + self.dashed_polygon(&quad.0, color_fill, None, None, None); } pub fn dashed_quad(&mut self, quad: Quad, color_fill: Option<&str>, dash_width: Option, dash_gap_width: Option, dash_offset: Option) { + self.dashed_polygon(&quad.0, color_fill, dash_width, dash_gap_width, dash_offset); + } + + pub fn polygon(&mut self, polygon: &[DVec2], color_fill: Option<&str>) { + self.dashed_polygon(&polygon, color_fill, None, None, None); + } + + pub fn dashed_polygon(&mut self, polygon: &[DVec2], color_fill: Option<&str>, dash_width: Option, dash_gap_width: Option, dash_offset: Option) { + if polygon.len() < 2 { + return; + } + self.start_dpi_aware_transform(); // Set the dash pattern @@ -63,10 +75,10 @@ impl OverlayContext { } self.render_context.begin_path(); - self.render_context.move_to(quad.0[3].x.round() - 0.5, quad.0[3].y.round() - 0.5); + self.render_context.move_to(polygon.last().unwrap().x.round() - 0.5, polygon.last().unwrap().y.round() - 0.5); - for i in 0..4 { - self.render_context.line_to(quad.0[i].x.round() - 0.5, quad.0[i].y.round() - 0.5); + for point in polygon { + self.render_context.line_to(point.x.round() - 0.5, point.y.round() - 0.5); } if let Some(color_fill) = color_fill { diff --git a/editor/src/messages/tool/common_functionality/shape_editor.rs b/editor/src/messages/tool/common_functionality/shape_editor.rs index 1b6f868765..41745d7b71 100644 --- a/editor/src/messages/tool/common_functionality/shape_editor.rs +++ b/editor/src/messages/tool/common_functionality/shape_editor.rs @@ -7,13 +7,32 @@ use crate::messages::prelude::*; use crate::messages::tool::common_functionality::snapping::SnapTypeConfiguration; use crate::messages::tool::tool_messages::path_tool::PointSelectState; -use bezier_rs::{Bezier, BezierHandles, TValue}; +use bezier_rs::{Bezier, BezierHandles, Subpath, TValue}; use graphene_core::transform::Transform; use graphene_core::vector::{ManipulatorPointId, PointId, VectorData, VectorModificationType}; use glam::{DAffine2, DVec2}; use graphene_std::vector::{HandleId, SegmentId}; +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum SelectionChange { + Clear, + Extend, + Shrink, +} + +#[derive(Clone, Copy, Debug)] +pub enum SelectionShape<'a> { + Box([DVec2; 2]), + Lasso(&'a Vec), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum SelectionShapeType { + Box, + Lasso, +} + #[derive(Debug, PartialEq, Eq, Copy, Clone, Default)] pub enum ManipulatorAngle { #[default] @@ -1394,9 +1413,9 @@ impl ShapeState { false } - pub fn select_all_in_quad(&mut self, network_interface: &NodeNetworkInterface, quad: [DVec2; 2], clear_selection: bool) { + pub fn select_all_in_shape(&mut self, network_interface: &NodeNetworkInterface, selection_shape: SelectionShape, selection_change: SelectionChange) { for (&layer, state) in &mut self.selected_shape_state { - if clear_selection { + if selection_change == SelectionChange::Clear { state.clear_points() } @@ -1413,13 +1432,34 @@ impl ShapeState { assert!(vector_data.point_domain.ids().contains(&end)); } + let polygon_subpath = if let SelectionShape::Lasso(polygon) = selection_shape { + if polygon.len() < 2 { + return; + } + let polygon: Subpath = Subpath::from_anchors_linear(polygon.to_vec(), true); + Some(polygon) + } else { + None + }; + for (id, bezier, _, _) in vector_data.segment_bezier_iter() { for (position, id) in [(bezier.handle_start(), ManipulatorPointId::PrimaryHandle(id)), (bezier.handle_end(), ManipulatorPointId::EndHandle(id))] { let Some(position) = position else { continue }; let transformed_position = transform.transform_point2(position); - if quad[0].min(quad[1]).cmple(transformed_position).all() && quad[0].max(quad[1]).cmpge(transformed_position).all() { - state.select_point(id); + let select = match selection_shape { + SelectionShape::Box(quad) => quad[0].min(quad[1]).cmple(transformed_position).all() && quad[0].max(quad[1]).cmpge(transformed_position).all(), + SelectionShape::Lasso(_) => polygon_subpath + .as_ref() + .expect("If `selection_shape` is a polygon then subpath is constructed beforehand.") + .contains_point(transformed_position), + }; + + if select { + match selection_change { + SelectionChange::Shrink => state.deselect_point(id), + _ => state.select_point(id), + } } } } @@ -1427,8 +1467,19 @@ impl ShapeState { for (&id, &position) in vector_data.point_domain.ids().iter().zip(vector_data.point_domain.positions()) { let transformed_position = transform.transform_point2(position); - if quad[0].min(quad[1]).cmple(transformed_position).all() && quad[0].max(quad[1]).cmpge(transformed_position).all() { - state.select_point(ManipulatorPointId::Anchor(id)); + let select = match selection_shape { + SelectionShape::Box(quad) => quad[0].min(quad[1]).cmple(transformed_position).all() && quad[0].max(quad[1]).cmpge(transformed_position).all(), + SelectionShape::Lasso(_) => polygon_subpath + .as_ref() + .expect("If `selection_shape` is a polygon then subpath is constructed beforehand.") + .contains_point(transformed_position), + }; + + if select { + match selection_change { + SelectionChange::Shrink => state.deselect_point(ManipulatorPointId::Anchor(id)), + _ => state.select_point(ManipulatorPointId::Anchor(id)), + } } } } diff --git a/editor/src/messages/tool/tool_messages/path_tool.rs b/editor/src/messages/tool/tool_messages/path_tool.rs index 96a6975aa7..ac9efe6f19 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -1,11 +1,17 @@ +use super::select_tool::extend_lasso; use super::tool_prelude::*; -use crate::consts::{COLOR_OVERLAY_BLUE, DRAG_THRESHOLD, HANDLE_ROTATE_SNAP_ANGLE, INSERT_POINT_ON_SEGMENT_TOO_FAR_DISTANCE, SELECTION_THRESHOLD, SELECTION_TOLERANCE}; +use crate::consts::{ + COLOR_OVERLAY_BLUE, DRAG_DIRECTION_MODE_DETERMINATION_THRESHOLD, DRAG_THRESHOLD, HANDLE_ROTATE_SNAP_ANGLE, INSERT_POINT_ON_SEGMENT_TOO_FAR_DISTANCE, SELECTION_THRESHOLD, SELECTION_TOLERANCE, +}; use crate::messages::portfolio::document::overlays::utility_functions::path_overlays; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; +use crate::messages::preferences::SelectionMode; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; -use crate::messages::tool::common_functionality::shape_editor::{ClosestSegment, ManipulatorAngle, OpposingHandleLengths, SelectedPointsInfo, ShapeState}; +use crate::messages::tool::common_functionality::shape_editor::{ + ClosestSegment, ManipulatorAngle, OpposingHandleLengths, SelectedPointsInfo, SelectionChange, SelectionShape, SelectionShapeType, ShapeState, +}; use crate::messages::tool::common_functionality::snapping::{SnapCache, SnapCandidatePoint, SnapConstraint, SnapData, SnapManager}; use graphene_core::renderer::Quad; @@ -35,9 +41,11 @@ pub enum PathToolMessage { DeleteAndBreakPath, DragStop { extend_selection: Key, + shrink_selection: Key, }, Enter { extend_selection: Key, + shrink_selection: Key, }, Escape, ClosePath, @@ -51,6 +59,7 @@ pub enum PathToolMessage { MouseDown { direct_insert_without_sliding: Key, extend_selection: Key, + lasso_select: Key, }, NudgeSelectedPoints { delta_x: f64, @@ -230,7 +239,7 @@ impl<'a> MessageHandler> for PathToo DeleteAndBreakPath, SwapSelectedHandles, ), - PathToolFsmState::DrawingBox => actions!(PathToolMessageDiscriminant; + PathToolFsmState::Drawing { .. } => actions!(PathToolMessageDiscriminant; FlipSmoothSharp, DragStop, PointerMove, @@ -283,7 +292,9 @@ enum PathToolFsmState { #[default] Ready, Dragging(DraggingState), - DrawingBox, + Drawing { + selection_shape: SelectionShapeType, + }, InsertPoint, } @@ -295,6 +306,8 @@ enum InsertEndKind { #[derive(Default)] struct PathToolData { snap_manager: SnapManager, + lasso_polygon: Vec, + selection_mode: Option, drag_start_pos: DVec2, previous_mouse_position: DVec2, toggle_colinear_debounce: bool, @@ -323,6 +336,37 @@ impl PathToolData { self.saved_points_before_anchor_select_toggle.clear(); } + pub fn selection_quad(&self) -> Quad { + let bbox = self.selection_box(); + Quad::from_box(bbox) + } + + pub fn calculate_direction(&mut self) -> SelectionMode { + let bbox = self.selection_box(); + let above_threshold = bbox[1].distance_squared(bbox[0]) > DRAG_DIRECTION_MODE_DETERMINATION_THRESHOLD.powi(2); + + if self.selection_mode.is_none() && above_threshold { + let mode = if bbox[1].x < bbox[0].x { + SelectionMode::Touched + } else { + // This also covers the case where they're equal: the area is zero, so we use `Enclosed` to ensure the selection ends up empty, as nothing will be enclosed by an empty area + SelectionMode::Enclosed + }; + self.selection_mode = Some(mode); + } + + self.selection_mode.unwrap_or(SelectionMode::Touched) + } + + pub fn selection_box(&self) -> [DVec2; 2] { + if self.previous_mouse_position == self.drag_start_pos { + let tolerance = DVec2::splat(SELECTION_TOLERANCE); + [self.drag_start_pos - tolerance, self.drag_start_pos + tolerance] + } else { + [self.drag_start_pos, self.previous_mouse_position] + } + } + fn start_insertion(&mut self, responses: &mut VecDeque, segment: ClosestSegment) -> PathToolFsmState { if self.segment.is_some() { warn!("Segment was `Some(..)` before `start_insertion`") @@ -370,6 +414,7 @@ impl PathToolData { PathToolFsmState::Ready } + #[allow(clippy::too_many_arguments)] fn mouse_down( &mut self, shape_editor: &mut ShapeState, @@ -378,6 +423,7 @@ impl PathToolData { responses: &mut VecDeque, extend_selection: bool, direct_insert_without_sliding: bool, + lasso_select: bool, ) -> PathToolFsmState { self.double_click_handled = false; self.opposing_handle_lengths = None; @@ -419,12 +465,13 @@ impl PathToolData { PathToolFsmState::Dragging(self.dragging_state) } - // Start drawing a box + // Start drawing else { self.drag_start_pos = input.mouse.position; self.previous_mouse_position = document.metadata().document_to_viewport.inverse().transform_point2(input.mouse.position); - PathToolFsmState::DrawingBox + let selection_shape = if lasso_select { SelectionShapeType::Lasso } else { SelectionShapeType::Box }; + PathToolFsmState::Drawing { selection_shape } } } @@ -659,13 +706,28 @@ impl Fsm for PathToolFsmState { path_overlays(document, shape_editor, &mut overlay_context); match self { - Self::DrawingBox => { - let fill_color = graphene_std::Color::from_rgb_str(crate::consts::COLOR_OVERLAY_BLUE.strip_prefix('#').unwrap()) + Self::Drawing { selection_shape } => { + let mut fill_color = graphene_std::Color::from_rgb_str(crate::consts::COLOR_OVERLAY_BLUE.strip_prefix('#').unwrap()) .unwrap() .with_alpha(0.05) .rgba_hex(); + fill_color.insert(0, '#'); + let fill_color = Some(fill_color.as_str()); - overlay_context.quad(Quad::from_box([tool_data.drag_start_pos, tool_data.previous_mouse_position]), Some(&("#".to_string() + &fill_color))); + let mut selection_direction = tool_action_data.preferences.get_selection_mode(); + if selection_direction == SelectionMode::Directional { + selection_direction = tool_data.calculate_direction(); + } + + let quad = tool_data.selection_quad(); + let polygon = &tool_data.lasso_polygon; + + match (selection_shape, selection_direction) { + (SelectionShapeType::Box, SelectionMode::Enclosed) => overlay_context.dashed_quad(quad, fill_color, Some(4.), Some(4.), Some(0.5)), + (SelectionShapeType::Lasso, SelectionMode::Enclosed) => overlay_context.dashed_polygon(polygon, fill_color, Some(4.), Some(4.), Some(0.5)), + (SelectionShapeType::Box, _) => overlay_context.quad(quad, fill_color), + (SelectionShapeType::Lasso, _) => overlay_context.polygon(polygon, fill_color), + } } Self::Dragging(_) => { tool_data.snap_manager.draw_overlays(SnapData::new(document, input), &mut overlay_context); @@ -694,7 +756,7 @@ impl Fsm for PathToolFsmState { } // `Self::InsertPoint` case: - (Self::InsertPoint, PathToolMessage::MouseDown { extend_selection, .. } | PathToolMessage::Enter { extend_selection }) => { + (Self::InsertPoint, PathToolMessage::MouseDown { extend_selection, .. } | PathToolMessage::Enter { extend_selection, .. }) => { tool_data.double_click_handled = true; let extend_selection = input.keyboard.get(extend_selection as usize); tool_data.end_insertion(shape_editor, responses, InsertEndKind::Add { extend_selection }) @@ -713,15 +775,20 @@ impl Fsm for PathToolFsmState { PathToolMessage::MouseDown { direct_insert_without_sliding, extend_selection, + lasso_select, }, ) => { let extend_selection = input.keyboard.get(extend_selection as usize); + let lasso_select = input.keyboard.get(lasso_select as usize); let direct_insert_without_sliding = input.keyboard.get(direct_insert_without_sliding as usize); - tool_data.mouse_down(shape_editor, document, input, responses, extend_selection, direct_insert_without_sliding) + tool_data.selection_mode = None; + tool_data.lasso_polygon.clear(); + + tool_data.mouse_down(shape_editor, document, input, responses, extend_selection, direct_insert_without_sliding, lasso_select) } ( - PathToolFsmState::DrawingBox, + PathToolFsmState::Drawing { selection_shape }, PathToolMessage::PointerMove { equidistant, toggle_colinear, @@ -731,6 +798,11 @@ impl Fsm for PathToolFsmState { }, ) => { tool_data.previous_mouse_position = input.mouse.position; + + if selection_shape == SelectionShapeType::Lasso { + extend_lasso(&mut tool_data.lasso_polygon, input.mouse.position); + } + responses.add(OverlaysMessage::Draw); // Auto-panning @@ -754,7 +826,7 @@ impl Fsm for PathToolFsmState { ]; tool_data.auto_panning.setup_by_mouse_position(input, &messages, responses); - PathToolFsmState::DrawingBox + PathToolFsmState::Drawing { selection_shape } } ( PathToolFsmState::Dragging(_), @@ -829,13 +901,13 @@ impl Fsm for PathToolFsmState { PathToolFsmState::Dragging(tool_data.dragging_state) } - (PathToolFsmState::DrawingBox, PathToolMessage::PointerOutsideViewport { .. }) => { + (PathToolFsmState::Drawing { selection_shape: selection_type }, PathToolMessage::PointerOutsideViewport { .. }) => { // Auto-panning if let Some(offset) = tool_data.auto_panning.shift_viewport(input, responses) { tool_data.drag_start_pos += offset; } - PathToolFsmState::DrawingBox + PathToolFsmState::Drawing { selection_shape: selection_type } } ( PathToolFsmState::Dragging(dragging_state), @@ -887,14 +959,30 @@ impl Fsm for PathToolFsmState { state } - (PathToolFsmState::DrawingBox, PathToolMessage::Enter { extend_selection }) => { + (PathToolFsmState::Drawing { selection_shape }, PathToolMessage::Enter { extend_selection, shrink_selection }) => { let extend_selection = input.keyboard.get(extend_selection as usize); + let shrink_selection = input.keyboard.get(shrink_selection as usize); + + let selection_change = if shrink_selection { + SelectionChange::Shrink + } else if extend_selection { + SelectionChange::Extend + } else { + SelectionChange::Clear + }; if tool_data.drag_start_pos == tool_data.previous_mouse_position { responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![] }); } else { - shape_editor.select_all_in_quad(&document.network_interface, [tool_data.drag_start_pos, tool_data.previous_mouse_position], !extend_selection); + match selection_shape { + SelectionShapeType::Box => { + let bbox = [tool_data.drag_start_pos, tool_data.previous_mouse_position]; + shape_editor.select_all_in_shape(&document.network_interface, SelectionShape::Box(bbox), selection_change); + } + SelectionShapeType::Lasso => shape_editor.select_all_in_shape(&document.network_interface, SelectionShape::Lasso(&tool_data.lasso_polygon), selection_change), + } } + responses.add(OverlaysMessage::Draw); PathToolFsmState::Ready @@ -904,25 +992,40 @@ impl Fsm for PathToolFsmState { tool_data.snap_manager.cleanup(responses); PathToolFsmState::Ready } - (PathToolFsmState::DrawingBox, PathToolMessage::Escape | PathToolMessage::RightClick) => { + (PathToolFsmState::Drawing { .. }, PathToolMessage::Escape | PathToolMessage::RightClick) => { tool_data.snap_manager.cleanup(responses); PathToolFsmState::Ready } // Mouse up - (PathToolFsmState::DrawingBox, PathToolMessage::DragStop { extend_selection }) => { + (PathToolFsmState::Drawing { selection_shape }, PathToolMessage::DragStop { extend_selection, shrink_selection }) => { let extend_selection = input.keyboard.get(extend_selection as usize); + let shrink_selection = input.keyboard.get(shrink_selection as usize); + + let select_kind = if shrink_selection { + SelectionChange::Shrink + } else if extend_selection { + SelectionChange::Extend + } else { + SelectionChange::Clear + }; if tool_data.drag_start_pos == tool_data.previous_mouse_position { responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![] }); } else { - shape_editor.select_all_in_quad(&document.network_interface, [tool_data.drag_start_pos, tool_data.previous_mouse_position], !extend_selection); + match selection_shape { + SelectionShapeType::Box => { + let bbox = [tool_data.drag_start_pos, tool_data.previous_mouse_position]; + shape_editor.select_all_in_shape(&document.network_interface, SelectionShape::Box(bbox), select_kind); + } + SelectionShapeType::Lasso => shape_editor.select_all_in_shape(&document.network_interface, SelectionShape::Lasso(&tool_data.lasso_polygon), select_kind), + } } responses.add(OverlaysMessage::Draw); responses.add(PathToolMessage::SelectedPointUpdated); PathToolFsmState::Ready } - (_, PathToolMessage::DragStop { extend_selection }) => { + (_, PathToolMessage::DragStop { extend_selection, .. }) => { if tool_data.select_anchor_toggled { shape_editor.deselect_all_points(); shape_editor.select_points_by_manipulator_id(&tool_data.saved_points_before_anchor_select_toggle); @@ -1058,6 +1161,7 @@ impl Fsm for PathToolFsmState { let hint_data = match self { PathToolFsmState::Ready => HintData(vec![ HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Select Point"), HintInfo::keys([Key::Shift], "Extend Selection").prepend_plus()]), + HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Select Area"), HintInfo::keys([Key::Control], "Lasso").prepend_plus()]), HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Insert Point on Segment")]), // TODO: Only show if at least one anchor is selected, and dynamically show either "Smooth" or "Sharp" based on the current state HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDouble, "Make Anchor Smooth/Sharp")]), @@ -1114,11 +1218,12 @@ impl Fsm for PathToolFsmState { dragging_hint_data } - PathToolFsmState::DrawingBox => HintData(vec![ + PathToolFsmState::Drawing { .. } => HintData(vec![ HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()]), HintGroup(vec![ HintInfo::mouse(MouseMotion::LmbDrag, "Select Area"), - HintInfo::keys([Key::Shift], "Extend Selection").prepend_plus(), + HintInfo::keys([Key::Shift], "Extend").prepend_plus(), + HintInfo::keys([Key::Alt], "Subtract").prepend_plus(), ]), ]), PathToolFsmState::InsertPoint => HintData(vec![ diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs index d41c32610a..4fc5e379b2 100644 --- a/editor/src/messages/tool/tool_messages/select_tool.rs +++ b/editor/src/messages/tool/tool_messages/select_tool.rs @@ -1,7 +1,7 @@ #![allow(clippy::too_many_arguments)] use super::tool_prelude::*; -use crate::consts::{ROTATE_SNAP_ANGLE, SELECTION_TOLERANCE}; +use crate::consts::{DRAG_DIRECTION_MODE_DETERMINATION_THRESHOLD, ROTATE_SNAP_ANGLE, SELECTION_TOLERANCE}; use crate::messages::input_mapper::utility_types::input_mouse::ViewportPosition; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; @@ -12,10 +12,12 @@ use crate::messages::portfolio::document::utility_types::transformation::Selecte use crate::messages::preferences::SelectionMode; use crate::messages::tool::common_functionality::graph_modification_utils::{get_text, is_layer_fed_by_node_of_name}; use crate::messages::tool::common_functionality::pivot::Pivot; +use crate::messages::tool::common_functionality::shape_editor::SelectionShapeType; use crate::messages::tool::common_functionality::snapping::{self, SnapCandidatePoint, SnapData, SnapManager}; use crate::messages::tool::common_functionality::transformation_cage::*; use crate::messages::tool::common_functionality::{auto_panning::AutoPanning, measure}; +use bezier_rs::Subpath; use graph_craft::document::NodeId; use graphene_core::renderer::Quad; use graphene_core::text::load_face; @@ -74,14 +76,23 @@ pub enum SelectToolMessage { Overlays(OverlayContext), // Tool-specific messages - DragStart { extend_selection: Key, select_deepest: Key }, - DragStop { remove_from_selection: Key, negative_box_selection: Key }, + DragStart { + extend_selection: Key, + remove_from_selection: Key, + select_deepest: Key, + lasso_select: Key, + }, + DragStop { + remove_from_selection: Key, + }, EditLayer, Enter, PointerMove(SelectToolPointerKeys), PointerOutsideViewport(SelectToolPointerKeys), SelectOptions(SelectOptionsUpdate), - SetPivot { position: PivotPosition }, + SetPivot { + position: PivotPosition, + }, } impl ToolMetadata for SelectTool { @@ -252,10 +263,11 @@ impl ToolTransition for SelectTool { } } } + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] enum SelectToolFsmState { Ready { selection: NestedSelectionBehavior }, - DrawingBox, + Drawing { selection_shape: SelectionShapeType }, Dragging, ResizingBounds, RotatingBounds, @@ -273,6 +285,8 @@ impl Default for SelectToolFsmState { struct SelectToolData { drag_start: ViewportPosition, drag_current: ViewportPosition, + lasso_polygon: Vec, + selection_mode: Option, layers_dragging: Vec, layer_selected_on_start: Option, select_single_layer: Option, @@ -308,14 +322,21 @@ impl SelectToolData { Quad::from_box(bbox) } - pub fn calculate_direction(&self) -> SelectionMode { + pub fn calculate_direction(&mut self) -> SelectionMode { let bbox: [DVec2; 2] = self.selection_box(); - if bbox[1].x < bbox[0].x { - SelectionMode::Touched - } else { - // This also covers the case where they're equal: the area is zero, so we use `Enclosed` to ensure the selection ends up empty, as nothing will be enclosed by an empty area - SelectionMode::Enclosed + let above_threshold = bbox[1].distance_squared(bbox[0]) > DRAG_DIRECTION_MODE_DETERMINATION_THRESHOLD.powi(2); + + if self.selection_mode.is_none() && above_threshold { + let mode = if bbox[1].x < bbox[0].x { + SelectionMode::Touched + } else { + // This also covers the case where they're equal: the area is zero, so we use `Enclosed` to ensure the selection ends up empty, as nothing will be enclosed by an empty area + SelectionMode::Enclosed + }; + self.selection_mode = Some(mode); } + + self.selection_mode.unwrap_or(SelectionMode::Touched) } pub fn selection_box(&self) -> [DVec2; 2] { @@ -327,6 +348,22 @@ impl SelectToolData { } } + pub fn intersect_lasso_no_artboards(&self, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler) -> Vec { + if self.lasso_polygon.len() < 2 { + return Vec::new(); + } + let polygon = Subpath::from_anchors_linear(self.lasso_polygon.clone(), true); + document.intersect_polygon_no_artboards(polygon, input).collect() + } + + pub fn is_layer_inside_lasso_polygon(&self, layer: &LayerNodeIdentifier, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler) -> bool { + if self.lasso_polygon.len() < 2 { + return false; + } + let polygon = Subpath::from_anchors_linear(self.lasso_polygon.clone(), true); + document.is_layer_fully_inside_polygon(layer, input, polygon) + } + /// Duplicates the currently dragging layers. Called when Alt is pressed and the layers have not yet been duplicated. fn start_duplicates(&mut self, document: &mut DocumentMessageHandler, responses: &mut VecDeque) { self.non_duplicated_layers = Some(self.layers_dragging.clone()); @@ -493,8 +530,8 @@ impl Fsm for SelectToolFsmState { // Update pivot tool_data.pivot.update_pivot(document, &mut overlay_context); - // Check if the tool is in box selection mode - if matches!(self, Self::DrawingBox) { + // Check if the tool is in selection mode + if let Self::Drawing { selection_shape } = self { // Get the updated selection box bounds let quad = Quad::from_box([tool_data.drag_start, tool_data.drag_current]); @@ -505,9 +542,16 @@ impl Fsm for SelectToolFsmState { // Draw outline visualizations on the layers to be selected let mut draw_layer_outline = |layer| overlay_context.outline(document.metadata().layer_outline(layer), document.metadata().transform_to_viewport(layer)); - let intersection = document.intersect_quad_no_artboards(quad, input); + let intersection: Vec = match selection_shape { + SelectionShapeType::Box => document.intersect_quad_no_artboards(quad, input).collect(), + SelectionShapeType::Lasso => tool_data.intersect_lasso_no_artboards(document, input), + }; if selection_direction == SelectionMode::Enclosed { - for layer in intersection.filter(|layer| document.is_layer_fully_inside(layer, quad)) { + let is_inside = |layer: &LayerNodeIdentifier| match selection_shape { + SelectionShapeType::Box => document.is_layer_fully_inside(layer, quad), + SelectionShapeType::Lasso => tool_data.is_layer_inside_lasso_polygon(layer, document, input), + }; + for layer in intersection.into_iter().filter(is_inside) { draw_layer_outline(layer); } } else { @@ -524,10 +568,13 @@ impl Fsm for SelectToolFsmState { fill_color.insert(0, '#'); let fill_color = Some(fill_color.as_str()); - if selection_direction == SelectionMode::Enclosed { - overlay_context.dashed_quad(quad, fill_color, Some(4.), Some(4.), Some(0.5)); - } else { - overlay_context.quad(quad, fill_color); + let polygon = &tool_data.lasso_polygon; + + match (selection_shape, selection_direction) { + (SelectionShapeType::Box, SelectionMode::Enclosed) => overlay_context.dashed_quad(quad, fill_color, Some(4.), Some(4.), Some(0.5)), + (SelectionShapeType::Lasso, SelectionMode::Enclosed) => overlay_context.dashed_polygon(polygon, fill_color, Some(4.), Some(4.), Some(0.5)), + (SelectionShapeType::Box, _) => overlay_context.quad(quad, fill_color), + (SelectionShapeType::Lasso, _) => overlay_context.polygon(polygon, fill_color), } } // Only highlight layers if the viewport is not being panned (middle mouse button is pressed) @@ -566,9 +613,18 @@ impl Fsm for SelectToolFsmState { self } - (SelectToolFsmState::Ready { .. }, SelectToolMessage::DragStart { extend_selection, select_deepest }) => { + ( + SelectToolFsmState::Ready { .. }, + SelectToolMessage::DragStart { + extend_selection, + remove_from_selection, + select_deepest, + lasso_select, + }, + ) => { tool_data.drag_start = input.mouse.position; tool_data.drag_current = input.mouse.position; + tool_data.selection_mode = None; let dragging_bounds = tool_data.bounding_box_manager.as_mut().and_then(|bounding_box| { let edges = bounding_box.check_selected_edges(input.mouse.position); @@ -697,8 +753,7 @@ impl Fsm for SelectToolFsmState { // Dragging a selection box else { tool_data.layers_dragging = selected; - - if !input.keyboard.key(extend_selection) { + if !input.keyboard.key(extend_selection) && !input.keyboard.key(remove_from_selection) { responses.add(DocumentMessage::DeselectAllLayers); tool_data.layers_dragging.clear(); } @@ -716,7 +771,8 @@ impl Fsm for SelectToolFsmState { responses.add(DocumentMessage::StartTransaction); SelectToolFsmState::Dragging } else { - SelectToolFsmState::DrawingBox + let selection_shape = if input.keyboard.key(lasso_select) { SelectionShapeType::Lasso } else { SelectionShapeType::Box }; + SelectToolFsmState::Drawing { selection_shape } } }; tool_data.non_duplicated_layers = None; @@ -873,10 +929,14 @@ impl Fsm for SelectToolFsmState { SelectToolFsmState::DraggingPivot } - (SelectToolFsmState::DrawingBox, SelectToolMessage::PointerMove(modifier_keys)) => { + (SelectToolFsmState::Drawing { selection_shape }, SelectToolMessage::PointerMove(modifier_keys)) => { tool_data.drag_current = input.mouse.position; responses.add(OverlaysMessage::Draw); + if selection_shape == SelectionShapeType::Lasso { + extend_lasso(&mut tool_data.lasso_polygon, tool_data.drag_current); + } + // AutoPanning let messages = [ SelectToolMessage::PointerOutsideViewport(modifier_keys.clone()).into(), @@ -884,7 +944,7 @@ impl Fsm for SelectToolFsmState { ]; tool_data.auto_panning.setup_by_mouse_position(input, &messages, responses); - SelectToolFsmState::DrawingBox + SelectToolFsmState::Drawing { selection_shape } } (SelectToolFsmState::Ready { .. }, SelectToolMessage::PointerMove(_)) => { let mut cursor = tool_data.bounding_box_manager.as_ref().map_or(MouseCursorIcon::Default, |bounds| bounds.get_cursor(input, true)); @@ -931,7 +991,7 @@ impl Fsm for SelectToolFsmState { self } - (SelectToolFsmState::DrawingBox, SelectToolMessage::PointerOutsideViewport(_)) => { + (SelectToolFsmState::Drawing { .. }, SelectToolMessage::PointerOutsideViewport(_)) => { // AutoPanning if let Some(shift) = tool_data.auto_panning.shift_viewport(input, responses) { tool_data.drag_start += shift; @@ -960,7 +1020,7 @@ impl Fsm for SelectToolFsmState { let selection = tool_data.nested_selection_behavior; SelectToolFsmState::Ready { selection } } - (SelectToolFsmState::Dragging, SelectToolMessage::DragStop { remove_from_selection, .. }) => { + (SelectToolFsmState::Dragging, SelectToolMessage::DragStop { remove_from_selection }) => { // Deselect layer if not snap dragging responses.add(DocumentMessage::EndTransaction); @@ -1059,13 +1119,7 @@ impl Fsm for SelectToolFsmState { let selection = tool_data.nested_selection_behavior; SelectToolFsmState::Ready { selection } } - ( - SelectToolFsmState::DrawingBox, - SelectToolMessage::DragStop { - remove_from_selection, - negative_box_selection, - }, - ) => { + (SelectToolFsmState::Drawing { selection_shape }, SelectToolMessage::DragStop { remove_from_selection }) => { let quad = tool_data.selection_quad(); let mut selection_direction = tool_action_data.preferences.get_selection_mode(); @@ -1073,17 +1127,24 @@ impl Fsm for SelectToolFsmState { selection_direction = tool_data.calculate_direction(); } - let intersection = document.intersect_quad_no_artboards(quad, input); + let intersection: Vec = match selection_shape { + SelectionShapeType::Box => document.intersect_quad_no_artboards(quad, input).collect(), + SelectionShapeType::Lasso => tool_data.intersect_lasso_no_artboards(document, input), + }; let new_selected: HashSet<_> = if selection_direction == SelectionMode::Enclosed { - intersection.filter(|layer| document.is_layer_fully_inside(layer, quad)).collect() + let is_inside = |layer: &LayerNodeIdentifier| match selection_shape { + SelectionShapeType::Box => document.is_layer_fully_inside(layer, quad), + SelectionShapeType::Lasso => tool_data.is_layer_inside_lasso_polygon(layer, document, input), + }; + intersection.into_iter().filter(is_inside).collect() } else { - intersection.collect() + intersection.into_iter().collect() }; let current_selected: HashSet<_> = document.network_interface.selected_nodes(&[]).unwrap().selected_layers(document.metadata()).collect(); if new_selected != current_selected { // Negative selection when both Shift and Ctrl are pressed - if input.keyboard.key(remove_from_selection) && input.keyboard.key(negative_box_selection) { + if input.keyboard.key(remove_from_selection) { let updated_selection = current_selected .into_iter() .filter(|layer| !new_selected.iter().any(|selected| layer.starts_with(*selected, document.metadata()))) @@ -1114,6 +1175,9 @@ impl Fsm for SelectToolFsmState { .collect(), }); } + + tool_data.lasso_polygon.clear(); + responses.add(OverlaysMessage::Draw); let selection = tool_data.nested_selection_behavior; @@ -1199,7 +1263,9 @@ impl Fsm for SelectToolFsmState { }), HintGroup(vec![ HintInfo::mouse(MouseMotion::LmbDrag, "Select Area"), - HintInfo::keys([Key::Shift], "Extend Selection").prepend_plus(), + HintInfo::keys([Key::Shift], "Extend").prepend_plus(), + HintInfo::keys([Key::Alt], "Subtract").prepend_plus(), + HintInfo::keys([Key::Control], "Lasso").prepend_plus(), ]), HintGroup(vec![HintInfo::multi_keys([[Key::KeyG], [Key::KeyR], [Key::KeyS]], "Grab/Rotate/Scale Selected")]), HintGroup(vec![ @@ -1226,10 +1292,10 @@ impl Fsm for SelectToolFsmState { ]); responses.add(FrontendMessage::UpdateInputHints { hint_data }); } - SelectToolFsmState::DrawingBox => { + SelectToolFsmState::Drawing { .. } => { let hint_data = HintData(vec![ HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()]), - HintGroup(vec![HintInfo::keys([Key::Control, Key::Shift], "Remove from Selection").add_mac_keys([Key::Command, Key::Shift])]), + HintGroup(vec![HintInfo::keys([Key::Shift], "Extend"), HintInfo::keys([Key::Alt], "Subtract")]), // TODO: Re-select deselected layers during drag when Shift is pressed, and re-deselect if Shift is released before drag ends. // TODO: (See https://discord.com/channels/731730685944922173/1216976541947531264/1321360311298818048) // HintGroup(vec![HintInfo::keys([Key::Shift], "Extend Selection")]) @@ -1328,3 +1394,17 @@ fn edit_layer_deepest_manipulation(layer: LayerNodeIdentifier, network_interface responses.add(TextToolMessage::EditSelected); } } + +pub fn extend_lasso(lasso_polygon: &mut Vec, point: DVec2) { + if lasso_polygon.len() < 2 { + lasso_polygon.push(point); + } else { + let last_points = lasso_polygon.last_chunk::<2>().unwrap(); + let distance = last_points[0].distance_squared(last_points[1]); + + if distance < SELECTION_TOLERANCE.powi(2) { + lasso_polygon.pop(); + } + lasso_polygon.push(point); + } +}