Skip to content

Add the re-fit on delete feature to the Path tool #2768

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions editor/src/messages/input_mapper/input_mappings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,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),
Expand Down
77 changes: 65 additions & 12 deletions editor/src/messages/tool/common_functionality/shape_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -1235,7 +1235,7 @@ impl ShapeState {
}
}

fn dissolve_anchor(anchor: PointId, responses: &mut VecDeque<Message>, layer: LayerNodeIdentifier, vector_data: &VectorData) -> Option<[(HandleId, PointId); 2]> {
fn dissolve_anchor(anchor: PointId, responses: &mut VecDeque<Message>, 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 });
Expand All @@ -1258,11 +1258,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<Bezier> { 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<Message>) {
pub fn delete_selected_points(&mut self, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>, refit: bool) {
for (&layer, state) in &mut self.selected_shape_state {
let mut missing_anchors = HashMap::new();
let mut deleted_anchors = HashSet::new();
Expand Down Expand Up @@ -1301,21 +1305,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;

// 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);
}
}

*handle = connected.into_iter().find(|(_, point)| !visited.contains(point));
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)
Expand All @@ -1326,7 +1355,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
Expand All @@ -1336,12 +1365,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 });
Expand Down
104 changes: 75 additions & 29 deletions editor/src/messages/tool/common_functionality/utility_functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();

Expand All @@ -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::<Vec<_>>();

let points = new_curve.compute_lookup_table(Some(optimization_samples), None).collect::<Vec<_>>();
let dist = points1.iter().zip(points.iter()).map(|(p1, p2)| (p1.x - p2.x).powi(2) + (p1.y - p2.y).powi(2)).sum::<f64>();

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::<Vec<_>>();
let points2 = other_segment.compute_lookup_table(Some(n_points2), None).collect::<Vec<_>>();

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;
Expand All @@ -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::<Vec<_>>();
let mut points2 = other_segment.compute_lookup_table(Some(n), None).collect::<Vec<_>>();
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);
Expand All @@ -582,9 +595,42 @@ pub fn find_two_param_best_approximate(p1: DVec2, p3: DVec2, d1: DVec2, d2: DVec
break;
}
}
(a, b)
}

let len1 = a.exp().max(min_len1);
let len2 = b.exp().max(min_len2);
pub fn find_refit_handle_lengths(p1: DVec2, p3: DVec2, beziers: Vec<Bezier>, d1: DVec2, d2: DVec2) -> [DVec2; 2] {
let points_per_bezier = OPTIMIZATION_SAMPLES / beziers.len();

(d1 * len1, d2 * len2)
let points = if points_per_bezier < 1 {
beziers.iter().map(|bezier| bezier.start()).collect::<Vec<_>>()
} else {
let mut points = Vec::new();
for bezier in &beziers {
let lookup = bezier.compute_lookup_table(Some(points_per_bezier), None).collect::<Vec<_>>();
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::<f64>();

dist / limit as f64
});

let len1 = a.exp();
let len2 = b.exp();

[d1 * len1, d2 * len2]
}
25 changes: 21 additions & 4 deletions editor/src/messages/tool/tool_messages/path_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ use bezier_rs::{Bezier, 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 {
Expand Down Expand Up @@ -48,6 +47,7 @@ pub enum PathToolMessage {
DeselectAllPoints,
Delete,
DeleteAndBreakPath,
DeleteAndRefit,
DragStop {
extend_selection: Key,
shrink_selection: Key,
Expand Down Expand Up @@ -328,6 +328,7 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionHandlerData<'a>> for PathToo
DeselectAllPoints,
BreakPath,
DeleteAndBreakPath,
DeleteAndRefit,
ClosePath,
PointerMove,
),
Expand All @@ -340,6 +341,7 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionHandlerData<'a>> for PathToo
Delete,
BreakPath,
DeleteAndBreakPath,
DeleteAndRefit,
SwapSelectedHandles,
),
PathToolFsmState::Drawing { .. } => actions!(PathToolMessageDiscriminant;
Expand All @@ -350,6 +352,7 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionHandlerData<'a>> for PathToo
Enter,
BreakPath,
DeleteAndBreakPath,
DeleteAndRefit,
Escape,
RightClick,
),
Expand Down Expand Up @@ -2058,18 +2061,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::FlipSmoothSharp) => {
Expand Down Expand Up @@ -2423,8 +2439,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 {
Expand Down
Loading