diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs index 44bbdef00f..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}; @@ -132,7 +133,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,6 +202,43 @@ 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. +/// 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; + } + + 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); + + 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 }; + + 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] { let hex = hex.trim().trim_start_matches('#'); if hex.len() != 6 && hex.len() != 8 { 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 a4ea05db1d..46f0e477b4 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,32 @@ 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 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, + 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); + 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 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; diff --git a/editor/src/messages/tool/tool_messages/freehand_tool.rs b/editor/src/messages/tool/tool_messages/freehand_tool.rs index eba9c4c209..35c4d9f183 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::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::{ 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, @@ -196,6 +201,7 @@ impl<'a> MessageHandler> for Free FreehandToolFsmState::Ready => actions!(FreehandToolMessageDiscriminant; DragStart, DragStop, + PointerMove, ), FreehandToolFsmState::Drawing => actions!(FreehandToolMessageDiscriminant; DragStop, @@ -209,6 +215,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 +247,25 @@ 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 }) => { + 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 }) => { responses.add(DocumentMessage::StartTransaction); @@ -251,14 +273,25 @@ 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_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)); + + 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); - 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); @@ -291,7 +324,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. @@ -401,7 +434,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 +684,274 @@ 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"); + } + + #[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/tool_messages/spline_tool.rs b/editor/src/messages/tool/tool_messages/spline_tool.rs index 1a3faae9e0..829e26f458 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::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; @@ -302,12 +303,20 @@ 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 }) => { + 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 } @@ -565,7 +574,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); 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 => { 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.