diff --git a/editor/src/consts.rs b/editor/src/consts.rs index bee85d36e1..2f779d7656 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -117,6 +117,7 @@ pub const SEGMENT_INSERTION_DISTANCE: f64 = 5.; pub const SEGMENT_OVERLAY_SIZE: f64 = 10.; pub const SEGMENT_SELECTED_THICKNESS: f64 = 3.; pub const HANDLE_LENGTH_FACTOR: f64 = 0.5; +pub const SNAP_DAMPENING_START_MULTIPLIER: f64 = 0.3; // GRADIENT TOOL pub const GRADIENT_MIDPOINT_DIAMOND_RADIUS: f64 = 4.; diff --git a/editor/src/messages/tool/tool_messages/path_tool.rs b/editor/src/messages/tool/tool_messages/path_tool.rs index 40b201a41b..8bceef3bf1 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -3,7 +3,7 @@ use super::tool_prelude::*; use crate::consts::{ COLOR_OVERLAY_BLUE, COLOR_OVERLAY_BLUE_05, COLOR_OVERLAY_GRAY, COLOR_OVERLAY_GREEN, COLOR_OVERLAY_GREEN_25, COLOR_OVERLAY_RED, COLOR_OVERLAY_RED_25, DEFAULT_STROKE_WIDTH, DOUBLE_CLICK_MILLISECONDS, DRAG_DIRECTION_MODE_DETERMINATION_THRESHOLD, DRAG_THRESHOLD, DRILL_THROUGH_THRESHOLD, HANDLE_ROTATE_SNAP_ANGLE, SEGMENT_INSERTION_DISTANCE, SEGMENT_OVERLAY_SIZE, - SELECTION_THRESHOLD, SELECTION_TOLERANCE, + SELECTION_THRESHOLD, SELECTION_TOLERANCE, SNAP_DAMPENING_START_MULTIPLIER, }; use crate::messages::clipboard::utility_types::ClipboardContent; use crate::messages::input_mapper::utility_types::macros::action_shortcut_manual; @@ -13,6 +13,7 @@ use crate::messages::portfolio::document::overlays::utility_functions::{path_ove use crate::messages::portfolio::document::overlays::utility_types::{DrawHandles, OverlayContext}; use crate::messages::portfolio::document::utility_types::clipboards::Clipboard; use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier}; +use crate::messages::portfolio::document::utility_types::misc::{PathSnapTarget, SnapTarget}; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; use crate::messages::portfolio::document::utility_types::transformation::Axis; use crate::messages::preferences::SelectionMode; @@ -22,7 +23,7 @@ use crate::messages::tool::common_functionality::pivot::{PivotGizmo, PivotGizmoT use crate::messages::tool::common_functionality::shape_editor::{ ClosestSegment, ManipulatorAngle, OpposingHandleLengths, SelectedLayerState, SelectedPointsInfo, SelectionChange, SelectionShape, SelectionShapeType, ShapeState, }; -use crate::messages::tool::common_functionality::snapping::{SnapCache, SnapCandidatePoint, SnapConstraint, SnapData, SnapManager}; +use crate::messages::tool::common_functionality::snapping::{SnapCache, SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnappedPoint}; use crate::messages::tool::common_functionality::utility_functions::{calculate_segment_angle, find_two_param_best_approximate, make_path_editable_is_allowed}; use graphene_std::Color; use graphene_std::renderer::Quad; @@ -1062,6 +1063,9 @@ impl PathToolData { snap_angle: bool, tangent_to_neighboring_tangents: bool, ) -> f64 { + if handle_vector.length_squared() < f64::EPSILON { + return self.angle; + } let current_angle = -handle_vector.angle_to(DVec2::X); if let Some((vector, layer)) = shape_editor @@ -1150,11 +1154,47 @@ impl PathToolData { false => self.snap_manager.free_snap(&snap_data, &snap_point, Default::default()), }; + let snap_result = self.reduce_snap_weight(snap_result, new_handle_position, anchor_position); + self.snap_manager.update_indicator(snap_result.clone()); document.metadata().document_to_viewport.transform_vector2(snap_result.snapped_point_document - handle_position) } + fn reduce_snap_weight(&self, mut snap_result: SnappedPoint, new_handle_position: DVec2, anchor_position: DVec2) -> SnappedPoint { + //If the snapping result is a non-finite position + if snap_result.distance == f64::INFINITY { + return SnappedPoint { + snapped_point_document: new_handle_position, + ..Default::default() + }; + } + + if !matches!(snap_result.target, SnapTarget::Path(PathSnapTarget::AlongPath)) { + return snap_result; + } + + let selection_status = &self.selection_status; + if selection_status.angle() != Some(ManipulatorAngle::Colinear) { + return snap_result; + } + + let anchor_distance = snap_result.snapped_point_document.distance(anchor_position); + let dampening_start = snap_result.tolerance * SNAP_DAMPENING_START_MULTIPLIER; + if anchor_distance >= dampening_start { + return snap_result; + } + + // 1 -> full snap; 0 -> no snap + let t: f64 = (anchor_distance / dampening_start).clamp(0.0, 1.0); + // smoothstep function: 3 * t ^ 2 - 2 * t ^ 3 + let weight = t * t * (3.0 - 2.0 * t); + //linear interpolation + snap_result.snapped_point_document = new_handle_position.lerp(snap_result.snapped_point_document, weight); + + snap_result + } + fn start_snap_along_axis(&mut self, shape_editor: &mut ShapeState, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque) { // Find the negative delta to take the point to the drag start position let current_mouse = input.mouse.position; @@ -3662,3 +3702,231 @@ fn update_dynamic_hints( hint_data.send_layout(responses); responses.add(ToolMessage::UpdateHints); } + +#[cfg(test)] +mod test_path { + use crate::messages::input_mapper::utility_types::input_keyboard::ModifierKeys; + use crate::messages::input_mapper::utility_types::input_mouse::MouseKeys; + use crate::messages::portfolio::document::utility_types::misc::{SNAP_FUNCTIONS_FOR_BOUNDING_BOXES, SNAP_FUNCTIONS_FOR_PATHS}; + pub use crate::test_utils::test_prelude::*; + use glam::DAffine2; + use graphene_std::subpath::BezierHandles; + use graphene_std::vector::Vector; + + async fn prepare_document_for_path_snap_weight(anchor_positions: &[DVec2]) -> EditorTestUtils { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.set_viewport_size(DVec2::splat(-1000.), DVec2::splat(1000.)).await; // Necessary for doing snapping since snaps outside of the viewport are discarded + + editor.drag_tool(ToolType::Artboard, 0., 0., 1000., 600., ModifierKeys::empty()).await; // Necessary for doing path snapping without it that path snapping does not work + editor.select_tool(ToolType::Select).await; + + // Disable all bounding box snapping + for (_, closure, _) in SNAP_FUNCTIONS_FOR_BOUNDING_BOXES { + editor + .handle_message(DocumentMessage::SetSnapping { + closure: Some(closure), + snapping_state: false, + }) + .await; + } + + // Disable all path snapping EXCEPT along_path + for (name, closure, _) in SNAP_FUNCTIONS_FOR_PATHS { + let enabled = name == "Along Paths"; + + editor + .handle_message(DocumentMessage::SetSnapping { + closure: Some(closure), + snapping_state: enabled, + }) + .await; + } + + //Create Bezier path for testing + editor.drag_tool(ToolType::Pen, anchor_positions[0].x, anchor_positions[0].y, 650., 30., ModifierKeys::empty()).await; + editor.drag_tool(ToolType::Pen, anchor_positions[1].x, anchor_positions[1].y, 370., 420., ModifierKeys::empty()).await; + editor.drag_tool(ToolType::Pen, anchor_positions[2].x, anchor_positions[2].y, 20., 330., ModifierKeys::empty()).await; + editor.press(Key::Enter, ModifierKeys::empty()).await; + + assert_eq!( + editor.active_document().metadata().all_layers().count(), + 2, + "The document should contain one artboard and one path layers" + ); + + let (modified_path, layer_to_viewport) = get_path_data(&editor); + assert_anchor_positions(&modified_path, layer_to_viewport, anchor_positions, 1e-10); + + let expected_handles: Vec = vec![ + BezierHandles::Cubic { + handle_start: DVec2::new(700.0, 60.0), + handle_end: DVec2::new(430.0, 180.0), + }, + BezierHandles::Cubic { + handle_start: DVec2::new(370.0, 420.0), + handle_end: DVec2::new(80.0, 70.0), + }, + ]; + assert_handle_positions(&modified_path, &expected_handles, layer_to_viewport, 1e-10); + + editor + } + + fn get_path_data(editor: &EditorTestUtils) -> (Vector, DAffine2) { + let document = editor.active_document(); + + let path_layer = document.metadata().all_layers().nth(1).expect("Expected path layer"); + + let modified_path = document.network_interface.compute_modified_vector(path_layer).expect("Vector not found in the path layer"); + + let layer_to_document = document.metadata().transform_to_document(path_layer); + + (modified_path, layer_to_document) + } + + fn assert_anchor_positions(vector: &Vector, transform: DAffine2, expected_anchors: &[DVec2], epsilon: f64) { + let anchors_in_viewport: Vec = vector + .point_domain + .ids() + .iter() + .filter_map(|&point_id| { + let pos = vector.point_domain.position_from_id(point_id)?; + Some(transform.transform_point2(pos)) + }) + .collect(); + + assert_eq!(anchors_in_viewport.len(), expected_anchors.len(), "Anchor count mismatch"); + + for (i, expected) in expected_anchors.iter().enumerate() { + let actual = anchors_in_viewport[i]; + let distance = (actual - *expected).length(); + + assert!(distance < epsilon, "Anchor {i} mismatch: expected {expected:?}, got {actual:?}, distance {distance}"); + } + } + + fn assert_handle_positions(vector: &Vector, expected_handles: &[BezierHandles], transform: DAffine2, epsilon: f64) { + let segment_ids = vector.segment_domain.ids(); + + assert_eq!(segment_ids.len(), expected_handles.len(), "Segment count mismatch for handles"); + + for (i, segment_id) in segment_ids.iter().enumerate() { + let segment = vector.segment_from_id(*segment_id).expect("Segment not found"); + let expected = &expected_handles[i]; + + match (&segment.handles, expected) { + (BezierHandles::Linear, BezierHandles::Linear) => { + // OK + } + + (BezierHandles::Quadratic { handle: actual }, BezierHandles::Quadratic { handle: expected }) => { + let actual_viewport = transform.transform_point2(*actual); + + let dist = (actual_viewport - expected).length(); + + assert!( + dist < epsilon, + "Segment {i} quadratic handle mismatch: expected {:?}, got {:?}, dist {}", + expected, + actual_viewport, + dist + ); + } + + ( + BezierHandles::Cubic { + handle_start: actual_start, + handle_end: actual_end, + }, + BezierHandles::Cubic { + handle_start: expected_start, + handle_end: expected_end, + }, + ) => { + let actual_start_viewport = transform.transform_point2(*actual_start); + let actual_end_viewport = transform.transform_point2(*actual_end); + + let dist_start = (actual_start_viewport - expected_start).length(); + let dist_end = (actual_end_viewport - expected_end).length(); + + assert!( + dist_start < epsilon, + "Segment {i} cubic start handle mismatch: expected {:?}, got {:?}, dist {}", + expected_start, + actual_start_viewport, + dist_start + ); + + assert!( + dist_end < epsilon, + "Segment {i} cubic end handle mismatch: expected {:?}, got {:?}, dist {}", + expected_end, + actual_end_viewport, + dist_end + ); + } + // Mismatch case + (actual, expected) => { + panic!("Segment {i} handle type mismatch: actual = {:?}, expected = {:?}", actual, expected); + } + } + } + } + + #[tokio::test] + async fn path_move_handle_close_to_anchor_with_along_path_snapping() { + let anchor_positions = [DVec2::new(50., 30.), DVec2::new(400., 300.), DVec2::new(50., 200.)]; + let delta_x = -2.; + let delta_y = 1.; + let mut editor = prepare_document_for_path_snap_weight(&anchor_positions).await; + + editor.click_tool(ToolType::Path, MouseKeys::LEFT, anchor_positions[1], ModifierKeys::empty()).await; + editor + .drag_tool(ToolType::Path, 370., 420., anchor_positions[1].x + delta_x, anchor_positions[1].y + delta_y, ModifierKeys::empty()) + .await; + editor.press(Key::Enter, ModifierKeys::empty()).await; + + let (modified_path, layer_to_document) = get_path_data(&editor); + + assert_anchor_positions(&modified_path, layer_to_document, &anchor_positions, 1e-10); + + let expected_handles: Vec = vec![ + BezierHandles::Cubic { + handle_start: DVec2::new(700.0, 60.0), + handle_end: DVec2::new(470.9153, 198.6539), + }, + BezierHandles::Cubic { + handle_start: DVec2::new(399.0945, 301.2940), + handle_end: DVec2::new(80.0, 70.0), + }, + ]; + assert_handle_positions(&modified_path, &expected_handles, layer_to_document, 1e-4); + } + + #[tokio::test] + async fn path_move_handle_to_anchor() { + let anchor_positions = [DVec2::new(50., 30.), DVec2::new(400., 300.), DVec2::new(50., 200.)]; + let mut editor = prepare_document_for_path_snap_weight(&anchor_positions).await; + + editor.click_tool(ToolType::Path, MouseKeys::LEFT, anchor_positions[1], ModifierKeys::empty()).await; + editor.drag_tool(ToolType::Path, 370., 420., anchor_positions[1].x, anchor_positions[1].y, ModifierKeys::empty()).await; + editor.press(Key::Enter, ModifierKeys::empty()).await; + + let (modified_path, layer_to_document) = get_path_data(&editor); + + assert_anchor_positions(&modified_path, layer_to_document, &anchor_positions, 1e-10); + + let expected_handles: Vec = vec![ + BezierHandles::Cubic { + handle_start: DVec2::new(700.0, 60.0), + handle_end: DVec2::new(430.0, 180.0), + }, + BezierHandles::Cubic { + handle_start: DVec2::new(400.0, 300.0), + handle_end: DVec2::new(80.0, 70.0), + }, + ]; + assert_handle_positions(&modified_path, &expected_handles, layer_to_document, 1e-10); + } +}