From dd3177fbd0f0f55d161beb5e37fda648ef08367f Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 6 Sep 2026 18:06:29 -0700 Subject: [PATCH 1/5] Let the Freehand tool continue a selected open path from its endpoint and show endpoint overlays in the Freehand and Spline tools --- .../document/overlays/utility_functions.rs | 20 ++ .../common_functionality/utility_functions.rs | 24 ++ .../tool/tool_messages/freehand_tool.rs | 220 +++++++++++++++++- .../tool/tool_messages/spline_tool.rs | 10 +- 4 files changed, 271 insertions(+), 3 deletions(-) diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs index 44bbdef00f..becf5b5e75 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs @@ -201,6 +201,26 @@ pub fn path_overlays(document: &DocumentMessageHandler, draw_handles: DrawHandle } } +/// Draws an anchor overlay at each endpoint of every open path on the selected layers, in the selected style for endpoints that are part of the path editing selection. +pub fn path_endpoint_overlays(document: &DocumentMessageHandler, shape_editor: &ShapeState, overlay_context: &mut OverlayContext) { + if !overlay_context.visibility_settings.anchors() { + return; + } + + for layer in document.network_interface.selected_nodes().selected_layers(document.metadata()) { + let Some(vector) = document.network_interface.compute_modified_vector(layer) else { continue }; + let transform = document.metadata().transform_to_viewport(layer); + let selected_layer_state = shape_editor.selected_shape_state.get(&layer); + + for id in vector.anchor_endpoints() { + let Some(position) = vector.point_domain.position_from_id(id) else { continue }; + + let selected = selected_layer_state.is_some_and(|state| state.is_point_selected(ManipulatorPointId::Anchor(id))); + overlay_context.manipulator_anchor(transform.transform_point2(position), selected, None); + } + } +} + pub fn hex_to_rgba_u8(hex: &str) -> [u8; 4] { let hex = hex.trim().trim_start_matches('#'); if hex.len() != 6 && hex.len() != 8 { diff --git a/editor/src/messages/tool/common_functionality/utility_functions.rs b/editor/src/messages/tool/common_functionality/utility_functions.rs index a4ea05db1d..89fe0d0285 100644 --- a/editor/src/messages/tool/common_functionality/utility_functions.rs +++ b/editor/src/messages/tool/common_functionality/utility_functions.rs @@ -58,6 +58,30 @@ where best } +/// Finds the endpoint of an open path closest to the goal (in viewport space) across the given layers, if one lies within the tolerance. +/// Only anchors with a single connected segment qualify, so closed paths are never matched. Returns the endpoint's position in the layer's local space. +pub fn closest_open_path_endpoint(document: &DocumentMessageHandler, goal: DVec2, tolerance: f64, layers: impl Iterator) -> Option<(LayerNodeIdentifier, PointId, DVec2)> { + let mut best = None; + let mut best_distance_squared = tolerance * tolerance; + + for layer in layers { + let to_viewport = document.metadata().transform_to_viewport(layer); + let Some(vector) = document.network_interface.compute_modified_vector(layer) else { continue }; + + for id in vector.anchor_endpoints() { + let Some(position) = vector.point_domain.position_from_id(id) else { continue }; + + let distance_squared = to_viewport.transform_point2(position).distance_squared(goal); + if distance_squared < best_distance_squared { + best = Some((layer, id, position)); + best_distance_squared = distance_squared; + } + } + } + + best +} + /// Calculates the bounding box of the layer's text, based on the settings for max width and height specified in the typesetting config. pub fn text_bounding_box(layer: LayerNodeIdentifier, document: &DocumentMessageHandler, fonts: &FontsMessageHandler, responses: &mut VecDeque) -> Quad { // Use the `editor:text_frame` attribute if available (handles multi-item glyphs and the 'Index Elements' node) diff --git a/editor/src/messages/tool/tool_messages/freehand_tool.rs b/editor/src/messages/tool/tool_messages/freehand_tool.rs index eba9c4c209..c1dcc0664a 100644 --- a/editor/src/messages/tool/tool_messages/freehand_tool.rs +++ b/editor/src/messages/tool/tool_messages/freehand_tool.rs @@ -1,5 +1,8 @@ use super::tool_prelude::*; +use crate::consts::SNAP_POINT_TOLERANCE; use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_network_node_type; +use crate::messages::portfolio::document::overlays::utility_functions::path_endpoint_overlays; +use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::tool::common_functionality::color_selector::{ DrawingToolState, apply_fill_color_pick, apply_fill_enabled, apply_stroke_color_pick, apply_stroke_enabled, apply_working_colors, reset_colors_on_deactivation, swap_fill_and_stroke, @@ -8,6 +11,7 @@ use crate::messages::tool::common_functionality::color_selector::{ use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::common_functionality::resize::translation_transform_set; use crate::messages::tool::common_functionality::stroke_options::{StrokeOptionsUpdate, apply_stroke_option, create_stroke_options_popover_widget}; +use crate::messages::tool::common_functionality::utility_functions::closest_open_path_endpoint; use glam::DVec2; use graph_craft::document::NodeId; use graphene_std::Color; @@ -39,6 +43,7 @@ impl Default for FreehandOptions { #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum FreehandToolMessage { // Standard messages + Overlays { context: OverlayContext }, Abort, SelectionChanged, WorkingColorChanged, @@ -209,6 +214,7 @@ impl<'a> MessageHandler> for Free impl ToolTransition for FreehandTool { fn event_to_message_map(&self) -> EventToMessageMap { EventToMessageMap { + overlay_provider: Some(|context: OverlayContext| FreehandToolMessage::Overlays { context }.into()), tool_abort: Some(FreehandToolMessage::Abort.into()), selection_changed: Some(FreehandToolMessage::SelectionChanged.into()), graph_changed: Some(FreehandToolMessage::SelectionChanged.into()), @@ -240,10 +246,20 @@ impl Fsm for FreehandToolFsmState { tool_options: &Self::ToolOptions, responses: &mut VecDeque, ) -> Self { - let ToolActionMessageContext { document, input, viewport, .. } = tool_action_data; + let ToolActionMessageContext { + document, + input, + viewport, + shape_editor, + .. + } = tool_action_data; let ToolMessage::Freehand(event) = event else { return self }; match (self, event) { + (_, FreehandToolMessage::Overlays { context: mut overlay_context }) => { + path_endpoint_overlays(document, shape_editor, &mut overlay_context); + self + } (FreehandToolFsmState::Ready, FreehandToolMessage::DragStart { append_to_selected }) => { responses.add(DocumentMessage::StartTransaction); @@ -251,6 +267,16 @@ impl Fsm for FreehandToolFsmState { tool_data.end_point = None; tool_data.new_layer_viewport_start = None; + // Pressing on the endpoint of a selected open path continues that path in place, keeping the layer, its selection, and its style + let selected_nodes = document.network_interface.selected_nodes(); + let selected_layers = selected_nodes.selected_layers(document.metadata()); + if let Some((layer, endpoint, position)) = closest_open_path_endpoint(document, input.mouse.position, SNAP_POINT_TOLERANCE, selected_layers) { + tool_data.layer = Some(layer); + tool_data.end_point = Some((position, endpoint)); + + return FreehandToolFsmState::Drawing; + } + if input.keyboard.key(append_to_selected) { let selected_nodes = document.network_interface.selected_nodes(); let mut selected_layers_except_artboards = selected_nodes.selected_layers_except_artboards(&document.network_interface); @@ -401,7 +427,7 @@ mod test_freehand { use crate::messages::tool::common_functionality::stroke_options::StrokeOptionsUpdate; use crate::messages::tool::tool_messages::freehand_tool::FreehandOptionsUpdate; use crate::test_utils::test_prelude::*; - use glam::{DAffine2, DVec2}; + use glam::{DAffine2, DMat2, DVec2}; use graphene_std::vector::Vector; async fn get_vector_and_transform_list(editor: &mut EditorTestUtils) -> Vec<(Vector, DAffine2)> { @@ -651,4 +677,194 @@ mod test_freehand { stroke_width.unwrap() ); } + + /// Draws a Freehand stroke through the given viewport positions and returns the layer it created, which the tool leaves selected. + async fn draw_freehand_stroke(editor: &mut EditorTestUtils, points: &[DVec2]) -> LayerNodeIdentifier { + editor.select_tool(ToolType::Freehand).await; + editor.drag_path(points, ModifierKeys::empty()).await; + editor.get_selected_layer().await.expect("The Freehand stroke should create and select a layer") + } + + fn layer_count(editor: &EditorTestUtils) -> usize { + editor.active_document().metadata().all_layers().count() + } + + fn point_and_segment_counts(editor: &EditorTestUtils, layer: LayerNodeIdentifier) -> (usize, usize) { + let vector = editor.active_document().network_interface.compute_modified_vector(layer).expect("Layer should have vector geometry"); + (vector.point_domain.ids().len(), vector.segment_domain.ids().len()) + } + + /// The viewport positions of the layer's anchors, split into the endpoints of its open paths and every other anchor. + fn endpoint_and_other_anchor_viewport_positions(editor: &EditorTestUtils, layer: LayerNodeIdentifier) -> (Vec, Vec) { + let document = editor.active_document(); + let vector = document.network_interface.compute_modified_vector(layer).expect("Layer should have vector geometry"); + let transform = document.metadata().transform_to_viewport(layer); + + let endpoints: Vec<_> = vector.anchor_endpoints().collect(); + let viewport_position = |id| vector.point_domain.position_from_id(id).map(|position| transform.transform_point2(position)); + + let endpoint_positions = endpoints.iter().filter_map(|&id| viewport_position(id)).collect(); + let other_positions = vector.anchor_points().filter(|id| !endpoints.contains(id)).filter_map(viewport_position).collect(); + (endpoint_positions, other_positions) + } + + const INITIAL_STROKE: [DVec2; 3] = [DVec2::new(100., 100.), DVec2::new(200., 150.), DVec2::new(300., 100.)]; + + #[tokio::test] + async fn test_extend_open_path_from_endpoint() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + let layer = draw_freehand_stroke(&mut editor, &INITIAL_STROKE).await; + let (initial_point_count, initial_segment_count) = point_and_segment_counts(&editor, layer); + assert_eq!(initial_segment_count, initial_point_count - 1, "The initial stroke should be a single open path"); + + // Press just inside the snapping tolerance of an endpoint, then keep drawing + let (endpoints, _) = endpoint_and_other_anchor_viewport_positions(&editor, layer); + let endpoint = *endpoints.last().expect("The stroke should have endpoints"); + let continuation = [endpoint + DVec2::new(2., -2.), DVec2::new(400., 150.), DVec2::new(500., 100.)]; + editor.drag_path(&continuation, ModifierKeys::empty()).await; + + assert_eq!(layer_count(&editor), 1, "Continuing from an endpoint should not create a new layer"); + assert_eq!(editor.get_selected_layer().await, Some(layer), "The selection should be left as it was"); + + let (point_count, segment_count) = point_and_segment_counts(&editor, layer); + assert_eq!(point_count, initial_point_count + continuation.len() - 1, "Each pointer move after the press should add a point"); + assert_eq!(segment_count, point_count - 1, "The continued stroke should form a single open path"); + } + + #[tokio::test] + async fn test_stroke_away_from_endpoint_creates_new_layer() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + let layer = draw_freehand_stroke(&mut editor, &INITIAL_STROKE).await; + let initial_counts = point_and_segment_counts(&editor, layer); + + editor.drag_path(&[DVec2::new(100., 300.), DVec2::new(200., 350.), DVec2::new(300., 300.)], ModifierKeys::empty()).await; + + assert_eq!(layer_count(&editor), 2, "A stroke starting away from any endpoint should create a new layer"); + assert_eq!(point_and_segment_counts(&editor, layer), initial_counts, "The existing path should be untouched"); + } + + #[tokio::test] + async fn test_middle_anchor_does_not_extend() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + // Four pointer positions capture three points, so the path has an anchor that is not an endpoint + let layer = draw_freehand_stroke(&mut editor, &[DVec2::new(100., 100.), DVec2::new(200., 150.), DVec2::new(300., 100.), DVec2::new(400., 150.)]).await; + let initial_counts = point_and_segment_counts(&editor, layer); + + let (_, other_anchors) = endpoint_and_other_anchor_viewport_positions(&editor, layer); + let middle_anchor = *other_anchors.first().expect("The stroke should have an anchor between its endpoints"); + editor + .drag_path(&[middle_anchor, middle_anchor + DVec2::new(0., 100.), middle_anchor + DVec2::new(0., 200.)], ModifierKeys::empty()) + .await; + + assert_eq!(layer_count(&editor), 2, "Only the endpoints of a path should be continued from"); + assert_eq!(point_and_segment_counts(&editor, layer), initial_counts, "The existing path should be untouched"); + } + + #[tokio::test] + async fn test_closed_path_never_extends() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + editor.draw_rect(100., 100., 300., 300.).await; + let rectangle = editor.get_selected_layer().await.expect("The rectangle should be selected"); + let (endpoints, corners) = endpoint_and_other_anchor_viewport_positions(&editor, rectangle); + assert!(endpoints.is_empty(), "A closed path has no endpoints"); + let corner = *corners.first().expect("The rectangle should have corner anchors"); + + editor.select_tool(ToolType::Freehand).await; + editor.drag_path(&[corner, corner + DVec2::new(100., 50.), corner + DVec2::new(200., 0.)], ModifierKeys::empty()).await; + + assert_eq!(layer_count(&editor), 2, "Pressing on a closed path's anchor should start a new layer"); + } + + #[tokio::test] + async fn test_extend_after_layer_transform() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + let layer = draw_freehand_stroke(&mut editor, &INITIAL_STROKE).await; + let (initial_point_count, _) = point_and_segment_counts(&editor, layer); + + editor + .handle_message(GraphOperationMessage::TransformSet { + layer, + transform: DAffine2::from_scale_angle_translation(DVec2::new(1.5, 0.8), 0.3, DVec2::new(40., -25.)), + transform_in: TransformIn::Local, + skip_rerender: false, + }) + .await; + + let transform = editor.active_document().metadata().transform_to_viewport(layer); + assert!(!transform.matrix2.abs_diff_eq(DMat2::IDENTITY, 1e-6), "The layer should be rotated and scaled"); + + // Press within tolerance of where an endpoint now sits in the viewport, then keep drawing + let (endpoints, _) = endpoint_and_other_anchor_viewport_positions(&editor, layer); + let endpoint = *endpoints.first().expect("The stroke should have endpoints"); + let continuation = [endpoint + DVec2::new(-2., 2.), endpoint + DVec2::new(80., 60.), endpoint + DVec2::new(160., 20.)]; + editor.drag_path(&continuation, ModifierKeys::empty()).await; + + assert_eq!(layer_count(&editor), 1, "Continuing from a transformed layer's endpoint should not create a new layer"); + + let (point_count, segment_count) = point_and_segment_counts(&editor, layer); + assert_eq!(point_count, initial_point_count + continuation.len() - 1, "Each pointer move after the press should add a point"); + assert_eq!(segment_count, point_count - 1, "The continued stroke should form a single open path"); + + // The new points are stored in the layer's local space, so they should sit under the pointer once transformed back to the viewport + let document = editor.active_document(); + let vector = document.network_interface.compute_modified_vector(layer).expect("Layer should have vector geometry"); + let transform = document.metadata().transform_to_viewport(layer); + for &pointer_position in &continuation[1..] { + let under_pointer = vector + .point_domain + .positions() + .iter() + .any(|&position| transform.transform_point2(position).distance(pointer_position) < 1.); + assert!(under_pointer, "Expected a point under the pointer at {pointer_position:?}"); + } + } + + #[tokio::test] + async fn test_endpoint_takes_priority_over_shift_append() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + let layer = draw_freehand_stroke(&mut editor, &INITIAL_STROKE).await; + let (initial_point_count, _) = point_and_segment_counts(&editor, layer); + + let (endpoints, _) = endpoint_and_other_anchor_viewport_positions(&editor, layer); + let endpoint = *endpoints.last().expect("The stroke should have endpoints"); + let continuation = [endpoint + DVec2::new(2., 2.), DVec2::new(400., 150.), DVec2::new(500., 100.)]; + editor.drag_path(&continuation, ModifierKeys::SHIFT).await; + + assert_eq!(layer_count(&editor), 1, "Shift should keep drawing on the selected layer"); + + // Appending a disconnected subpath would leave the layer with one fewer segment than a single continued path has + let (point_count, segment_count) = point_and_segment_counts(&editor, layer); + assert_eq!(point_count, initial_point_count + continuation.len() - 1, "Each pointer move after the press should add a point"); + assert_eq!(segment_count, point_count - 1, "Shift on an endpoint should continue the path rather than append a new subpath"); + } + + #[tokio::test] + async fn test_press_and_release_on_endpoint_leaves_path_unchanged() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + let layer = draw_freehand_stroke(&mut editor, &INITIAL_STROKE).await; + let initial_counts = point_and_segment_counts(&editor, layer); + + let (endpoints, _) = endpoint_and_other_anchor_viewport_positions(&editor, layer); + let endpoint = *endpoints.last().expect("The stroke should have endpoints"); + editor.move_mouse(endpoint.x, endpoint.y, ModifierKeys::empty(), MouseKeys::empty()).await; + editor.left_mousedown(endpoint.x, endpoint.y, ModifierKeys::empty()).await; + editor.left_mouseup(endpoint.x, endpoint.y, ModifierKeys::empty()).await; + + assert_eq!(layer_count(&editor), 1, "Releasing without moving should not create a layer"); + assert_eq!(point_and_segment_counts(&editor, layer), initial_counts, "Releasing without moving should leave the path unchanged"); + } } diff --git a/editor/src/messages/tool/tool_messages/spline_tool.rs b/editor/src/messages/tool/tool_messages/spline_tool.rs index 1a3faae9e0..95ea478690 100644 --- a/editor/src/messages/tool/tool_messages/spline_tool.rs +++ b/editor/src/messages/tool/tool_messages/spline_tool.rs @@ -2,6 +2,7 @@ use super::tool_prelude::*; use crate::consts::{DRAG_THRESHOLD, PATH_JOIN_THRESHOLD, SNAP_POINT_TOLERANCE}; use crate::messages::input_mapper::utility_types::pointer::MouseKeys; use crate::messages::portfolio::document::node_graph::document_node_definitions::{resolve_network_node_type, resolve_proto_node_type}; +use crate::messages::portfolio::document::overlays::utility_functions::path_endpoint_overlays; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; @@ -302,12 +303,19 @@ impl Fsm for SplineToolFsmState { tool_options: &Self::ToolOptions, responses: &mut VecDeque, ) -> Self { - let ToolActionMessageContext { document, input, viewport, .. } = tool_action_data; + let ToolActionMessageContext { + document, + input, + viewport, + shape_editor, + .. + } = tool_action_data; let ToolMessage::Spline(event) = event else { return self }; match (self, event) { (_, SplineToolMessage::CanvasTransformed) => self, (_, SplineToolMessage::Overlays { context: mut overlay_context }) => { + path_endpoint_overlays(document, shape_editor, &mut overlay_context); tool_data.snap_manager.draw_overlays(SnapData::new(document, input, viewport), &mut overlay_context); self } From 2fa62f6eb1fc441884ef6ded654daec8101af715 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 6 Sep 2026 18:34:14 -0700 Subject: [PATCH 2/5] Share the closest-point search with the open path endpoint lookup, tally endpoint connections in a single pass, and rename the endpoint overlay function --- .../document/overlays/utility_functions.rs | 2 +- .../common_functionality/utility_functions.rs | 49 ++++++++----------- .../tool/tool_messages/freehand_tool.rs | 5 +- .../tool/tool_messages/spline_tool.rs | 4 +- .../vector-types/src/vector/vector_types.rs | 10 +++- 5 files changed, 34 insertions(+), 36 deletions(-) diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs index becf5b5e75..247ae756b0 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs @@ -202,7 +202,7 @@ pub fn path_overlays(document: &DocumentMessageHandler, draw_handles: DrawHandle } /// Draws an anchor overlay at each endpoint of every open path on the selected layers, in the selected style for endpoints that are part of the path editing selection. -pub fn path_endpoint_overlays(document: &DocumentMessageHandler, shape_editor: &ShapeState, overlay_context: &mut OverlayContext) { +pub fn open_path_endpoint_overlays(document: &DocumentMessageHandler, shape_editor: &ShapeState, overlay_context: &mut OverlayContext) { if !overlay_context.visibility_settings.anchors() { return; } diff --git a/editor/src/messages/tool/common_functionality/utility_functions.rs b/editor/src/messages/tool/common_functionality/utility_functions.rs index 89fe0d0285..2f3ba5eddb 100644 --- a/editor/src/messages/tool/common_functionality/utility_functions.rs +++ b/editor/src/messages/tool/common_functionality/utility_functions.rs @@ -23,6 +23,12 @@ pub fn should_extend(document: &DocumentMessageHandler, goal: DVec2, tolerance: closest_point(document, goal, tolerance, layers, |_| false) } +/// Finds the endpoint of an open path closest to the goal (in viewport space) across the given layers, if one lies within the tolerance. +/// Only anchors with a single connected segment qualify, so closed paths are never matched. Returns the endpoint's position in the layer's local space. +pub fn closest_open_path_endpoint(document: &DocumentMessageHandler, goal: DVec2, tolerance: f64, layers: impl Iterator) -> Option<(LayerNodeIdentifier, PointId, DVec2)> { + closest_candidate_point(document, goal, tolerance, layers, |vector| vector.anchor_endpoints().collect()) +} + /// Determine the closest point to the goal point under max_distance. /// Additionally exclude checking closeness to the point which given to exclude() returns true. pub fn closest_point( @@ -35,19 +41,28 @@ pub fn closest_point( where T: Fn(PointId) -> bool, { + closest_candidate_point(document, goal, max_distance, layers, |vector| vector.anchor_points().filter(|&id| !exclude(id)).collect()) +} + +/// Determines the closest of each layer's candidate points to the goal (in viewport space) under max_distance. Returns the point's position in the layer's local space. +fn closest_candidate_point( + document: &DocumentMessageHandler, + goal: DVec2, + max_distance: f64, + layers: impl Iterator, + candidates: impl Fn(&Vector) -> Vec, +) -> Option<(LayerNodeIdentifier, PointId, DVec2)> { let mut best = None; let mut best_distance_squared = max_distance * max_distance; + for layer in layers { let viewspace = document.metadata().transform_to_viewport(layer); let Some(vector) = document.network_interface.compute_modified_vector(layer) else { continue }; - for id in vector.anchor_points() { - if exclude(id) { - continue; - } + + for id in candidates(&vector) { let Some(point) = vector.point_domain.position_from_id(id) else { continue }; let distance_squared = viewspace.transform_point2(point).distance_squared(goal); - if distance_squared < best_distance_squared { best = Some((layer, id, point)); best_distance_squared = distance_squared; @@ -58,30 +73,6 @@ where best } -/// Finds the endpoint of an open path closest to the goal (in viewport space) across the given layers, if one lies within the tolerance. -/// Only anchors with a single connected segment qualify, so closed paths are never matched. Returns the endpoint's position in the layer's local space. -pub fn closest_open_path_endpoint(document: &DocumentMessageHandler, goal: DVec2, tolerance: f64, layers: impl Iterator) -> Option<(LayerNodeIdentifier, PointId, DVec2)> { - let mut best = None; - let mut best_distance_squared = tolerance * tolerance; - - for layer in layers { - let to_viewport = document.metadata().transform_to_viewport(layer); - let Some(vector) = document.network_interface.compute_modified_vector(layer) else { continue }; - - for id in vector.anchor_endpoints() { - let Some(position) = vector.point_domain.position_from_id(id) else { continue }; - - let distance_squared = to_viewport.transform_point2(position).distance_squared(goal); - if distance_squared < best_distance_squared { - best = Some((layer, id, position)); - best_distance_squared = distance_squared; - } - } - } - - best -} - /// Calculates the bounding box of the layer's text, based on the settings for max width and height specified in the typesetting config. pub fn text_bounding_box(layer: LayerNodeIdentifier, document: &DocumentMessageHandler, fonts: &FontsMessageHandler, responses: &mut VecDeque) -> Quad { // Use the `editor:text_frame` attribute if available (handles multi-item glyphs and the 'Index Elements' node) diff --git a/editor/src/messages/tool/tool_messages/freehand_tool.rs b/editor/src/messages/tool/tool_messages/freehand_tool.rs index c1dcc0664a..5c9416ade7 100644 --- a/editor/src/messages/tool/tool_messages/freehand_tool.rs +++ b/editor/src/messages/tool/tool_messages/freehand_tool.rs @@ -1,7 +1,7 @@ use super::tool_prelude::*; use crate::consts::SNAP_POINT_TOLERANCE; use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_network_node_type; -use crate::messages::portfolio::document::overlays::utility_functions::path_endpoint_overlays; +use crate::messages::portfolio::document::overlays::utility_functions::open_path_endpoint_overlays; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::tool::common_functionality::color_selector::{ @@ -257,7 +257,7 @@ impl Fsm for FreehandToolFsmState { let ToolMessage::Freehand(event) = event else { return self }; match (self, event) { (_, FreehandToolMessage::Overlays { context: mut overlay_context }) => { - path_endpoint_overlays(document, shape_editor, &mut overlay_context); + open_path_endpoint_overlays(document, shape_editor, &mut overlay_context); self } (FreehandToolFsmState::Ready, FreehandToolMessage::DragStart { append_to_selected }) => { @@ -278,7 +278,6 @@ impl Fsm for FreehandToolFsmState { } if input.keyboard.key(append_to_selected) { - let selected_nodes = document.network_interface.selected_nodes(); let mut selected_layers_except_artboards = selected_nodes.selected_layers_except_artboards(&document.network_interface); let existing_layer = selected_layers_except_artboards.next().filter(|_| selected_layers_except_artboards.next().is_none()); if let Some(layer) = existing_layer { diff --git a/editor/src/messages/tool/tool_messages/spline_tool.rs b/editor/src/messages/tool/tool_messages/spline_tool.rs index 95ea478690..ca618fe280 100644 --- a/editor/src/messages/tool/tool_messages/spline_tool.rs +++ b/editor/src/messages/tool/tool_messages/spline_tool.rs @@ -2,7 +2,7 @@ use super::tool_prelude::*; use crate::consts::{DRAG_THRESHOLD, PATH_JOIN_THRESHOLD, SNAP_POINT_TOLERANCE}; use crate::messages::input_mapper::utility_types::pointer::MouseKeys; use crate::messages::portfolio::document::node_graph::document_node_definitions::{resolve_network_node_type, resolve_proto_node_type}; -use crate::messages::portfolio::document::overlays::utility_functions::path_endpoint_overlays; +use crate::messages::portfolio::document::overlays::utility_functions::open_path_endpoint_overlays; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; @@ -315,7 +315,7 @@ impl Fsm for SplineToolFsmState { match (self, event) { (_, SplineToolMessage::CanvasTransformed) => self, (_, SplineToolMessage::Overlays { context: mut overlay_context }) => { - path_endpoint_overlays(document, shape_editor, &mut overlay_context); + open_path_endpoint_overlays(document, shape_editor, &mut overlay_context); tool_data.snap_manager.draw_overlays(SnapData::new(document, input, viewport), &mut overlay_context); self } diff --git a/node-graph/libraries/vector-types/src/vector/vector_types.rs b/node-graph/libraries/vector-types/src/vector/vector_types.rs index 034a2c28f3..62367f05b6 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_types.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_types.rs @@ -366,7 +366,15 @@ impl Vector { /// Anchor points at the ends of open subpaths. These are points with exactly one connection by a segment to another anchor. pub fn anchor_endpoints(&self) -> impl Iterator + '_ { - self.anchor_points().enumerate().filter(|&(index, _)| self.segment_domain.connected_count(index) == 1).map(|(_, id)| id) + // O(points + segments): tally every point's connections in a single pass + let mut connected_counts = vec![0_usize; self.point_domain.ids().len()]; + for &point_index in self.segment_domain.start_point().iter().chain(self.segment_domain.end_point()) { + if let Some(count) = connected_counts.get_mut(point_index) { + *count += 1; + } + } + + self.anchor_points().zip(connected_counts).filter(|&(_, count)| count == 1).map(|(id, _)| id) } /// Computes if all the connected handles are colinear for an anchor, or if that handle is colinear for a handle. From e795d1f09f3fddfcdb6865bb64a31aa8def4d19f Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 6 Sep 2026 19:01:12 -0700 Subject: [PATCH 3/5] Skip hidden layers and use the feeds-aware transform in path point searches, path overlays, and Freehand point placement --- .../document/overlays/utility_functions.rs | 8 +- .../network_interface/queries.rs | 7 ++ .../portfolio/document/utility_types/nodes.rs | 8 +- .../common_functionality/utility_functions.rs | 8 +- .../tool/tool_messages/freehand_tool.rs | 94 +++++++++++++++++-- .../messages/tool/tool_messages/path_tool.rs | 2 +- .../transform_layer_message_handler.rs | 2 +- 7 files changed, 108 insertions(+), 21 deletions(-) diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs index 247ae756b0..6bbc928f7d 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs @@ -132,7 +132,7 @@ pub fn path_overlays(document: &DocumentMessageHandler, draw_handles: DrawHandle let display_handles = overlay_context.visibility_settings.handles(); let display_anchors = overlay_context.visibility_settings.anchors(); - for layer in document.network_interface.selected_nodes().selected_layers(document.metadata()) { + for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) { let Some(vector) = document.network_interface.compute_modified_vector(layer) else { continue }; let transform = document.metadata().transform_to_viewport_if_feeds(layer, &document.network_interface); if display_path { @@ -201,15 +201,15 @@ pub fn path_overlays(document: &DocumentMessageHandler, draw_handles: DrawHandle } } -/// Draws an anchor overlay at each endpoint of every open path on the selected layers, in the selected style for endpoints that are part of the path editing selection. +/// Draws an anchor overlay at each endpoint of every open path on the selected visible layers, in the selected style for endpoints that are part of the path editing selection. pub fn open_path_endpoint_overlays(document: &DocumentMessageHandler, shape_editor: &ShapeState, overlay_context: &mut OverlayContext) { if !overlay_context.visibility_settings.anchors() { return; } - for layer in document.network_interface.selected_nodes().selected_layers(document.metadata()) { + for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) { let Some(vector) = document.network_interface.compute_modified_vector(layer) else { continue }; - let transform = document.metadata().transform_to_viewport(layer); + let transform = document.metadata().transform_to_viewport_if_feeds(layer, &document.network_interface); let selected_layer_state = shape_editor.selected_shape_state.get(&layer); for id in vector.anchor_endpoints() { diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs index bf888f4ca5..b4b577ed56 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/queries.rs @@ -841,6 +841,13 @@ impl NodeNetworkInterface { self.query(network_path, "is_visible", |view| view.is_visible(node_id)).unwrap_or_default() } + /// Whether a layer in the document network is visible, which also requires every ancestor to be visible. + pub fn is_layer_visible(&self, layer: LayerNodeIdentifier) -> bool { + layer + .ancestors(self.document_metadata()) + .all(|ancestor| ancestor == LayerNodeIdentifier::ROOT_PARENT || self.is_visible(&ancestor.to_node(), &[])) + } + pub fn is_layer(&self, node_id: &NodeId, network_path: &[NodeId]) -> bool { self.query(network_path, "is_layer", |view| view.is_layer(node_id)).unwrap_or_default() } diff --git a/editor/src/messages/portfolio/document/utility_types/nodes.rs b/editor/src/messages/portfolio/document/utility_types/nodes.rs index e24a86ae0e..5f0b8057cf 100644 --- a/editor/src/messages/portfolio/document/utility_types/nodes.rs +++ b/editor/src/messages/portfolio/document/utility_types/nodes.rs @@ -52,13 +52,7 @@ pub struct SelectedNodes(pub Vec); impl SelectedNodes { pub fn layer_visible(&self, layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> bool { - layer.ancestors(network_interface.document_metadata()).all(|layer| { - if layer != LayerNodeIdentifier::ROOT_PARENT { - network_interface.is_visible(&layer.to_node(), &[]) - } else { - true - } - }) + network_interface.is_layer_visible(layer) } pub fn selected_visible_layers<'a>(&'a self, network_interface: &'a NodeNetworkInterface) -> impl Iterator + 'a { diff --git a/editor/src/messages/tool/common_functionality/utility_functions.rs b/editor/src/messages/tool/common_functionality/utility_functions.rs index 2f3ba5eddb..46f0e477b4 100644 --- a/editor/src/messages/tool/common_functionality/utility_functions.rs +++ b/editor/src/messages/tool/common_functionality/utility_functions.rs @@ -44,7 +44,7 @@ where closest_candidate_point(document, goal, max_distance, layers, |vector| vector.anchor_points().filter(|&id| !exclude(id)).collect()) } -/// Determines the closest of each layer's candidate points to the goal (in viewport space) under max_distance. Returns the point's position in the layer's local space. +/// Determines the closest of each visible layer's candidate points to the goal (in viewport space) under max_distance. Returns the point's position in the layer's local space. fn closest_candidate_point( document: &DocumentMessageHandler, goal: DVec2, @@ -56,7 +56,11 @@ fn closest_candidate_point( let mut best_distance_squared = max_distance * max_distance; for layer in layers { - let viewspace = document.metadata().transform_to_viewport(layer); + if !document.network_interface.is_layer_visible(layer) { + continue; + } + + let viewspace = document.metadata().transform_to_viewport_if_feeds(layer, &document.network_interface); let Some(vector) = document.network_interface.compute_modified_vector(layer) else { continue }; for id in candidates(&vector) { diff --git a/editor/src/messages/tool/tool_messages/freehand_tool.rs b/editor/src/messages/tool/tool_messages/freehand_tool.rs index 5c9416ade7..80e3b3bd6c 100644 --- a/editor/src/messages/tool/tool_messages/freehand_tool.rs +++ b/editor/src/messages/tool/tool_messages/freehand_tool.rs @@ -269,8 +269,8 @@ impl Fsm for FreehandToolFsmState { // Pressing on the endpoint of a selected open path continues that path in place, keeping the layer, its selection, and its style let selected_nodes = document.network_interface.selected_nodes(); - let selected_layers = selected_nodes.selected_layers(document.metadata()); - if let Some((layer, endpoint, position)) = closest_open_path_endpoint(document, input.mouse.position, SNAP_POINT_TOLERANCE, selected_layers) { + let selected_visible_layers = selected_nodes.selected_visible_layers(&document.network_interface); + if let Some((layer, endpoint, position)) = closest_open_path_endpoint(document, input.mouse.position, SNAP_POINT_TOLERANCE, selected_visible_layers) { tool_data.layer = Some(layer); tool_data.end_point = Some((position, endpoint)); @@ -278,12 +278,14 @@ impl Fsm for FreehandToolFsmState { } if input.keyboard.key(append_to_selected) { - let mut selected_layers_except_artboards = selected_nodes.selected_layers_except_artboards(&document.network_interface); - let existing_layer = selected_layers_except_artboards.next().filter(|_| selected_layers_except_artboards.next().is_none()); + let mut appendable_layers = selected_nodes + .selected_visible_layers(&document.network_interface) + .filter(|layer| !document.network_interface.is_artboard(&layer.to_node(), &[])); + let existing_layer = appendable_layers.next().filter(|_| appendable_layers.next().is_none()); if let Some(layer) = existing_layer { tool_data.layer = Some(layer); - let transform = document.metadata().transform_to_viewport(layer); + let transform = document.metadata().transform_to_viewport_if_feeds(layer, &document.network_interface); let position = transform.inverse().transform_point2(input.mouse.position); extend_path_with_next_segment(tool_data, position, false, responses); @@ -316,7 +318,7 @@ impl Fsm for FreehandToolFsmState { } (FreehandToolFsmState::Drawing, FreehandToolMessage::PointerMove) => { if let Some(layer) = tool_data.layer { - let transform = document.metadata().transform_to_viewport(layer); + let transform = document.metadata().transform_to_viewport_if_feeds(layer, &document.network_interface); // For newly created layers, the deferred TransformSet may not yet be reflected // in the metadata, so compute local position from the known viewport start. @@ -866,4 +868,84 @@ mod test_freehand { assert_eq!(layer_count(&editor), 1, "Releasing without moving should not create a layer"); assert_eq!(point_and_segment_counts(&editor, layer), initial_counts, "Releasing without moving should leave the path unchanged"); } + + #[tokio::test] + async fn test_hidden_layer_endpoint_is_not_extended() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + let layer = draw_freehand_stroke(&mut editor, &INITIAL_STROKE).await; + let initial_counts = point_and_segment_counts(&editor, layer); + let (endpoints, _) = endpoint_and_other_anchor_viewport_positions(&editor, layer); + let endpoint = *endpoints.first().expect("The stroke should have endpoints"); + + // Where the hidden layer's endpoint would land without its transform + let metadata = editor.active_document().metadata(); + let untransformed_endpoint = metadata + .document_to_viewport + .transform_point2(metadata.transform_to_viewport(layer).inverse().transform_point2(endpoint)); + + editor + .handle_message(NodeGraphMessage::ToggleVisibility { + node_id: layer.to_node(), + network_path: Vec::new(), + }) + .await; + assert_eq!(editor.get_selected_layer().await, Some(layer), "Hiding the layer should leave it selected"); + + editor + .drag_path(&[endpoint + DVec2::new(2., -2.), DVec2::new(400., 150.), DVec2::new(500., 100.)], ModifierKeys::empty()) + .await; + assert_eq!(layer_count(&editor), 2, "A stroke at a hidden layer's endpoint should create a new layer"); + + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await; + editor + .drag_path( + &[untransformed_endpoint, untransformed_endpoint + DVec2::new(100., 50.), untransformed_endpoint + DVec2::new(200., 0.)], + ModifierKeys::empty(), + ) + .await; + assert_eq!( + layer_count(&editor), + 3, + "A stroke where the hidden layer's untransformed endpoint would sit should also create a new layer" + ); + + editor + .handle_message(NodeGraphMessage::ToggleVisibility { + node_id: layer.to_node(), + network_path: Vec::new(), + }) + .await; + assert_eq!(point_and_segment_counts(&editor, layer), initial_counts, "The hidden layer should be untouched"); + } + + #[tokio::test] + async fn test_locked_layer_endpoint_is_extended() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + let layer = draw_freehand_stroke(&mut editor, &INITIAL_STROKE).await; + let (initial_point_count, _) = point_and_segment_counts(&editor, layer); + let (endpoints, _) = endpoint_and_other_anchor_viewport_positions(&editor, layer); + let endpoint = *endpoints.last().expect("The stroke should have endpoints"); + + editor + .handle_message(NodeGraphMessage::ToggleLocked { + node_id: layer.to_node(), + network_path: Vec::new(), + }) + .await; + assert_eq!(editor.get_selected_layer().await, Some(layer), "Locking the layer should leave it selected"); + + let continuation = [endpoint + DVec2::new(2., -2.), DVec2::new(400., 150.), DVec2::new(500., 100.)]; + editor.drag_path(&continuation, ModifierKeys::empty()).await; + + // Locking only blocks viewport picking by the Select tool, so a selected locked layer stays editable + assert_eq!(layer_count(&editor), 1, "A selected locked layer's endpoint should still be continued from"); + + let (point_count, segment_count) = point_and_segment_counts(&editor, layer); + assert_eq!(point_count, initial_point_count + continuation.len() - 1, "Each pointer move after the press should add a point"); + assert_eq!(segment_count, point_count - 1, "The continued stroke should form a single open path"); + } } diff --git a/editor/src/messages/tool/tool_messages/path_tool.rs b/editor/src/messages/tool/tool_messages/path_tool.rs index 8a1ed50e8f..bda6e6e43d 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -1587,7 +1587,7 @@ impl Fsm for PathToolFsmState { match (self, event) { (_, PathToolMessage::SelectionChanged) => { // Set the newly targeted layers to visible - let target_layers = document.network_interface.selected_nodes().selected_layers(document.metadata()).collect(); + let target_layers = document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface).collect(); shape_editor.set_selected_layers(target_layers); diff --git a/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs b/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs index 88b2c8fb2f..bc9dbd6adb 100644 --- a/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs +++ b/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs @@ -584,7 +584,7 @@ impl MessageHandler> for self.mouse_position = input.mouse.position; } TransformLayerMessage::SelectionChanged => { - let target_layers = document.network_interface.selected_nodes().selected_layers(document.metadata()).collect(); + let target_layers = document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface).collect(); shape_editor.set_selected_layers(target_layers); } TransformLayerMessage::TypeBackspace => { From 51e07f3918d6ee88bc826c683572dde5dd8547a9 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 6 Sep 2026 20:10:59 -0700 Subject: [PATCH 4/5] Map the Spline tool's merge goal with the feeds-aware transform so it matches the endpoint candidates --- editor/src/messages/tool/tool_messages/spline_tool.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/editor/src/messages/tool/tool_messages/spline_tool.rs b/editor/src/messages/tool/tool_messages/spline_tool.rs index ca618fe280..37dbad4c75 100644 --- a/editor/src/messages/tool/tool_messages/spline_tool.rs +++ b/editor/src/messages/tool/tool_messages/spline_tool.rs @@ -573,7 +573,10 @@ fn try_merging_lastest_endpoint(document: &DocumentMessageHandler, tool_data: &m .filter(|layer| !document.network_interface.is_artboard(&layer.to_node(), &[])); let exclude = |p: PointId| preview_point.is_some_and(|pp| pp == p) || *last_endpoint == p; - let position = document.metadata().transform_to_viewport(current_layer).transform_point2(*last_endpoint_position); + let position = document + .metadata() + .transform_to_viewport_if_feeds(current_layer, &document.network_interface) + .transform_point2(*last_endpoint_position); let (layer, endpoint, _) = closest_point(document, position, PATH_JOIN_THRESHOLD, layers, exclude)?; tool_data.merge_layers.insert(layer); From a5d62dafd8e1de55711e684bb17e82be1386e392 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 6 Sep 2026 20:24:09 -0700 Subject: [PATCH 5/5] Highlight the hovered open path endpoint in the Freehand and Spline tools --- .../document/overlays/utility_functions.rs | 30 +++++++++++++++---- .../tool/tool_messages/freehand_tool.rs | 8 ++++- .../tool/tool_messages/spline_tool.rs | 3 +- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs index 6bbc928f7d..59de9f797e 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs @@ -1,9 +1,10 @@ use super::utility_types::{DrawHandles, OverlayContext}; -use crate::consts::HIDE_HANDLE_DISTANCE; +use crate::consts::{HIDE_HANDLE_DISTANCE, SNAP_POINT_TOLERANCE}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; pub use crate::messages::portfolio::document::utility_types::text_metrics::text_width; use crate::messages::tool::common_functionality::shape_editor::{SelectedLayerState, ShapeState}; +use crate::messages::tool::common_functionality::utility_functions::closest_open_path_endpoint; use crate::messages::tool::tool_messages::tool_prelude::DocumentMessageHandler; use glam::{DAffine2, DVec2}; use graphene_std::vector::misc::{BezierHandles, ManipulatorPointId, point_to_dvec2, segment_to_handles}; @@ -202,23 +203,40 @@ pub fn path_overlays(document: &DocumentMessageHandler, draw_handles: DrawHandle } /// Draws an anchor overlay at each endpoint of every open path on the selected visible layers, in the selected style for endpoints that are part of the path editing selection. -pub fn open_path_endpoint_overlays(document: &DocumentMessageHandler, shape_editor: &ShapeState, overlay_context: &mut OverlayContext) { +/// Given a pointer position, the endpoint a press there would continue from is drawn in the hover style instead. +pub fn open_path_endpoint_overlays(document: &DocumentMessageHandler, shape_editor: &ShapeState, pointer: Option, overlay_context: &mut OverlayContext) { if !overlay_context.visibility_settings.anchors() { return; } - for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) { + let selected_nodes = document.network_interface.selected_nodes(); + let is_selected = |layer: LayerNodeIdentifier, id: PointId| { + shape_editor + .selected_shape_state + .get(&layer) + .is_some_and(|state| state.is_point_selected(ManipulatorPointId::Anchor(id))) + }; + let hovered = pointer.and_then(|pointer| closest_open_path_endpoint(document, pointer, SNAP_POINT_TOLERANCE, selected_nodes.selected_visible_layers(&document.network_interface))); + + for layer in selected_nodes.selected_visible_layers(&document.network_interface) { let Some(vector) = document.network_interface.compute_modified_vector(layer) else { continue }; let transform = document.metadata().transform_to_viewport_if_feeds(layer, &document.network_interface); - let selected_layer_state = shape_editor.selected_shape_state.get(&layer); for id in vector.anchor_endpoints() { + if hovered.is_some_and(|(hovered_layer, hovered_id, _)| hovered_layer == layer && hovered_id == id) { + continue; + } let Some(position) = vector.point_domain.position_from_id(id) else { continue }; - let selected = selected_layer_state.is_some_and(|state| state.is_point_selected(ManipulatorPointId::Anchor(id))); - overlay_context.manipulator_anchor(transform.transform_point2(position), selected, None); + overlay_context.manipulator_anchor(transform.transform_point2(position), is_selected(layer, id), None); } } + + // Drawn last so its halo sits above any other endpoint at the same spot + if let Some((layer, id, position)) = hovered { + let transform = document.metadata().transform_to_viewport_if_feeds(layer, &document.network_interface); + overlay_context.hover_manipulator_anchor(transform.transform_point2(position), is_selected(layer, id)); + } } pub fn hex_to_rgba_u8(hex: &str) -> [u8; 4] { diff --git a/editor/src/messages/tool/tool_messages/freehand_tool.rs b/editor/src/messages/tool/tool_messages/freehand_tool.rs index 80e3b3bd6c..35c4d9f183 100644 --- a/editor/src/messages/tool/tool_messages/freehand_tool.rs +++ b/editor/src/messages/tool/tool_messages/freehand_tool.rs @@ -201,6 +201,7 @@ impl<'a> MessageHandler> for Free FreehandToolFsmState::Ready => actions!(FreehandToolMessageDiscriminant; DragStart, DragStop, + PointerMove, ), FreehandToolFsmState::Drawing => actions!(FreehandToolMessageDiscriminant; DragStop, @@ -257,7 +258,12 @@ impl Fsm for FreehandToolFsmState { let ToolMessage::Freehand(event) = event else { return self }; match (self, event) { (_, FreehandToolMessage::Overlays { context: mut overlay_context }) => { - open_path_endpoint_overlays(document, shape_editor, &mut overlay_context); + let pointer = (self == FreehandToolFsmState::Ready).then_some(input.mouse.position); + open_path_endpoint_overlays(document, shape_editor, pointer, &mut overlay_context); + self + } + (FreehandToolFsmState::Ready, FreehandToolMessage::PointerMove) => { + responses.add(OverlaysMessage::Draw); self } (FreehandToolFsmState::Ready, FreehandToolMessage::DragStart { append_to_selected }) => { diff --git a/editor/src/messages/tool/tool_messages/spline_tool.rs b/editor/src/messages/tool/tool_messages/spline_tool.rs index 37dbad4c75..829e26f458 100644 --- a/editor/src/messages/tool/tool_messages/spline_tool.rs +++ b/editor/src/messages/tool/tool_messages/spline_tool.rs @@ -315,7 +315,8 @@ impl Fsm for SplineToolFsmState { match (self, event) { (_, SplineToolMessage::CanvasTransformed) => self, (_, SplineToolMessage::Overlays { context: mut overlay_context }) => { - open_path_endpoint_overlays(document, shape_editor, &mut overlay_context); + let pointer = (self == SplineToolFsmState::Ready).then_some(input.mouse.position); + open_path_endpoint_overlays(document, shape_editor, pointer, &mut overlay_context); tool_data.snap_manager.draw_overlays(SnapData::new(document, input, viewport), &mut overlay_context); self }