diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index ad3f42a3d..0b5395f07 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -208,8 +208,10 @@ pub fn input_mappings() -> Mapping { // PathToolMessage entry!(KeyDown(Delete); modifiers=[Accel], action_dispatch=PathToolMessage::DeleteAndBreakPath), entry!(KeyDown(Backspace); modifiers=[Accel], action_dispatch=PathToolMessage::DeleteAndBreakPath), - entry!(KeyDown(Delete); modifiers=[Shift], action_dispatch=PathToolMessage::BreakPath), - entry!(KeyDown(Backspace); modifiers=[Shift], action_dispatch=PathToolMessage::BreakPath), + entry!(KeyDown(Delete); modifiers=[Alt], action_dispatch=PathToolMessage::BreakPath), + entry!(KeyDown(Backspace); modifiers=[Alt], action_dispatch=PathToolMessage::BreakPath), + entry!(KeyDown(Delete); modifiers=[Shift], action_dispatch=PathToolMessage::DeleteAndRefit), + entry!(KeyDown(Backspace); modifiers=[Shift], action_dispatch=PathToolMessage::DeleteAndRefit), entry!(KeyDownNoRepeat(Tab); action_dispatch=PathToolMessage::SwapSelectedHandles), entry!(KeyDown(MouseLeft); action_dispatch=PathToolMessage::MouseDown { extend_selection: Shift, lasso_select: Control, handle_drag_from_anchor: Alt, drag_restore_handle: Control, molding_in_segment_edit: KeyA }), entry!(KeyDown(MouseRight); action_dispatch=PathToolMessage::RightClick), diff --git a/editor/src/messages/tool/common_functionality/shape_editor.rs b/editor/src/messages/tool/common_functionality/shape_editor.rs index 14ae8898a..59e5c3559 100644 --- a/editor/src/messages/tool/common_functionality/shape_editor.rs +++ b/editor/src/messages/tool/common_functionality/shape_editor.rs @@ -9,7 +9,7 @@ use crate::messages::portfolio::document::utility_types::network_interface::Node use crate::messages::preferences::SelectionMode; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::snapping::SnapTypeConfiguration; -use crate::messages::tool::common_functionality::utility_functions::{is_intersecting, is_visible_point}; +use crate::messages::tool::common_functionality::utility_functions::{find_refit_handle_lengths, is_intersecting, is_visible_point}; use crate::messages::tool::tool_messages::path_tool::{PathOverlayMode, PointSelectState}; use bezier_rs::{Bezier, BezierHandles, Subpath, TValue}; use glam::{DAffine2, DVec2}; @@ -1268,7 +1268,7 @@ impl ShapeState { } } - fn dissolve_anchor(anchor: PointId, responses: &mut VecDeque, layer: LayerNodeIdentifier, vector_data: &VectorData) -> Option<[(HandleId, PointId); 2]> { + fn dissolve_anchor(anchor: PointId, responses: &mut VecDeque, layer: LayerNodeIdentifier, vector_data: &VectorData) -> Option<[(HandleId, PointId, Bezier); 2]> { // Delete point let modification_type = VectorModificationType::RemovePoint { id: anchor }; responses.add(GraphOperationMessage::Vector { layer, modification_type }); @@ -1291,11 +1291,15 @@ impl ShapeState { let [Some(start), Some(end)] = opposites.map(|opposite| opposite.to_manipulator_point().get_anchor(vector_data)) else { return None; }; - Some([(handles[0], start), (handles[1], end)]) + + let get_bezier = |segment_id: SegmentId| -> Option { vector_data.segment_bezier_iter().find(|(id, _, _, _)| *id == segment_id).map(|(_, bezier, _, _)| bezier) }; + let beziers = opposites.map(|opposite| get_bezier(opposite.segment)); + + Some([(handles[0], start, beziers[0]?), (handles[1], end, beziers[1]?)]) } /// Dissolve the selected points. - pub fn delete_selected_points(&mut self, document: &DocumentMessageHandler, responses: &mut VecDeque) { + pub fn delete_selected_points(&mut self, document: &DocumentMessageHandler, responses: &mut VecDeque, refit: bool) { for (&layer, state) in &mut self.selected_shape_state { let mut missing_anchors = HashMap::new(); let mut deleted_anchors = HashSet::new(); @@ -1334,21 +1338,46 @@ impl ShapeState { } let mut visited = Vec::new(); - while let Some((anchor, handles)) = missing_anchors.keys().next().copied().and_then(|id| missing_anchors.remove_entry(&id)) { + while let Some((anchor, connected_info)) = missing_anchors.keys().next().copied().and_then(|id| missing_anchors.remove_entry(&id)) { visited.push(anchor); // If the adjacent point is just this point then skip - let mut handles = handles.map(|handle| (handle.1 != anchor).then_some(handle)); + let mut handles = connected_info.map(|handle| (handle.1 != anchor).then_some(handle)); // If the adjacent points are themselves being deleted, then repeatedly visit the newest agacent points. - for handle in &mut handles { - while let Some((point, connected)) = (*handle).and_then(|(_, point)| missing_anchors.remove_entry(&point)) { - visited.push(point); + let [handle1, handle2] = &mut handles; - *handle = connected.into_iter().find(|(_, point)| !visited.contains(point)); + // Store Beziers for fitting later + let mut beziers_start = Vec::new(); + let mut beziers_end = Vec::new(); + if let Some((_, _, bezier)) = *handle1 { + beziers_start.push(bezier); + } + while let Some((point, connected)) = (*handle1).and_then(|(_, point, _)| missing_anchors.remove_entry(&point)) { + visited.push(point); + + if let Some(new_handle) = connected.into_iter().find(|(_, point, _)| !visited.contains(point)) { + *handle1 = Some(new_handle); + beziers_start.push(new_handle.2); } } + if let Some((_, _, bezier)) = *handle2 { + beziers_end.push(bezier); + } + while let Some((point, connected)) = (*handle2).and_then(|(_, point, _)| missing_anchors.remove_entry(&point)) { + visited.push(point); + + if let Some(new_handle) = connected.into_iter().find(|(_, point, _)| !visited.contains(point)) { + *handle2 = Some(new_handle); + beziers_end.push(new_handle.2); + } + } + + beziers_start.reverse(); + let mut combined = beziers_start.clone(); + combined.extend(beziers_end); + let [Some(start), Some(end)] = handles else { continue }; // Avoid reconnecting to points that are being deleted (this can happen if a whole loop is deleted) @@ -1359,7 +1388,7 @@ impl ShapeState { // Avoid reconnecting to points which have adjacent segments selected // Grab the handles from the opposite side of the segment(s) being deleted and make it relative to the anchor - let [handle_start, handle_end] = [start, end].map(|(handle, _)| { + let [handle_start, handle_end] = [start, end].map(|(handle, _, _)| { let handle = handle.opposite(); let handle_position = handle.to_manipulator_point().get_position(&vector_data); let relative_position = handle @@ -1369,12 +1398,36 @@ impl ShapeState { handle_position.and_then(|handle| relative_position.map(|relative| handle - relative)).unwrap_or_default() }); + let [handle1, handle2] = if refit { + let handle_start_unit = handle_start.try_normalize().unwrap_or_default(); + let handle_end_unit = handle_end.try_normalize().unwrap_or_default(); + + let p1 = start + .0 + .opposite() + .to_manipulator_point() + .get_anchor(&vector_data) + .and_then(|anchor| vector_data.point_domain.position_from_id(anchor)) + .unwrap_or_default(); + + let p3 = end + .0 + .opposite() + .to_manipulator_point() + .get_anchor(&vector_data) + .and_then(|anchor| vector_data.point_domain.position_from_id(anchor)) + .unwrap_or_default(); + find_refit_handle_lengths(p1, p3, combined, handle_start_unit, handle_end_unit) + } else { + [handle_start, handle_end] + }; + let segment = start.0.segment; let modification_type = VectorModificationType::InsertSegment { id: segment, points: [start.1, end.1], - handles: [Some(handle_start), Some(handle_end)], + handles: [Some(handle1), Some(handle2)], }; responses.add(GraphOperationMessage::Vector { layer, modification_type }); diff --git a/editor/src/messages/tool/common_functionality/utility_functions.rs b/editor/src/messages/tool/common_functionality/utility_functions.rs index 23829271b..8c347ace6 100644 --- a/editor/src/messages/tool/common_functionality/utility_functions.rs +++ b/editor/src/messages/tool/common_functionality/utility_functions.rs @@ -15,6 +15,8 @@ use graphene_std::text::{FontCache, load_font}; use graphene_std::vector::{HandleExt, HandleId, ManipulatorPointId, PointId, SegmentId, VectorData, VectorModificationType}; use kurbo::{CubicBez, Line, ParamCurveExtrema, PathSeg, Point, QuadBez}; +const OPTIMIZATION_SAMPLES: usize = 40; + /// Determines if a path should be extended. Goal in viewport space. Returns the path and if it is extending from the start, if applicable. pub fn should_extend( document: &DocumentMessageHandler, @@ -495,7 +497,7 @@ pub fn transforming_transform_cage( /// 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 { +pub fn log_optimization(a: f64, b: f64, p1: DVec2, p3: DVec2, d1: DVec2, d2: DVec2, points1: &[DVec2], optimization_samples: usize) -> f64 { let start_handle_length = a.exp(); let end_handle_length = b.exp(); @@ -506,16 +508,48 @@ pub fn log_optimization(a: f64, b: f64, p1: DVec2, p3: DVec2, d1: DVec2, d2: DVe 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 points = new_curve.compute_lookup_table(Some(optimization_samples), 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 + dist / optimization_samples 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) { +pub fn find_two_param_best_approximate(p1: DVec2, p3: DVec2, d1: DVec2, d2: DVec2, min_len1: f64, min_len2: f64, further_segment: Bezier, other_segment: Bezier) -> (DVec2, DVec2) { + let further_segment = if further_segment.start.distance(p1) >= f64::EPSILON { + further_segment.reverse() + } else { + further_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 = further_segment.length(None); + let l2 = other_segment.length(None); + + let ratio = l1 / (l1 + l2); + + let n_points1 = (OPTIMIZATION_SAMPLES as f64 * ratio).floor() as usize; + let n_points2 = OPTIMIZATION_SAMPLES - n_points1; + + let mut points1 = further_segment.compute_lookup_table(Some(2), None).collect::>(); + let points2 = other_segment.compute_lookup_table(Some(n_points2), None).collect::>(); + + if points2.len() >= 2 { + points1.extend_from_slice(&points2[1..]); + } + + let (a, b) = adam_optimizer(|a: f64, b: f64| -> f64 { log_optimization(a, b, p1, p3, d1, d2, &points1, OPTIMIZATION_SAMPLES) }); + + let len1 = a.exp().max(min_len1); + let len2 = b.exp().max(min_len2); + + (d1 * len1, d2 * len2) +} + +pub fn adam_optimizer(f: impl Fn(f64, f64) -> f64) -> (f64, f64) { let h = 1e-6; let tol = 1e-6; let max_iter = 200; @@ -535,27 +569,6 @@ pub fn find_two_param_best_approximate(p1: DVec2, p3: DVec2, d1: DVec2, d2: DVec 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); @@ -582,9 +595,42 @@ pub fn find_two_param_best_approximate(p1: DVec2, p3: DVec2, d1: DVec2, d2: DVec break; } } - - let len1 = a.exp().max(min_len1); - let len2 = b.exp().max(min_len2); - - (d1 * len1, d2 * len2) + (a, b) +} + +pub fn find_refit_handle_lengths(p1: DVec2, p3: DVec2, beziers: Vec, d1: DVec2, d2: DVec2) -> [DVec2; 2] { + let points_per_bezier = OPTIMIZATION_SAMPLES / beziers.len(); + + let points = if points_per_bezier < 1 { + beziers.iter().map(|bezier| bezier.start()).collect::>() + } else { + let mut points = Vec::new(); + for bezier in &beziers { + let lookup = bezier.compute_lookup_table(Some(points_per_bezier), None).collect::>(); + points.extend_from_slice(&lookup[..lookup.len() - 1]); + } + points + }; + + let limit = points.len(); + + let (a, b) = adam_optimizer(|a: f64, b: f64| -> f64 { + let start_handle_len = a.exp(); + let end_handle_len = b.exp(); + + let c1 = p1 + d1 * start_handle_len; + let c2 = p3 + d2 * end_handle_len; + + let new_curve = Bezier::from_cubic_coordinates(p1.x, p1.y, c1.x, c1.y, c2.x, c2.y, p3.x, p3.y); + + let new_points = new_curve.compute_lookup_table(Some(limit), None); + let dist = points.iter().zip(new_points).map(|(p1, p2)| (p1.x - p2.x).powi(2) + (p1.y - p2.y).powi(2)).sum::(); + + dist / limit as f64 + }); + + let len1 = a.exp(); + let len2 = b.exp(); + + [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 dbce924fc..adf009f65 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -20,7 +20,6 @@ use bezier_rs::{Bezier, BezierHandles, TValue}; use graphene_std::renderer::Quad; use graphene_std::vector::{HandleExt, HandleId, NoHashBuilder, SegmentId, VectorData}; use graphene_std::vector::{ManipulatorPointId, PointId, VectorModificationType}; -use std::vec; #[derive(Default)] pub struct PathTool { @@ -48,6 +47,7 @@ pub enum PathToolMessage { DeselectAllPoints, Delete, DeleteAndBreakPath, + DeleteAndRefit, DragStop { extend_selection: Key, shrink_selection: Key, @@ -331,6 +331,7 @@ impl<'a> MessageHandler> for PathToo DeselectAllPoints, BreakPath, DeleteAndBreakPath, + DeleteAndRefit, ClosePath, PointerMove, ), @@ -343,6 +344,7 @@ impl<'a> MessageHandler> for PathToo Delete, BreakPath, DeleteAndBreakPath, + DeleteAndRefit, SwapSelectedHandles, ), PathToolFsmState::Drawing { .. } => actions!(PathToolMessageDiscriminant; @@ -353,6 +355,7 @@ impl<'a> MessageHandler> for PathToo Enter, BreakPath, DeleteAndBreakPath, + DeleteAndRefit, Escape, RightClick, ), @@ -2046,18 +2049,31 @@ impl Fsm for PathToolFsmState { (_, PathToolMessage::Delete) => { // Delete the selected points and clean up overlays responses.add(DocumentMessage::AddTransaction); + shape_editor.delete_selected_points(document, responses, false); shape_editor.delete_selected_segments(document, responses); - shape_editor.delete_selected_points(document, responses); responses.add(PathToolMessage::SelectionChanged); PathToolFsmState::Ready } (_, PathToolMessage::BreakPath) => { + responses.add(DocumentMessage::AddTransaction); shape_editor.break_path_at_selected_point(document, responses); + responses.add(PathToolMessage::SelectionChanged); + PathToolFsmState::Ready } (_, PathToolMessage::DeleteAndBreakPath) => { + responses.add(DocumentMessage::AddTransaction); shape_editor.delete_point_and_break_path(document, responses); + responses.add(PathToolMessage::SelectionChanged); + + PathToolFsmState::Ready + } + (_, PathToolMessage::DeleteAndRefit) => { + responses.add(DocumentMessage::AddTransaction); + shape_editor.delete_selected_points(document, responses, true); + responses.add(PathToolMessage::SelectionChanged); + PathToolFsmState::Ready } (_, PathToolMessage::DoubleClick { extend_selection, shrink_selection }) => { @@ -2468,8 +2484,9 @@ fn update_dynamic_hints( 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()); + delete_selected_hints.push(HintInfo::keys([Key::Accel], "With Segments").prepend_plus()); + delete_selected_hints.push(HintInfo::keys([Key::Shift], "Re-Fit").prepend_plus()); + delete_selected_hints.push(HintInfo::keys([Key::Alt], "Cut Anchor").prepend_plus()); } if single_colinear_anchor_selected {