Repository: GraphiteEditor/Graphite (language: rust)

Issue:
Experimental option for vector meshes
Mesh vector networks allow >2 bézier segments to share a single anchor point. This is good for outline based icons and is analogous to [Figma's vector networks](https://help.figma.com/hc/en-us/articles/360040450213-Vector-networks). A basic backend implementation is mostly done; however we need to evaluate UX around colinear handles and line joins. This can be more easily done once an experimental implementation is added.

This should just be updating the pen and path tool to extend points regardless to their number of connections. Currently the `VectorData::single_connected_points()` function is used to only allow extension of anchors with one connected bézier curve.


Discussion / hints:

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 8a829eacb9..412bedd2ca 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2397,6 +2397,7 @@ dependencies = [
  "serde_json",
  "specta",
  "spirv-std",
+ "tinyvec",
  "tokio",
  "usvg",
  "vello",
diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs
index f6a922a6ae..22d331e7f2 100644
--- a/editor/src/dispatcher.rs
+++ b/editor/src/dispatcher.rs
@@ -228,6 +228,7 @@ impl Dispatcher {
 							input: &self.message_handlers.input_preprocessor_message_handler,
 							persistent_data: &self.message_handlers.portfolio_message_handler.persistent_data,
 							node_graph: &self.message_handlers.portfolio_message_handler.executor,
+							preferences: &self.message_handlers.preferences_message_handler,
 						};
 
 						self.message_handlers.tool_message_handler.process_message(message, &mut queue, data);
diff --git a/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs
index b084df83f8..b4375a7f01 100644
--- a/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs
+++ b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs
@@ -47,7 +47,7 @@ impl PreferencesDialogMessageHandler {
 			TextLabel::new("Zoom with Scroll").table_align(true).tooltip(zoom_with_scroll_tooltip).widget_holder(),
 		];
 		let vello_tooltip = "Use the experimental Vello renderer (your browser must support WebGPU)";
-		let renderer_section = vec![TextLabel::new("Renderer").italic(true).widget_holder()];
+		let renderer_section = vec![TextLabel::new("Experimental").italic(true).widget_holder()];
 		let use_vello = vec![
 			CheckboxInput::new(preferences.use_vello && preferences.supports_wgpu())
 				.tooltip(vello_tooltip)
@@ -55,7 +55,7 @@ impl PreferencesDialogMessageHandler {
 				.on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::UseVello { use_vello: checkbox_input.checked }.into())
 				.widget_holder(),
 			Separator::new(SeparatorType::Unrelated).widget_holder(),
-			TextLabel::new("Vello (Experimental)")
+			TextLabel::new("Vello Renderer")
 				.table_align(true)
 				.tooltip(vello_tooltip)
 				.disabled(!preferences.supports_wgpu())
@@ -85,11 +85,22 @@ impl PreferencesDialogMessageHandler {
 		// 		.widget_holder(),
 		// ];
 
+		let vector_mesh_tooltip = "Allow tools to produce vector meshes, where more than two segments can connect to an anchor point.\n\nCurrently this does not properly handle line joins and fills.";
+		let vector_meshes = vec![
+			CheckboxInput::new(preferences.vector_meshes)
+				.tooltip(vector_mesh_tooltip)
+				.on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::VectorMeshes { enabled: checkbox_input.checked }.into())
+				.widget_holder(),
+			Separator::new(SeparatorType::Unrelated).widget_holder(),
+			TextLabel::new("Vector Meshes").table_align(true).tooltip(vector_mesh_tooltip).widget_holder(),
+		];
+
 		Layout::WidgetLayout(WidgetLayout::new(vec![
 			LayoutGroup::Row { widgets: input_section },
 			LayoutGroup::Row { widgets: zoom_with_scroll },
 			LayoutGroup::Row { widgets: renderer_section },
 			LayoutGroup::Row { widgets: use_vello },
+			LayoutGroup::Row { widgets: vector_meshes },
 			// LayoutGroup::Row { widgets: imaginate_server_hostname },
 			// LayoutGroup::Row { widgets: imaginate_refresh_frequency },
 		]))
diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs
index b71d3e3874..1556699ed9 100644
--- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs
+++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs
@@ -1,7 +1,7 @@
 use super::utility_types::OverlayContext;
 use crate::consts::HIDE_HANDLE_DISTANCE;
 use crate::messages::tool::common_functionality::shape_editor::{SelectedLayerState, ShapeState};
-use crate::messages::tool::tool_messages::tool_prelude::DocumentMessageHandler;
+use crate::messages::tool::tool_messages::tool_prelude::{DocumentMessageHandler, PreferencesMessageHandler};
 
 use graphene_core::vector::ManipulatorPointId;
 
@@ -62,7 +62,7 @@ pub fn path_overlays(document: &DocumentMessageHandler, shape_editor: &mut Shape
 	}
 }
 
-pub fn path_endpoint_overlays(document: &DocumentMessageHandler, shape_editor: &mut ShapeState, overlay_context: &mut OverlayContext) {
+pub fn path_endpoint_overlays(document: &DocumentMessageHandler, shape_editor: &mut ShapeState, overlay_context: &mut OverlayContext, preferences: &PreferencesMessageHandler) {
 	for layer in document.network_interface.selected_nodes(&[]).unwrap().selected_layers(document.metadata()) {
 		let Some(vector_data) = document.network_interface.compute_modified_vector(layer) else {
 			continue;
@@ -72,7 +72,7 @@ pub fn path_endpoint_overlays(document: &DocumentMessageHandler, shape_editor: &
 		let selected = shape_editor.selected_shape_state.get(&layer);
 		let is_selected = |selected: Option<&SelectedLayerState>, point: ManipulatorPointId| selected.is_some_and(|selected| selected.is_selected(point));
 
-		for point in vector_data.single_connected_points() {
+		for point in vector_data.extendable_points(preferences.vector_meshes) {
 			let Some(position) = vector_data.point_domain.position_from_id(point) else { continue };
 			let position = transform.transform_point2(position);
 			overlay_context.manipulator_anchor(position, is_selected(selected, ManipulatorPointId::Anchor(point)), None);
diff --git a/editor/src/messages/preferences/preferences_message.rs b/editor/src/messages/preferences/preferences_message.rs
index 44f1c68613..692b21e23b 100644
--- a/editor/src/messages/preferences/preferences_message.rs
+++ b/editor/src/messages/preferences/preferences_message.rs
@@ -9,5 +9,6 @@ pub enum PreferencesMessage {
 	ImaginateRefreshFrequency { seconds: f64 },
 	UseVello { use_vello: bool },
 	ImaginateServerHostname { hostname: String },
+	VectorMeshes { enabled: bool },
 	ModifyLayout { zoom_with_scroll: bool },
 }
diff --git a/editor/src/messages/preferences/preferences_message_handler.rs b/editor/src/messages/preferences/preferences_message_handler.rs
index f83234f502..55b4323670 100644
--- a/editor/src/messages/preferences/preferences_message_handler.rs
+++ b/editor/src/messages/preferences/preferences_message_handler.rs
@@ -8,6 +8,7 @@ pub struct PreferencesMessageHandler {
 	pub imaginate_refresh_frequency: f64,
 	pub zoom_with_scroll: bool,
 	pub use_vello: bool,
+	pub vector_meshes: bool,
 }
 
 impl PreferencesMessageHandler {
@@ -34,6 +35,7 @@ impl Default for PreferencesMessageHandler {
 			imaginate_refresh_frequency: 1.,
 			zoom_with_scroll: matches!(MappingVariant::default(), MappingVariant::ZoomWithScroll),
 			use_vello,
+			vector_meshes: false,
 		}
 	}
 }
@@ -88,6 +90,9 @@ impl MessageHandler<PreferencesMessage, ()> for PreferencesMessageHandler {
 				responses.add(PortfolioMessage::ImaginateCheckServerStatus);
 				responses.add(PortfolioMessage::EditorPreferences);
 			}
+			PreferencesMessage::VectorMeshes { enabled } => {
+				self.vector_meshes = enabled;
+			}
 			PreferencesMessage::ModifyLayout { zoom_with_scroll } => {
 				self.zoom_with_scroll = zoom_with_scroll;
 
diff --git a/editor/src/messages/tool/common_functionality/utility_functions.rs b/editor/src/messages/tool/common_functionality/utility_functions.rs
index 74ae9edaea..e845bdc94e 100644
--- a/editor/src/messages/tool/common_functionality/utility_functions.rs
+++ b/editor/src/messages/tool/common_functionality/utility_functions.rs
@@ -6,8 +6,14 @@ use graphene_std::vector::PointId;
 use glam::DVec2;
 
 /// Determines if a path should be extended. Goal in viewport space. Returns the path and if it is extending from the start, if applicable.
-pub fn should_extend(document: &DocumentMessageHandler, goal: DVec2, tolerance: f64, layers: impl Iterator<Item = LayerNodeIdentifier>) -> Option<(LayerNodeIdentifier, PointId, DVec2)> {
-	closest_point(document, goal, tolerance, layers, |_| false)
+pub fn should_extend(
+	document: &DocumentMessageHandler,
+	goal: DVec2,
+	tolerance: f64,
+	layers: impl Iterator<Item = LayerNodeIdentifier>,
+	preferences: &PreferencesMessageHandler,
+) -> Option<(LayerNodeIdentifier, PointId, DVec2)> {
+	closest_point(document, goal, tolerance, layers, |_| false, preferences)
 }
 
 /// Determine the closest point to the goal point under max_distance.
@@ -18,6 +24,7 @@ pub fn closest_point<T>(
 	max_distance: f64,
 	layers: impl Iterator<Item = LayerNodeIdentifier>,
 	exclude: T,
+	preferences: &PreferencesMessageHandler,
 ) -> Option<(LayerNodeIdentifier, PointId, DVec2)>
 where
 	T: Fn(PointId) -> bool,
@@ -29,7 +36,7 @@ where
 		let Some(vector_data) = document.network_interface.compute_modified_vector(layer) else {
 			continue;
 		};
-		for id in vector_data.single_connected_points() {
+		for id in vector_data.extendable_points(preferences.vector_meshes) {
 			if exclude(id) {
 				continue;
 			}
diff --git a/editor/src/messages/tool/tool_message_handler.rs b/editor/src/messages/tool/tool_message_handler.rs
index 239f0acc09..4d4d9d3747 100644
--- a/editor/src/messages/tool/tool_message_handler.rs
+++ b/editor/src/messages/tool/tool_message_handler.rs
@@ -18,6 +18,7 @@ pub struct ToolMessageData<'a> {
 	pub input: &'a InputPreprocessorMessageHandler,
 	pub persistent_data: &'a PersistentData,
 	pub node_graph: &'a NodeGraphExecutor,
+	pub preferences: &'a PreferencesMessageHandler,
 }
 
 #[derive(Debug, Default)]
@@ -36,6 +37,7 @@ impl MessageHandler<ToolMessage, ToolMessageData<'_>> for ToolMessageHandler {
 			input,
 			persistent_data,
 			node_graph,
+			preferences,
 		} = data;
 		let font_cache = &persistent_data.font_cache;
 
@@ -86,6 +88,7 @@ impl MessageHandler<ToolMessage, ToolMessageData<'_>> for ToolMessageHandler {
 							font_cache,
 							shape_editor: &mut self.shape_editor,
 							node_graph,
+							preferences,
 						};
 
 						if let Some(tool_abort_message) = tool.event_to_message_map().tool_abort {
@@ -180,6 +183,7 @@ impl MessageHandler<ToolMessage, ToolMessageData<'_>> for ToolMessageHandler {
 					font_cache,
 					shape_editor: &mut self.shape_editor,
 					node_graph,
+					preferences,
 				};
 
 				// Set initial hints and cursor
@@ -269,6 +273,7 @@ impl MessageHandler<ToolMessage, ToolMessageData<'_>> for ToolMessageHandler {
 							font_cache,
 							shape_editor: &mut self.shape_editor,
 							node_graph,
+							preferences,
 						};
 						if matches!(tool_message, ToolMessage::UpdateHints) {
 							if self.transform_layer_handler.is_transforming() {
diff --git a/editor/src/messages/tool/tool_messages/freehand_tool.rs b/editor/src/messages/tool/tool_messages/freehand_tool.rs
index 9fd3ca09ee..ce7f4dfbfa 100644
--- a/editor/src/messages/tool/tool_messages/freehand_tool.rs
+++ b/editor/src/messages/tool/tool_messages/freehand_tool.rs
@@ -191,13 +191,14 @@ impl Fsm for FreehandToolFsmState {
 			global_tool_data,
 			input,
 			shape_editor,
+			preferences,
 			..
 		} = tool_action_data;
 
 		let ToolMessage::Freehand(event) = event else { return self };
 		match (self, event) {
 			(_, FreehandToolMessage::Overlays(mut overlay_context)) => {
-				path_endpoint_overlays(document, shape_editor, &mut overlay_context);
+				path_endpoint_overlays(document, shape_editor, &mut overlay_context, tool_action_data.preferences);
 
 				self
 			}
@@ -210,7 +211,8 @@ impl Fsm for FreehandToolFsmState {
 
 				// Extend an endpoint of the selected path
 				let selected_nodes = document.network_interface.selected_nodes(&[]).unwrap();
-				if let Some((layer, point, position)) = should_extend(document, input.mouse.position, crate::consts::SNAP_POINT_TOLERANCE, selected_nodes.selected_layers(document.metadata())) {
+				let tolerance = crate::consts::SNAP_POINT_TOLERANCE;
+				if let Some((layer, point, position)) = should_extend(document, input.mouse.position, tolerance, selected_nodes.selected_layers(document.metadata()), preferences) {
 					tool_data.layer = Some(layer);
 					tool_data.end_point = Some((position, point));
 
diff --git a/editor/src/messages/tool/tool_messages/pen_tool.rs b/editor/src/messages/tool/tool_messages/pen_tool.rs
index 66fcbf1f04..df917b62ad 100644
--- a/editor/src/messages/tool/tool_messages/pen_tool.rs
+++ b/editor/src/messages/tool/tool_messages/pen_tool.rs
@@ -275,19 +275,19 @@ impl PenToolData {
 		}
 	}
 
-	fn finish_placing_handle(&mut self, snap_data: SnapData, transform: DAffine2, responses: &mut VecDeque<Message>) -> Option<PenToolFsmState> {
+	fn finish_placing_handle(&mut self, snap_data: SnapData, transform: DAffine2, preferences: &PreferencesMessageHandler, responses: &mut VecDeque<Message>) -> Option<PenToolFsmState> {
 		let document = snap_data.document;
 		let next_handle_start = self.next_handle_start;
 		let handle_start = self.latest_point()?.handle_start;
 		let mouse = snap_data.input.mouse.position;
 		let Some(handle_end) = self.handle_end else {
 			self.handle_end = Some(next_handle_start);
-			self.place_anchor(snap_data, transform, mouse, responses);
+			self.place_anchor(snap_data, transform, mouse, preferences, responses);
 			self.latest_point_mut()?.handle_start = next_handle_start;
 			return None;
 		};
 		let next_point = self.next_point;
-		self.place_anchor(snap_data, transform, mouse, responses);
+		self.place_anchor(snap_data, transform, mouse, preferences, responses);
 		let handles = [handle_start - self.latest_point()?.pos, handle_end - next_point].map(Some);
 
 		// Get close path
@@ -298,7 +298,7 @@ impl PenToolData {
 		let vector_data = document.network_interface.compute_modified_vector(layer)?;
 		let start = self.latest_point()?.id;
 		let transform = document.metadata().document_to_viewport * transform;
-		for id in vector_data.single_connected_points().filter(|&point| point != start) {
+		for id in vector_data.extendable_points(preferences.vector_meshes).filter(|&point| point != start) {
 			let Some(pos) = vector_data.point_domain.position_from_id(id) else { continue };
 			let transformed_distance_between_squared = transform.transform_point2(pos).distance_squared(transform.transform_point2(next_point));
 			let snap_point_tolerance_squared = crate::consts::SNAP_POINT_TOLERANCE.powi(2);
@@ -359,7 +359,7 @@ impl PenToolData {
 		Some(PenToolFsmState::DraggingHandle)
 	}
 
-	fn place_anchor(&mut self, snap_data: SnapData, transform: DAffine2, mouse: DVec2, responses: &mut VecDeque<Message>) -> Option<PenToolFsmState> {
+	fn place_anchor(&mut self, snap_data: SnapData, transform: DAffine2, mouse: DVec2, preferences: &PreferencesMessageHandler, responses: &mut VecDeque<Message>) -> Option<PenToolFsmState> {
 		let document = snap_data.document;
 
 		let relative = self.latest_point().map(|point| point.pos);
@@ -370,7 +370,7 @@ impl PenToolData {
 		let layer = selected_layers.next().filter(|_| selected_layers.next().is_none())?;
 		let vector_data = document.network_interface.compute_modified_vector(layer)?;
 		let transform = document.metadata().document_to_viewport * transform;
-		for point in vector_data.single_connected_points() {
+		for point in vector_data.extendable_points(preferences.vector_meshes) {
 			let Some(pos) = vector_data.point_domain.position_from_id(point) else { continue };
 			let transformed_distance_between_squared = transform.transform_point2(pos).distance_squared(transform.transform_point2(self.next_point));
 			let snap_point_tolerance_squared = crate::consts::SNAP_POINT_TOLERANCE.powi(2);
@@ -456,7 +456,15 @@ impl PenToolData {
 		transform.inverse().transform_point2(document_pos)
 	}
 
-	fn create_initial_point(&mut self, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque<Message>, tool_options: &PenOptions, append: bool) {
+	fn create_initial_point(
+		&mut self,
+		document: &DocumentMessageHandler,
+		input: &InputPreprocessorMessageHandler,
+		responses: &mut VecDeque<Message>,
+		tool_options: &PenOptions,
+		append: bool,
+		preferences: &PreferencesMessageHandler,
+	) {
 		let point = SnapCandidatePoint::handle(document.metadata().document_to_viewport.inverse().transform_point2(input.mouse.position));
 		let snapped = self.snap_manager.free_snap(&SnapData::new(document, input), &point, SnapTypeConfiguration::default());
 		let viewport = document.metadata().document_to_viewport.transform_point2(snapped.snapped_point_document);
@@ -464,7 +472,8 @@ impl PenToolData {
 		let selected_nodes = document.network_interface.selected_nodes(&[]).unwrap();
 		self.handle_end = None;
 
-		if let Some((layer, point, position)) = should_extend(document, viewport, crate::consts::SNAP_POINT_TOLERANCE, selected_nodes.selected_layers(document.metadata())) {
+		let tolerance = crate::consts::SNAP_POINT_TOLERANCE;
+		if let Some((layer, point, position)) = should_extend(document, viewport, tolerance, selected_nodes.selected_layers(document.metadata()), preferences) {
 			// Perform extension of an existing path
 			self.add_point(LastPoint {
 				id: point,
@@ -533,6 +542,7 @@ impl Fsm for PenToolFsmState {
 			global_tool_data,
 			input,
 			shape_editor,
+			preferences,
 			..
 		} = tool_action_data;
 
@@ -638,7 +648,7 @@ impl Fsm for PenToolFsmState {
 			(PenToolFsmState::Ready, PenToolMessage::DragStart { append_to_selected }) => {
 				responses.add(DocumentMessage::StartTransaction);
 
-				tool_data.create_initial_point(document, input, responses, tool_options, input.keyboard.key(append_to_selected));
+				tool_data.create_initial_point(document, input, responses, tool_options, input.keyboard.key(append_to_selected), preferences);
 
 				// Enter the dragging handle state while the mouse is held down, allowing the user to move the mouse and position the handle
 				PenToolFsmState::DraggingHandle
@@ -660,7 +670,7 @@ impl Fsm for PenToolFsmState {
 				if tool_data.buffering_merged_vector {
 					tool_data.buffering_merged_vector = false;
 					tool_data.bend_from_previous_point(SnapData::new(document, input), transform);
-					tool_data.place_anchor(SnapData::new(document, input), transform, input.mouse.position, responses);
+					tool_data.place_anchor(SnapData::new(document, input), transform, input.mouse.position, preferences, responses);
 					tool_data.buffering_merged_vector = false;
 					PenToolFsmState::DraggingHandle
 				} else {
@@ -673,7 +683,7 @@ impl Fsm for PenToolFsmState {
 					let layers = LayerNodeIdentifier::ROOT_PARENT
 						.descendants(document.metadata())
 						.filter(|layer| !document.network_interface.is_artboard(&layer.to_node(), &[]));
-					if let Some((other_layer, _, _)) = should_extend(document, viewport, crate::consts::SNAP_POINT_TOLERANCE, layers) {
+					if let Some((other_layer, _, _)) = should_extend(document, viewport, crate::consts::SNAP_POINT_TOLERANCE, layers, preferences) {
 						let selected_nodes = document.network_interface.selected_nodes(&[]).unwrap();
 						let mut selected_layers = selected_nodes.selected_layers(document.metadata());
 						if let Some(current_layer) = selected_layers.next().filter(|current_layer| selected_layers.next().is_none() && *current_layer != other_layer) {
@@ -696,7 +706,7 @@ impl Fsm for PenToolFsmState {
 				self
 			}
 			(PenToolFsmState::DraggingHandle, PenToolMessage::DragStop) => tool_data
-				.finish_placing_handle(SnapData::new(document, input), transform, responses)
+				.finish_placing_handle(SnapData::new(document, input), transform, preferences, responses)
 				.unwrap_or(PenToolFsmState::PlacingAnchor),
 			(PenToolFsmState::DraggingHandle, PenToolMessage::PointerMove { snap_angle, break_handle, lock_angle }) => {
 				tool_data.modifiers = ModifierState {
@@ -724,7 +734,7 @@ impl Fsm for PenToolFsmState {
 					break_handle: input.keyboard.key(break_handle),
 				};
 				let state = tool_data
-					.place_anchor(SnapData::new(document, input), transform, input.mouse.position, responses)
+					.place_anchor(SnapData::new(document, input), transform, input.mouse.position, preferences, responses)
 					.unwrap_or(PenToolFsmState::Ready);
 
 				// Auto-panning
@@ -783,7 +793,7 @@ impl Fsm for PenToolFsmState {
 				if tool_data.point_index > 0 {
 					tool_data.point_index -= 1;
 					tool_data
-						.place_anchor(SnapData::new(document, input), transform, input.mouse.position, responses)
+						.place_anchor(SnapData::new(document, input), transform, input.mouse.position, preferences, responses)
 						.unwrap_or(PenToolFsmState::PlacingAnchor)
 				} else {
 					responses.add(PenToolMessage::Abort);
@@ -792,7 +802,7 @@ impl Fsm for PenToolFsmState {
 			}
 			(_, PenToolMessage::Redo) => {
 				tool_data.point_index = (tool_data.point_index + 1).min(tool_data.latest_points.len().saturating_sub(1));
-				tool_data.place_anchor(SnapData::new(document, input), transform, input.mouse.position, responses);
+				tool_data.place_anchor(SnapData::new(document, input), transform, input.mouse.position, preferences, responses);
 				match tool_data.point_index {
 					0 => PenToolFsmState::Ready,
 					_ => PenToolFsmState::PlacingAnchor,
diff --git a/editor/src/messages/tool/tool_messages/spline_tool.rs b/editor/src/messages/tool/tool_messages/spline_tool.rs
index 0962825db3..7a9708b841 100644
--- a/editor/src/messages/tool/tool_messages/spline_tool.rs
+++ b/editor/src/messages/tool/tool_messages/spline_tool.rs
@@ -227,6 +227,7 @@ impl Fsm for SplineToolFsmState {
 			global_tool_data,
 			input,
 			shape_editor,
+			preferences,
 			..
 		} = tool_action_data;
 
@@ -234,7 +235,7 @@ impl Fsm for SplineToolFsmState {
 		match (self, event) {
 			(_, SplineToolMessage::CanvasTransformed) => self,
 			(_, SplineToolMessage::Overlays(mut overlay_context)) => {
-				path_endpoint_overlays(document, shape_editor, &mut overlay_context);
+				path_endpoint_overlays(document, shape_editor, &mut overlay_context, preferences);
 				tool_data.snap_manager.draw_overlays(SnapData::new(document, input), &mut overlay_context);
 				self
 			}
@@ -250,7 +251,7 @@ impl Fsm for SplineToolFsmState {
 
 				// Extend an endpoint of the selected path
 				let selected_nodes = document.network_interface.selected_nodes(&[]).unwrap();
-				if let Some((layer, point, position)) = should_extend(document, viewport, SNAP_POINT_TOLERANCE, selected_nodes.selected_layers(document.metadata())) {
+				if let Some((layer, point, position)) = should_extend(document, viewport, SNAP_POINT_TOLERANCE, selected_nodes.selected_layers(document.metadata()), preferences) {
 					tool_data.layer = Some(layer);
 					tool_data.points.push((point, position));
 					tool_data.next_point = position;
@@ -304,7 +305,7 @@ impl Fsm for SplineToolFsmState {
 				if tool_data.layer.is_none() {
 					return SplineToolFsmState::Ready;
 				};
-				if join_path(document, input.mouse.position, tool_data, responses) {
+				if join_path(document, input.mouse.position, tool_data, preferences, responses) {
 					responses.add(DocumentMessage::EndTransaction);
 					return SplineToolFsmState::Ready;
 				}
@@ -318,7 +319,7 @@ impl Fsm for SplineToolFsmState {
 			(SplineToolFsmState::Drawing, SplineToolMessage::PointerMove) => {
 				let Some(layer) = tool_data.layer else { return SplineToolFsmState::Ready };
 				let ignore = |cp: PointId| tool_data.preview_point.is_some_and(|pp| pp == cp) || tool_data.points.last().is_some_and(|(ep, _)| *ep == cp);
-				let join_point = closest_point(document, input.mouse.position, PATH_JOIN_THRESHOLD, vec![layer].into_iter(), ignore);
+				let join_point = closest_point(document, input.mouse.position, PATH_JOIN_THRESHOLD, vec![layer].into_iter(), ignore, preferences);
 
 				// Endpoints snapping
 				if let Some((_, _, point)) = join_point {
@@ -402,7 +403,7 @@ impl Fsm for SplineToolFsmState {
 }
 
 /// Return `true` only if new segment is inserted to connect two end points in the selected layer otherwise `false`.
-fn join_path(document: &DocumentMessageHandler, mouse_pos: DVec2, tool_data: &mut SplineToolData, responses: &mut VecDeque<Message>) -> bool {
+fn join_path(document: &DocumentMessageHandler, mouse_pos: DVec2, tool_data: &mut SplineToolData, preferences: &PreferencesMessageHandler, responses: &mut VecDeque<Message>) -> bool {
 	let Some(&(endpoint, _)) = tool_data.points.last() else { return false };
 
 	let preview_point = tool_data.preview_point;
@@ -410,9 +411,14 @@ fn join_path(document: &DocumentMessageHandler, mouse_pos: DVec2, tool_data: &mu
 	let selected_layers = selected_nodes.selected_layers(document.metadata());
 
 	// Get the closest point to mouse position which is not preview_point or end_point.
-	let closest_point = closest_point(document, mouse_pos, PATH_JOIN_THRESHOLD, selected_layers, |cp| {
-		preview_point.is_some_and(|pp| pp == cp) || cp == endpoint
-	});
+	let closest_point = closest_point(
+		document,
+		mouse_pos,
+		PATH_JOIN_THRESHOLD,
+		selected_layers,
+		|cp| preview_point.is_some_and(|pp| pp == cp) || cp == endpoint,
+		preferences,
+	);
 	let Some((layer, join_point, _)) = closest_point else { return false };
 
 	// Last end point inserted was the preview point and segment therefore we delete it before joining the end_point & join_point.
diff --git a/editor/src/messages/tool/utility_types.rs b/editor/src/messages/tool/utility_types.rs
index a547a3af61..c5f685d495 100644
--- a/editor/src/messages/tool/utility_types.rs
+++ b/editor/src/messages/tool/utility_types.rs
@@ -26,27 +26,7 @@ pub struct ToolActionHandlerData<'a> {
 	pub font_cache: &'a FontCache,
 	pub shape_editor: &'a mut ShapeState,
 	pub node_graph: &'a NodeGraphExecutor,
-}
-impl<'a> ToolActionHandlerData<'a> {
-	pub fn new(
-		document: &'a mut DocumentMessageHandler,
-		document_id: DocumentId,
-		global_tool_data: &'a DocumentToolData,
-		input: &'a InputPreprocessorMessageHandler,
-		font_cache: &'a FontCache,
-		shape_editor: &'a mut ShapeState,
-		node_graph: &'a NodeGraphExecutor,
-	) -> Self {
-		Self {
-			document,
-			document_id,
-			global_tool_data,
-			input,
-			font_cache,
-			shape_editor,
-			node_graph,
-		}
-	}
+	pub preferences: &'a PreferencesMessageHandler,
 }
 
 pub trait ToolCommon: for<'a, 'b> MessageHandler<ToolMessage, &'b mut ToolActionHandlerData<'a>> + LayoutHolder + ToolTransition + ToolMetadata {}
diff --git a/libraries/bezier-rs/src/subpath/solvers.rs b/libraries/bezier-rs/src/subpath/solvers.rs
index 72a66bc065..2393aa55d4 100644
--- a/libraries/bezier-rs/src/subpath/solvers.rs
+++ b/libraries/bezier-rs/src/subpath/solvers.rs
@@ -428,7 +428,7 @@ impl<PointId: crate::Identifier> Subpath<PointId> {
 			_ => 4.,
 		};
 		// TODO: Besides returning None using the `?` operator, is there a more appropriate way to handle a `None` result from `get_segment`?
-		let in_segment = self.get_segment(self.len_segments() - 1)?;
+		let in_segment = self.get_segment(self.len_segments().checked_sub(1)?)?;
 		let out_segment = other.get_segment(0)?;
 
 		let in_tangent = in_segment.tangent(TValue::Parametric(1.));
@@ -483,13 +483,13 @@ impl<PointId: crate::Identifier> Subpath<PointId> {
 		let center_to_right = right - center;
 		let center_to_left = left - center;
 
-		let in_segment = self.get_segment(self.len_segments() - 1).unwrap();
-		let in_tangent = in_segment.tangent(TValue::Parametric(1.));
+		let in_segment = self.len_segments().checked_sub(1).and_then(|segment| self.get_segment(segment));
+		let in_tangent = in_segment.map(|in_segment| in_segment.tangent(TValue::Parametric(1.)));
 
 		let mut angle = center_to_right.angle_to(center_to_left) / 2.;
 		let mut arc_point = center + DMat2::from_angle(angle).mul_vec2(center_to_right);
 
-		if (arc_point - left).angle_to(in_tangent).abs() > PI / 2. {
+		if in_tangent.map(|in_tangent| (arc_point - left).angle_to(in_tangent).abs()).unwrap_or_default() > PI / 2. {
 			angle = angle - PI * (if angle < 0. { -1. } else { 1. });
 			arc_point = center + DMat2::from_angle(angle).mul_vec2(center_to_right);
 		}
diff --git a/node-graph/gcore/Cargo.toml b/node-graph/gcore/Cargo.toml
index ac4465bdf1..80fc2462e0 100644
--- a/node-graph/gcore/Cargo.toml
+++ b/node-graph/gcore/Cargo.toml
@@ -55,6 +55,7 @@ glam = { workspace = true, default-features = false, features = [
 
 # Required dependencies
 half = { version = "2.4.1", default-features = false, features = ["bytemuck"] }
+tinyvec = { version = "1" }
 
 # Optional workspace dependencies
 dyn-any = { workspace = true, optional = true }
diff --git a/node-graph/gcore/src/vector/vector_data.rs b/node-graph/gcore/src/vector/vector_data.rs
index 93cadbbb52..ef19ac3853 100644
--- a/node-graph/gcore/src/vector/vector_data.rs
+++ b/node-graph/gcore/src/vector/vector_data.rs
@@ -243,14 +243,12 @@ impl VectorData {
 		self.point_domain.resolve_id(point).map_or(0, |point| self.segment_domain.connected_count(point))
 	}
 
-	/// Points connected to a single segment
-	pub fn single_connected_points(&self) -> impl Iterator<Item = PointId> + '_ {
-		self.point_domain
-			.ids()
-			.iter()
-			.enumerate()
-			.filter(|(index, _)| self.segment_domain.connected_count(*index) == 1)
-			.map(|(_, &id)| id)
+	/// Points that can be extended from.
+	///
+	/// This is usually only points with exactly one connection unless vector meshes are enabled.
+	pub fn extendable_points(&self, vector_meshes: bool) -> impl Iterator<Item = PointId> + '_ {
+		let point_ids = self.point_domain.ids().iter().enumerate();
+		point_ids.filter(move |(index, _)| vector_meshes || self.segment_domain.connected_count(*index) == 1).map(|(_, &id)| id)
 	}
 
 	/// Computes if all the connected handles are colinear for an anchor, or if that handle is colinear for a handle.
diff --git a/node-graph/gcore/src/vector/vector_data/attributes.rs b/node-graph/gcore/src/vector/vector_data/attributes.rs
index e41c998897..4b0ebc744e 100644
--- a/node-graph/gcore/src/vector/vector_data/attributes.rs
+++ b/node-graph/gcore/src/vector/vector_data/attributes.rs
@@ -654,7 +654,7 @@ impl super::VectorData {
 	}
 }
 
-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
 struct StrokePathIterPointSegmentMetadata {
 	segment_index: usize,
 	start_from_end: bool,
@@ -675,28 +675,24 @@ impl StrokePathIterPointSegmentMetadata {
 }
 
 #[derive(Clone, Default)]
-struct StrokePathIterPointMetadata([Option<StrokePathIterPointSegmentMetadata>; 2]);
+struct StrokePathIterPointMetadata(tinyvec::TinyVec<[StrokePathIterPointSegmentMetadata; 2]>);
 
 impl StrokePathIterPointMetadata {
 	fn set(&mut self, value: StrokePathIterPointSegmentMetadata) {
-		if self.0[0].is_none() {
-			self.0[0] = Some(value)
-		} else if self.0[1].is_none() {
-			self.0[1] = Some(value);
-		} else {
-			panic!("Mesh networks are not supported");
-		}
+		self.0.insert(0, value);
 	}
 	#[must_use]
 	fn connected(&self) -> usize {
-		self.0.iter().filter(|val| val.is_some()).count()
+		self.0.len()
 	}
 	#[must_use]
 	fn take_first(&mut self) -> Option<StrokePathIterPointSegmentMetadata> {
-		self.0[0].take().or_else(|| self.0[1].take())
+		self.0.pop()
 	}
 	fn take_eq(&mut self, target: StrokePathIterPointSegmentMetadata) -> bool {
-		self.0[0].take_if(|&mut value| value == target).or_else(|| self.0[1].take_if(|&mut value| value == target)).is_some()
+		let has_taken = self.0.contains(&target);
+		self.0.retain(|value| *value != target);
+		has_taken
 	}
 }
 


Test patch: