Repository: GraphiteEditor/Graphite (language: rust)

Issue:
Lasso selection with the Select tool
The Select tool currently lets you click-and-drag to box select a region of shapes within the rectangle. If <kbd>Shift</kbd> is held down before dragging, this lets the box selection *add* to the selection instead of *replacing* the existing selection.

Sometimes, users want more precise control over the region being selected than a rectangle can accommodate. For that, we'd like to have lasso selection. It should be triggered if <kbd>Ctrl</kbd> is held down before dragging. Unlike box selection described above where <kbd>Shift</kbd> has to be held down to *add* instead of *replacing* the selection, lasso selection will always *add* to the selection. Another difference from box selection is that the lasso must fully encompass the layer, rather than just barely touching it (as with box selection), to be included in the selection. Here's how it should work (a video mockup made using Blender):

https://github.com/user-attachments/assets/a5596190-1711-4e18-857e-00da14195419

The overlay system for drawing the blue lines, which is used for drawing the box selection rectangle, will also need to be used to draw the polyline of each mouse coordinate visited during the lasso selection drag. I don't think we currently have an existing function in the overlays system for drawing a polyline but it should be easy to adapt an existing function for that purpose.


Discussion / hints:
Implemented the method to check if subpath inside other subpath in #2183 

Fix patch:
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<PointId>, ipp: &InputPreprocessorMessageHandler) -> impl Iterator<Item = LayerNodeIdentifier> + '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<PointId>, ipp: &InputPreprocessorMessageHandler) -> impl Iterator<Item = LayerNodeIdentifier> + '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<PointId>) -> 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<Item = LayerNodeIdentifier> + '_ {
 		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<path_bool_lib::PathSegment>),
+	Polygon(Subpath<PointId>),
 }
 
 /// 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<f64>, dash_gap_width: Option<f64>, dash_offset: Option<f64>) {
+		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<f64>, dash_gap_width: Option<f64>, dash_offset: Option<f64>) {
+		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<DVec2>),
+}
+
+#[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<PointId> = 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<ToolMessage, &mut ToolActionHandlerData<'a>> 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<DVec2>,
+	selection_mode: Option<SelectionMode>,
 	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<Message>, 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<Message>,
 		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());
+
+						let mut selection_direction = tool_action_data.preferences.get_selection_mode();
+						if selection_direction == SelectionMode::Directional {
+							selection_direction = tool_data.calculate_direction();
+						}
 
-						overlay_context.quad(Quad::from_box([tool_data.drag_start_pos, tool_data.previous_mouse_position]), Some(&("#".to_string() + &fill_color)));
+						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<ViewportPosition>,
+	selection_mode: Option<SelectionMode>,
 	layers_dragging: Vec<LayerNodeIdentifier>,
 	layer_selected_on_start: Option<LayerNodeIdentifier>,
 	select_single_layer: Option<LayerNodeIdentifier>,
@@ -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<LayerNodeIdentifier> {
+		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<Message>) {
 		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<LayerNodeIdentifier> = 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<LayerNodeIdentifier> = 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<DVec2>, 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);
+	}
+}


Test patch: