diff --git a/editor/src/messages/tool/common_functionality/shape_editor.rs b/editor/src/messages/tool/common_functionality/shape_editor.rs index 42a7742d1d..2c6e1a5132 100644 --- a/editor/src/messages/tool/common_functionality/shape_editor.rs +++ b/editor/src/messages/tool/common_functionality/shape_editor.rs @@ -107,11 +107,7 @@ impl SelectedLayerState { } pub fn selected_points_count(&self) -> usize { - let count = self.selected_points.iter().fold(0, |acc, point| { - let is_ignored = (point.as_handle().is_some() && self.ignore_handles) || (point.as_anchor().is_some() && self.ignore_anchors); - acc + if is_ignored { 0 } else { 1 } - }); - count + self.selected_points.len() } } diff --git a/editor/src/messages/tool/common_functionality/utility_functions.rs b/editor/src/messages/tool/common_functionality/utility_functions.rs index 5e3d11c436..370c3e74fd 100644 --- a/editor/src/messages/tool/common_functionality/utility_functions.rs +++ b/editor/src/messages/tool/common_functionality/utility_functions.rs @@ -2,6 +2,7 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils::get_text; use crate::messages::tool::tool_messages::path_tool::PathOverlayMode; +use bezier_rs::Bezier; use glam::DVec2; use graphene_core::renderer::Quad; use graphene_core::text::{FontCache, load_face}; @@ -196,3 +197,99 @@ pub fn is_visible_point( } } } + +/// Calculates similarity metric between new bezier curve and two old beziers by using sampled points. +#[allow(clippy::too_many_arguments)] +pub fn log_optimization(a: f64, b: f64, p1: DVec2, p3: DVec2, d1: DVec2, d2: DVec2, points1: &[DVec2], n: usize) -> f64 { + let start_handle_length = a.exp(); + let end_handle_length = b.exp(); + + // Compute the handle positions of new bezier curve + let c1 = p1 + d1 * start_handle_length; + let c2 = p3 + d2 * end_handle_length; + + let new_curve = Bezier::from_cubic_coordinates(p1.x, p1.y, c1.x, c1.y, c2.x, c2.y, p3.x, p3.y); + + // Sample 2*n points from new curve and get the L2 metric between all of points + let points = new_curve.compute_lookup_table(Some(2 * n), None).collect::>(); + + let dist = points1.iter().zip(points.iter()).map(|(p1, p2)| (p1.x - p2.x).powi(2) + (p1.y - p2.y).powi(2)).sum::(); + + dist / (2 * n) as f64 +} + +/// Calculates optimal handle lengths with adam optimization. +#[allow(clippy::too_many_arguments)] +pub fn find_two_param_best_approximate(p1: DVec2, p3: DVec2, d1: DVec2, d2: DVec2, min_len1: f64, min_len2: f64, farther_segment: Bezier, other_segment: Bezier) -> (DVec2, DVec2) { + let h = 1e-6; + let tol = 1e-6; + let max_iter = 200; + + let mut a = (5_f64).ln(); + let mut b = (5_f64).ln(); + + let mut m_a = 0.; + let mut v_a = 0.; + let mut m_b = 0.; + let mut v_b = 0.; + + let initial_alpha = 0.05; + let decay_rate: f64 = 0.99; + + let beta1 = 0.9; + let beta2 = 0.999; + let epsilon = 1e-8; + + let n = 20; + + let farther_segment = if farther_segment.start.distance(p1) >= f64::EPSILON { + farther_segment.reverse() + } else { + farther_segment + }; + + let other_segment = if other_segment.end.distance(p3) >= f64::EPSILON { other_segment.reverse() } else { other_segment }; + + // Now we sample points proportional to the lengths of the beziers + let l1 = farther_segment.length(None); + let l2 = other_segment.length(None); + let ratio = l1 / (l1 + l2); + let n_points1 = ((2 * n) as f64 * ratio).floor() as usize; + let mut points1 = farther_segment.compute_lookup_table(Some(n_points1), None).collect::>(); + let mut points2 = other_segment.compute_lookup_table(Some(n), None).collect::>(); + points1.append(&mut points2); + + let f = |a: f64, b: f64| -> f64 { log_optimization(a, b, p1, p3, d1, d2, &points1, n) }; + + for t in 1..=max_iter { + let dfa = (f(a + h, b) - f(a - h, b)) / (2. * h); + let dfb = (f(a, b + h) - f(a, b - h)) / (2. * h); + + m_a = beta1 * m_a + (1. - beta1) * dfa; + m_b = beta1 * m_b + (1. - beta1) * dfb; + + v_a = beta2 * v_a + (1. - beta2) * dfa * dfa; + v_b = beta2 * v_b + (1. - beta2) * dfb * dfb; + + let m_a_hat = m_a / (1. - beta1.powi(t)); + let v_a_hat = v_a / (1. - beta2.powi(t)); + let m_b_hat = m_b / (1. - beta1.powi(t)); + let v_b_hat = v_b / (1. - beta2.powi(t)); + + let alpha_t = initial_alpha * decay_rate.powi(t); + + // Update log-lengths + a -= alpha_t * m_a_hat / (v_a_hat.sqrt() + epsilon); + b -= alpha_t * m_b_hat / (v_b_hat.sqrt() + epsilon); + + // Convergence check + if dfa.abs() < tol && dfb.abs() < tol { + break; + } + } + + let len1 = a.exp().max(min_len1); + let len2 = b.exp().max(min_len2); + + (d1 * len1, d2 * len2) +} diff --git a/editor/src/messages/tool/tool_messages/path_tool.rs b/editor/src/messages/tool/tool_messages/path_tool.rs index 10d17ef64a..a25b32b4ae 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -15,7 +15,8 @@ use crate::messages::tool::common_functionality::shape_editor::{ ClosestSegment, ManipulatorAngle, OpposingHandleLengths, SelectedPointsInfo, SelectionChange, SelectionShape, SelectionShapeType, ShapeState, }; use crate::messages::tool::common_functionality::snapping::{SnapCache, SnapCandidatePoint, SnapConstraint, SnapData, SnapManager}; -use crate::messages::tool::common_functionality::utility_functions::calculate_segment_angle; +use crate::messages::tool::common_functionality::utility_functions::{calculate_segment_angle, find_two_param_best_approximate}; +use bezier_rs::{Bezier, TValue}; use graphene_core::renderer::Quad; use graphene_core::vector::{ManipulatorPointId, PointId, VectorModificationType}; use graphene_std::vector::{HandleId, NoHashBuilder, SegmentId, VectorData}; @@ -308,6 +309,12 @@ impl<'a> MessageHandler> for PathToo Escape, RightClick, ), + PathToolFsmState::SlidingPoint => actions!(PathToolMessageDiscriminant; + PointerMove, + DragStop, + Escape, + RightClick + ), PathToolFsmState::MoldingSegment => actions!(PathToolMessageDiscriminant; PointerMove, DragStop, @@ -342,6 +349,20 @@ pub enum PointSelectState { Anchor, } +#[derive(Clone, Copy)] +pub struct SlidingSegmentData { + segment_id: SegmentId, + bezier: Bezier, + start: PointId, +} + +#[derive(Clone, Copy)] +pub struct SlidingPointInfo { + anchor: PointId, + layer: LayerNodeIdentifier, + connected_segments: [SlidingSegmentData; 2], +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum PathToolFsmState { #[default] @@ -350,6 +371,7 @@ enum PathToolFsmState { Drawing { selection_shape: SelectionShapeType, }, + SlidingPoint, MoldingSegment, } @@ -393,6 +415,7 @@ struct PathToolData { temporary_adjacent_handles_while_molding: Option<[Option; 2]>, frontier_handles_info: Option>>, adjacent_anchor_offset: Option, + sliding_point_info: Option, } impl PathToolData { @@ -907,12 +930,156 @@ impl PathToolData { tangent_vector.try_normalize() } + fn start_sliding_point(&mut self, shape_editor: &mut ShapeState, document: &DocumentMessageHandler) -> bool { + let single_anchor_selected = shape_editor.selected_points().count() == 1 && shape_editor.selected_points().any(|point| matches!(point, ManipulatorPointId::Anchor(_))); + + if single_anchor_selected { + let Some(anchor) = shape_editor.selected_points().next() else { return false }; + let Some(layer) = document.network_interface.selected_nodes().selected_layers(document.metadata()).next() else { + return false; + }; + let Some(vector_data) = document.network_interface.compute_modified_vector(layer) else { + return false; + }; + + // Check that the handles of anchor point are also colinear + if !vector_data.colinear(*anchor) { + return false; + }; + + let Some(point_id) = anchor.as_anchor() else { return false }; + + let mut connected_segments = [None, None]; + for (segment, bezier, start, end) in vector_data.segment_bezier_iter() { + if start == point_id || end == point_id { + match (connected_segments[0], connected_segments[1]) { + (None, None) => connected_segments[0] = Some(SlidingSegmentData { segment_id: segment, bezier, start }), + (Some(_), None) => connected_segments[1] = Some(SlidingSegmentData { segment_id: segment, bezier, start }), + _ => { + warn!("more than two segments connected to the anchor point"); + return false; + } + } + } + } + let connected_segments = if let [Some(seg1), Some(seg2)] = connected_segments { + [seg1, seg2] + } else { + warn!("expected exactly two connected segments"); + return false; + }; + + self.sliding_point_info = Some(SlidingPointInfo { + anchor: point_id, + layer, + connected_segments, + }); + return true; + } + false + } + + fn slide_point(&mut self, target_position: DVec2, responses: &mut VecDeque, network_interface: &NodeNetworkInterface, shape_editor: &ShapeState) { + let Some(sliding_point_info) = self.sliding_point_info else { return }; + let anchor = sliding_point_info.anchor; + let layer = sliding_point_info.layer; + + let Some(vector_data) = network_interface.compute_modified_vector(layer) else { return }; + let transform = network_interface.document_metadata().transform_to_viewport(layer); + let layer_pos = transform.inverse().transform_point2(target_position); + + let segments = sliding_point_info.connected_segments; + + let t1 = segments[0].bezier.project(layer_pos); + let position1 = segments[0].bezier.evaluate(TValue::Parametric(t1)); + + let t2 = segments[1].bezier.project(layer_pos); + let position2 = segments[1].bezier.evaluate(TValue::Parametric(t2)); + + let (closer_segment, farther_segment, t_value, new_position) = if position2.distance(layer_pos) < position1.distance(layer_pos) { + (segments[1], segments[0], t2, position2) + } else { + (segments[0], segments[1], t1, position1) + }; + + // Move the anchor to the new position + let Some(current_position) = ManipulatorPointId::Anchor(anchor).get_position(&vector_data) else { + return; + }; + let delta = new_position - current_position; + + shape_editor.move_anchor(anchor, &vector_data, delta, layer, None, responses); + + // Make a split at the t_value + let [first, second] = closer_segment.bezier.split(TValue::Parametric(t_value)); + let closer_segment_other_point = if anchor == closer_segment.start { closer_segment.bezier.end } else { closer_segment.bezier.start }; + + let (split_segment, other_segment) = if first.start == closer_segment_other_point { (first, second) } else { (second, first) }; + + // Primary handle maps to primary handle and secondary maps to secondary + let closer_primary_handle = HandleId::primary(closer_segment.segment_id); + let Some(handle_position) = split_segment.handle_start() else { return }; + let relative_position1 = handle_position - split_segment.start; + let modification_type = closer_primary_handle.set_relative_position(relative_position1); + responses.add(GraphOperationMessage::Vector { layer, modification_type }); + + let closer_secondary_handle = HandleId::end(closer_segment.segment_id); + let Some(handle_position) = split_segment.handle_end() else { return }; + let relative_position2 = handle_position - split_segment.end; + let modification_type = closer_secondary_handle.set_relative_position(relative_position2); + responses.add(GraphOperationMessage::Vector { layer, modification_type }); + + let end_handle_direction = if anchor == closer_segment.start { -relative_position1 } else { -relative_position2 }; + + let (farther_other_point, start_handle, end_handle, start_handle_pos) = if anchor == farther_segment.start { + ( + farther_segment.bezier.end, + HandleId::end(farther_segment.segment_id), + HandleId::primary(farther_segment.segment_id), + farther_segment.bezier.handle_end(), + ) + } else { + ( + farther_segment.bezier.start, + HandleId::primary(farther_segment.segment_id), + HandleId::end(farther_segment.segment_id), + farther_segment.bezier.handle_start(), + ) + }; + let Some(start_handle_position) = start_handle_pos else { return }; + let start_handle_direction = start_handle_position - farther_other_point; + + // Get normalized direction vectors, if cubic handle is zero then we consider corresponding tangent + let d1 = start_handle_direction.try_normalize().unwrap_or({ + if anchor == farther_segment.start { + -farther_segment.bezier.tangent(TValue::Parametric(0.99)) + } else { + farther_segment.bezier.tangent(TValue::Parametric(0.01)) + } + }); + + let d2 = end_handle_direction.try_normalize().unwrap_or_default(); + + let min_len1 = start_handle_direction.length() * 0.4; + let min_len2 = end_handle_direction.length() * 0.4; + + let (relative_pos1, relative_pos2) = find_two_param_best_approximate(farther_other_point, new_position, d1, d2, min_len1, min_len2, farther_segment.bezier, other_segment); + + // Now set those handles to these handle lengths keeping the directions d1, d2 + let modification_type = start_handle.set_relative_position(relative_pos1); + responses.add(GraphOperationMessage::Vector { layer, modification_type }); + + let modification_type = end_handle.set_relative_position(relative_pos2); + responses.add(GraphOperationMessage::Vector { layer, modification_type }); + } + #[allow(clippy::too_many_arguments)] fn drag( &mut self, equidistant: bool, lock_angle: bool, snap_angle: bool, + snap_axis: bool, shape_editor: &mut ShapeState, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, @@ -925,9 +1092,10 @@ impl PathToolData { .selected_points() .any(|point| matches!(point, ManipulatorPointId::EndHandle(_) | ManipulatorPointId::PrimaryHandle(_))); - if snap_angle && self.snapping_axis.is_none() && !single_handle_selected { + // This is where it starts snapping along axis + if snap_axis && self.snapping_axis.is_none() && !single_handle_selected { self.start_snap_along_axis(shape_editor, document, input, responses); - } else if !snap_angle && self.snapping_axis.is_some() { + } else if !snap_axis && self.snapping_axis.is_some() { self.stop_snap_along_axis(shape_editor, document, input, responses); } @@ -1037,7 +1205,8 @@ impl PathToolData { self.previous_mouse_position += document_to_viewport.inverse().transform_vector2(unsnapped_delta); } - if snap_angle && self.snapping_axis.is_some() { + // Constantly checking and changing the snapping axis based on current mouse position + if snap_axis && self.snapping_axis.is_some() { let Some(current_axis) = self.snapping_axis else { return }; let total_delta = self.drag_start_pos - input.mouse.position; @@ -1234,6 +1403,7 @@ impl Fsm for PathToolFsmState { } } } + Self::SlidingPoint => {} Self::MoldingSegment => {} } @@ -1380,10 +1550,15 @@ impl Fsm for PathToolFsmState { } if !tool_data.update_colinear(equidistant_state, toggle_colinear_state, tool_action_data.shape_editor, tool_action_data.document, responses) { + if snap_angle_state && lock_angle_state && tool_data.start_sliding_point(tool_action_data.shape_editor, tool_action_data.document) { + return PathToolFsmState::SlidingPoint; + } + tool_data.drag( equidistant_state, lock_angle_state, snap_angle_state, + snap_angle_state, tool_action_data.shape_editor, tool_action_data.document, input, @@ -1418,6 +1593,10 @@ impl Fsm for PathToolFsmState { PathToolFsmState::Dragging(tool_data.dragging_state) } + (PathToolFsmState::SlidingPoint, PathToolMessage::PointerMove { .. }) => { + tool_data.slide_point(input.mouse.position, responses, &document.network_interface, shape_editor); + PathToolFsmState::SlidingPoint + } (PathToolFsmState::MoldingSegment, PathToolMessage::PointerMove { break_colinear_molding, .. }) => { if tool_data.drag_start_pos.distance(input.mouse.position) > DRAG_THRESHOLD { tool_data.molding_segment = true; @@ -1570,6 +1749,14 @@ impl Fsm for PathToolFsmState { tool_data.snap_manager.cleanup(responses); PathToolFsmState::Ready } + (PathToolFsmState::SlidingPoint, PathToolMessage::Escape | PathToolMessage::RightClick) => { + tool_data.sliding_point_info = None; + + responses.add(DocumentMessage::AbortTransaction); + tool_data.snap_manager.cleanup(responses); + + PathToolFsmState::Ready + } (PathToolFsmState::MoldingSegment, PathToolMessage::Escape | PathToolMessage::RightClick) => { // Undo the molding and go back to the state before tool_data.molding_info = None; @@ -1627,7 +1814,7 @@ impl Fsm for PathToolFsmState { (_, PathToolMessage::DragStop { extend_selection, .. }) => { let extend_selection = input.keyboard.get(extend_selection as usize); let drag_occurred = tool_data.drag_start_pos.distance(input.mouse.position) > DRAG_THRESHOLD; - // TODO: Here we want only visible points to be considered + let nearest_point = shape_editor.find_nearest_visible_point_indices( &document.network_interface, input.mouse.position, @@ -1713,9 +1900,8 @@ impl Fsm for PathToolFsmState { shape_editor.deselect_all_points(); } - if tool_data.snapping_axis.is_some() { - tool_data.snapping_axis = None; - } + tool_data.snapping_axis = None; + tool_data.sliding_point_info = None; responses.add(DocumentMessage::EndTransaction); responses.add(PathToolMessage::SelectedPointUpdated); @@ -2065,32 +2251,65 @@ fn calculate_adjacent_anchor_tangent( } } -fn update_dynamic_hints(state: PathToolFsmState, responses: &mut VecDeque, _shape_editor: &mut ShapeState, document: &DocumentMessageHandler, tool_data: &PathToolData) { +fn update_dynamic_hints(state: PathToolFsmState, responses: &mut VecDeque, shape_editor: &mut ShapeState, document: &DocumentMessageHandler, tool_data: &PathToolData) { // Condinting based on currently selected segment if it has any one g1 continuous handle let hint_data = match state { - PathToolFsmState::Ready => HintData(vec![ - HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Select Point"), HintInfo::keys([Key::Shift], "Extend").prepend_plus()]), - HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Select Area"), HintInfo::keys([Key::Control], "Lasso").prepend_plus()]), - HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Insert Point on Segment")]), - HintGroup(vec![HintInfo::keys_and_mouse([Key::Alt], MouseMotion::Lmb, "Delete Segment")]), - // TODO: Only show if at least one anchor is selected, and dynamically show either "Smooth" or "Sharp" based on the current state - HintGroup(vec![ - HintInfo::mouse(MouseMotion::LmbDouble, "Convert Anchor Point"), - HintInfo::keys_and_mouse([Key::Alt], MouseMotion::Lmb, "To Sharp"), - HintInfo::keys_and_mouse([Key::Alt], MouseMotion::LmbDrag, "To Smooth"), - ]), - // TODO: Only show the following hints if at least one point is selected - HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Drag Selected")]), - HintGroup(vec![HintInfo::multi_keys([[Key::KeyG], [Key::KeyR], [Key::KeyS]], "Grab/Rotate/Scale Selected")]), - HintGroup(vec![HintInfo::arrow_keys("Nudge Selected"), HintInfo::keys([Key::Shift], "10x").prepend_plus()]), - HintGroup(vec![ - HintInfo::keys([Key::Delete], "Delete Selected"), - // TODO: Only show the following hints if at least one anchor is selected - HintInfo::keys([Key::Accel], "No Dissolve").prepend_plus(), - HintInfo::keys([Key::Shift], "Cut Anchor").prepend_plus(), - ]), - ]), + PathToolFsmState::Ready => { + // Show point sliding hints only when there is an anchor with colinear handles selected + let single_anchor_selected = shape_editor.selected_points().count() == 1 && shape_editor.selected_points().any(|point| matches!(point, ManipulatorPointId::Anchor(_))); + let at_least_one_anchor_selected = shape_editor.selected_points().any(|point| matches!(point, ManipulatorPointId::Anchor(_))); + let at_least_one_point_selected = shape_editor.selected_points().count() >= 1; + + let single_colinear_anchor_selected = if single_anchor_selected { + let anchor = shape_editor.selected_points().next().unwrap(); + let layer = document.network_interface.selected_nodes().selected_layers(document.metadata()).next().unwrap(); + let vector_data = document.network_interface.compute_modified_vector(layer).unwrap(); + vector_data.colinear(*anchor) + } else { + false + }; + + let mut drag_selected_hints = vec![HintInfo::mouse(MouseMotion::LmbDrag, "Drag Selected")]; + let mut delete_selected_hints = vec![HintInfo::keys([Key::Delete], "Delete Selected")]; + + if at_least_one_anchor_selected { + delete_selected_hints.push(HintInfo::keys([Key::Accel], "No Dissolve").prepend_plus()); + delete_selected_hints.push(HintInfo::keys([Key::Shift], "Cut Anchor").prepend_plus()); + } + + if single_colinear_anchor_selected { + drag_selected_hints.push(HintInfo::multi_keys([[Key::Control], [Key::Shift]], "Slide").prepend_plus()); + } + + let mut hint_data = vec![ + HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Select Point"), HintInfo::keys([Key::Shift], "Extend").prepend_plus()]), + HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Select Area"), HintInfo::keys([Key::Control], "Lasso").prepend_plus()]), + HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Insert Point on Segment")]), + HintGroup(vec![HintInfo::keys_and_mouse([Key::Alt], MouseMotion::Lmb, "Delete Segment")]), + ]; + + if at_least_one_anchor_selected { + // TODO: Dynamically show either "Smooth" or "Sharp" based on the current state + hint_data.push(HintGroup(vec![ + HintInfo::mouse(MouseMotion::LmbDouble, "Convert Anchor Point"), + HintInfo::keys_and_mouse([Key::Alt], MouseMotion::Lmb, "To Sharp"), + HintInfo::keys_and_mouse([Key::Alt], MouseMotion::LmbDrag, "To Smooth"), + ])); + } + + if at_least_one_point_selected { + let mut groups = vec![ + HintGroup(drag_selected_hints), + HintGroup(vec![HintInfo::multi_keys([[Key::KeyG], [Key::KeyR], [Key::KeyS]], "Grab/Rotate/Scale Selected")]), + HintGroup(vec![HintInfo::arrow_keys("Nudge Selected"), HintInfo::keys([Key::Shift], "10x").prepend_plus()]), + HintGroup(delete_selected_hints), + ]; + hint_data.append(&mut groups); + } + + HintData(hint_data) + } PathToolFsmState::Dragging(dragging_state) => { let colinear = dragging_state.colinear; let mut dragging_hint_data = HintData(Vec::new()); @@ -2188,6 +2407,7 @@ fn update_dynamic_hints(state: PathToolFsmState, responses: &mut VecDeque HintData(vec![HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()])]), }; responses.add(FrontendMessage::UpdateInputHints { hint_data }); }