Add anchor sliding along adjacent segments in the Path tool (#2682)

* Improved comments

* Add point sliding with approximate t value

* Add similarity calculation

* Numerical approach to fit the curve

* Reliable point sliding for cubic segments

* Fix formatting and clean comments

* Fix cubic with one handle logic

* Cancel on right click and escape

* Two parameter optimization

* Esc/ Right click cancellation

* Code review

* Fix dynamic hints

* Revert selected_points_counts and fix comments

* Code review

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
Adesh Gupta 2025-06-15 02:10:40 +05:30 committed by Keavon Chambers
parent eda858b6e2
commit 781271782c
3 changed files with 349 additions and 36 deletions

View file

@ -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()
}
}

View file

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

View file

@ -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<ToolMessage, &mut ToolActionHandlerData<'a>> 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<HandleId>; 2]>,
frontier_handles_info: Option<HashMap<SegmentId, Vec<PointId>>>,
adjacent_anchor_offset: Option<DVec2>,
sliding_point_info: Option<SlidingPointInfo>,
}
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<Message>, 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<Message>, _shape_editor: &mut ShapeState, document: &DocumentMessageHandler, tool_data: &PathToolData) {
fn update_dynamic_hints(state: PathToolFsmState, responses: &mut VecDeque<Message>, 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<Messag
HintData(molding_hints)
}
PathToolFsmState::SlidingPoint => HintData(vec![HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()])]),
};
responses.add(FrontendMessage::UpdateInputHints { hint_data });
}