Add Path tool support for Ctrl-dragging to pull out zero-length handles with angle locking (#2620)

* implement check-drag and angle-lock

* track bool

* flip-smooth-sharp

* fixed bugs

* fixed flip-smooth jump bug and random angle locking bug

* ctrl-alt 90 case

* aligned flip-smooth sharp and fixed arbitrary handle-length when flipped

* code-review change

* 0.5 instead of 0.8

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
0SlowPoke0 2025-05-18 01:53:11 +05:30 committed by GitHub
parent 54b4ef145c
commit 6e7f218068
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 114 additions and 35 deletions

View File

@ -102,6 +102,7 @@ pub const HIDE_HANDLE_DISTANCE: f64 = 3.;
pub const HANDLE_ROTATE_SNAP_ANGLE: f64 = 15.; pub const HANDLE_ROTATE_SNAP_ANGLE: f64 = 15.;
pub const SEGMENT_INSERTION_DISTANCE: f64 = 7.5; pub const SEGMENT_INSERTION_DISTANCE: f64 = 7.5;
pub const SEGMENT_OVERLAY_SIZE: f64 = 10.; pub const SEGMENT_OVERLAY_SIZE: f64 = 10.;
pub const HANDLE_LENGTH_FACTOR: f64 = 0.5;
// PEN TOOL // PEN TOOL
pub const CREATE_CURVE_THRESHOLD: f64 = 5.; pub const CREATE_CURVE_THRESHOLD: f64 = 5.;

View File

@ -212,7 +212,7 @@ pub fn input_mappings() -> Mapping {
entry!(KeyDown(Delete); modifiers=[Shift], action_dispatch=PathToolMessage::BreakPath), entry!(KeyDown(Delete); modifiers=[Shift], action_dispatch=PathToolMessage::BreakPath),
entry!(KeyDown(Backspace); modifiers=[Shift], action_dispatch=PathToolMessage::BreakPath), entry!(KeyDown(Backspace); modifiers=[Shift], action_dispatch=PathToolMessage::BreakPath),
entry!(KeyDownNoRepeat(Tab); action_dispatch=PathToolMessage::SwapSelectedHandles), 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 }), entry!(KeyDown(MouseLeft); action_dispatch=PathToolMessage::MouseDown { extend_selection: Shift, lasso_select: Control, handle_drag_from_anchor: Alt, drag_restore_handle: Control }),
entry!(KeyDown(MouseRight); action_dispatch=PathToolMessage::RightClick), entry!(KeyDown(MouseRight); action_dispatch=PathToolMessage::RightClick),
entry!(KeyDown(Escape); action_dispatch=PathToolMessage::Escape), entry!(KeyDown(Escape); action_dispatch=PathToolMessage::Escape),
entry!(KeyDown(KeyG); action_dispatch=PathToolMessage::GRS { key: KeyG }), entry!(KeyDown(KeyG); action_dispatch=PathToolMessage::GRS { key: KeyG }),

View File

@ -1,5 +1,7 @@
use super::graph_modification_utils::{self, merge_layers}; use super::graph_modification_utils::{self, merge_layers};
use super::snapping::{SnapCache, SnapCandidatePoint, SnapData, SnapManager, SnappedPoint}; use super::snapping::{SnapCache, SnapCandidatePoint, SnapData, SnapManager, SnappedPoint};
use super::utility_functions::calculate_segment_angle;
use crate::consts::HANDLE_LENGTH_FACTOR;
use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier}; use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier};
use crate::messages::portfolio::document::utility_types::misc::{PathSnapSource, SnapSource}; use crate::messages::portfolio::document::utility_types::misc::{PathSnapSource, SnapSource};
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
@ -728,6 +730,8 @@ impl ShapeState {
return; return;
}; };
let handles = vector_data.all_connected(point_id).take(2).collect::<Vec<_>>(); let handles = vector_data.all_connected(point_id).take(2).collect::<Vec<_>>();
let non_zero_handles = handles.iter().filter(|handle| handle.length(vector_data) > 1e-6).count();
let handle_segments = handles.iter().map(|handles| handles.segment).collect::<Vec<_>>();
// Grab the next and previous manipulator groups by simply looking at the next / previous index // Grab the next and previous manipulator groups by simply looking at the next / previous index
let points = handles.iter().map(|handle| vector_data.other_point(handle.segment, point_id)); let points = handles.iter().map(|handle| vector_data.other_point(handle.segment, point_id));
@ -735,16 +739,24 @@ impl ShapeState {
.map(|point| point.and_then(|point| ManipulatorPointId::Anchor(point).get_position(vector_data))) .map(|point| point.and_then(|point| ManipulatorPointId::Anchor(point).get_position(vector_data)))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
// Use the position relative to the anchor let mut segment_angle = 0.;
let mut directions = anchor_positions let mut segment_count = 0.;
.iter()
.map(|position| position.map(|position| (position - anchor_position)).and_then(DVec2::try_normalize));
// The direction of the handles is either the perpendicular vector to the sum of the anchors' positions or just the anchor's position (if only one) for segment in &handle_segments {
let mut handle_direction = match (directions.next().flatten(), directions.next().flatten()) { let Some(angle) = calculate_segment_angle(point_id, *segment, vector_data, false) else {
(Some(previous), Some(next)) => (previous - next).try_normalize().unwrap_or(next.perp()), continue;
(Some(val), None) | (None, Some(val)) => val, };
(None, None) => return, segment_angle += angle;
segment_count += 1.;
}
// For a non-endpoint anchor, handles are perpendicular to the average tangent of adjacent segments.(Refer:https://github.com/GraphiteEditor/Graphite/pull/2620#issuecomment-2881501494)
let mut handle_direction = if segment_count > 1. {
segment_angle = segment_angle / segment_count;
segment_angle += std::f64::consts::FRAC_PI_2;
DVec2::new(segment_angle.cos(), segment_angle.sin())
} else {
DVec2::new(segment_angle.cos(), segment_angle.sin())
}; };
// Set the manipulator to have colinear handles // Set the manipulator to have colinear handles
@ -762,20 +774,41 @@ impl ShapeState {
handle_direction *= -1.; handle_direction *= -1.;
} }
// Push both in and out handles into the correct position if non_zero_handles != 0 {
for ((handle, sign), other_anchor) in handles.iter().zip([1., -1.]).zip(&anchor_positions) { let [a, b] = handles.as_slice() else { return };
// To find the length of the new tangent we just take the distance to the anchor and divide by 3 (pretty arbitrary) let (non_zero_handle, zero_handle) = if a.length(vector_data) > 1e-6 { (a, b) } else { (b, a) };
let Some(length) = other_anchor.map(|position| (position - anchor_position).length() / 3.) else { let Some(direction) = non_zero_handle
continue; .to_manipulator_point()
.get_position(&vector_data)
.and_then(|position| (position - anchor_position).try_normalize())
else {
return;
}; };
let new_position = handle_direction * length * sign; let new_position = -direction * non_zero_handle.length(vector_data);
let modification_type = handle.set_relative_position(new_position); let modification_type = zero_handle.set_relative_position(new_position);
responses.add(GraphOperationMessage::Vector { layer, modification_type }); responses.add(GraphOperationMessage::Vector { layer, modification_type });
} else {
// Push both in and out handles into the correct position
for ((handle, sign), other_anchor) in handles.iter().zip([1., -1.]).zip(&anchor_positions) {
let Some(anchor_vector) = other_anchor.map(|position| (position - anchor_position)) else {
continue;
};
// Create the opposite handle if it doesn't exist (if it is not a cubic segment) let Some(unit_vector) = anchor_vector.try_normalize() else {
if handle.opposite().to_manipulator_point().get_position(vector_data).is_none() { continue;
let modification_type = handle.opposite().set_relative_position(DVec2::ZERO); };
let projection = anchor_vector.length() * HANDLE_LENGTH_FACTOR * handle_direction.dot(unit_vector).abs();
let new_position = handle_direction * projection * sign;
let modification_type = handle.set_relative_position(new_position);
responses.add(GraphOperationMessage::Vector { layer, modification_type }); responses.add(GraphOperationMessage::Vector { layer, modification_type });
// Create the opposite handle if it doesn't exist (if it is not a cubic segment)
if handle.opposite().to_manipulator_point().get_position(vector_data).is_none() {
let modification_type = handle.opposite().set_relative_position(DVec2::ZERO);
responses.add(GraphOperationMessage::Vector { layer, modification_type });
}
} }
} }
} }
@ -1503,13 +1536,13 @@ impl ShapeState {
let (id, anchor) = result?; let (id, anchor) = result?;
let handles = vector_data.all_connected(id); let handles = vector_data.all_connected(id);
let mut positions = handles let positions = handles
.filter_map(|handle| handle.to_manipulator_point().get_position(&vector_data)) .filter_map(|handle| handle.to_manipulator_point().get_position(&vector_data))
.filter(|&handle| !anchor.abs_diff_eq(handle, 1e-5)); .filter(|&handle| anchor.abs_diff_eq(handle, 1e-5))
.count();
// Check by comparing the handle positions to the anchor if this manipulator group is a point // Check by comparing the handle positions to the anchor if this manipulator group is a point
let already_sharp = positions.next().is_none(); if positions != 0 {
if already_sharp {
self.convert_manipulator_handles_to_colinear(&vector_data, id, responses, layer); self.convert_manipulator_handles_to_colinear(&vector_data, id, responses, layer);
} else { } else {
for handle in vector_data.all_connected(id) { for handle in vector_data.all_connected(id) {

View File

@ -67,6 +67,7 @@ pub enum PathToolMessage {
extend_selection: Key, extend_selection: Key,
lasso_select: Key, lasso_select: Key,
handle_drag_from_anchor: Key, handle_drag_from_anchor: Key,
drag_restore_handle: Key,
}, },
NudgeSelectedPoints { NudgeSelectedPoints {
delta_x: f64, delta_x: f64,
@ -362,7 +363,6 @@ struct PathToolData {
saved_points_before_handle_drag: Vec<ManipulatorPointId>, saved_points_before_handle_drag: Vec<ManipulatorPointId>,
handle_drag_toggle: bool, handle_drag_toggle: bool,
dragging_state: DraggingState, dragging_state: DraggingState,
current_selected_handle_id: Option<ManipulatorPointId>,
angle: f64, angle: f64,
opposite_handle_position: Option<DVec2>, opposite_handle_position: Option<DVec2>,
last_clicked_point_was_selected: bool, last_clicked_point_was_selected: bool,
@ -438,6 +438,7 @@ impl PathToolData {
extend_selection: bool, extend_selection: bool,
lasso_select: bool, lasso_select: bool,
handle_drag_from_anchor: bool, handle_drag_from_anchor: bool,
drag_zero_handle: bool,
) -> PathToolFsmState { ) -> PathToolFsmState {
self.double_click_handled = false; self.double_click_handled = false;
self.opposing_handle_lengths = None; self.opposing_handle_lengths = None;
@ -500,6 +501,24 @@ impl PathToolData {
} }
} }
if let Some((Some(point), Some(vector_data))) = shape_editor
.find_nearest_point_indices(&document.network_interface, input.mouse.position, SELECTION_THRESHOLD)
.and_then(|(layer, point)| Some((point.as_anchor(), document.network_interface.compute_modified_vector(layer))))
{
let handles = vector_data
.all_connected(point)
.filter(|handle| handle.length(&vector_data) < 1e-6)
.map(|handle| handle.to_manipulator_point())
.collect::<Vec<_>>();
let endpoint = vector_data.extendable_points(false).any(|anchor| point == anchor);
if drag_zero_handle && (handles.len() == 1 && !endpoint) {
shape_editor.deselect_all_points();
shape_editor.select_points_by_manipulator_id(&handles);
shape_editor.convert_selected_manipulators_to_colinear_handles(responses, document);
}
}
self.start_dragging_point(selected_points, input, document, shape_editor); self.start_dragging_point(selected_points, input, document, shape_editor);
responses.add(OverlaysMessage::Draw); responses.add(OverlaysMessage::Draw);
} }
@ -689,6 +708,7 @@ impl PathToolData {
handle_id: ManipulatorPointId, handle_id: ManipulatorPointId,
lock_angle: bool, lock_angle: bool,
snap_angle: bool, snap_angle: bool,
tangent_to_neighboring_tangents: bool,
) -> f64 { ) -> f64 {
let current_angle = -handle_vector.angle_to(DVec2::X); let current_angle = -handle_vector.angle_to(DVec2::X);
@ -699,17 +719,22 @@ impl PathToolData {
.and_then(|(layer, _)| document.network_interface.compute_modified_vector(*layer)) .and_then(|(layer, _)| document.network_interface.compute_modified_vector(*layer))
{ {
if relative_vector.length() < 25. && lock_angle && !self.angle_locked { if relative_vector.length() < 25. && lock_angle && !self.angle_locked {
if let Some(angle) = calculate_lock_angle(self, shape_editor, responses, document, &vector_data, handle_id) { if let Some(angle) = calculate_lock_angle(self, shape_editor, responses, document, &vector_data, handle_id, tangent_to_neighboring_tangents) {
self.angle = angle; self.angle = angle;
self.angle_locked = true;
return angle; return angle;
} }
} }
} }
// When the angle is locked we use the old angle if lock_angle && !self.angle_locked {
if self.current_selected_handle_id == Some(handle_id) && lock_angle {
self.angle_locked = true; self.angle_locked = true;
self.angle = -relative_vector.angle_to(DVec2::X);
return -relative_vector.angle_to(DVec2::X);
}
// When the angle is locked we use the old angle
if self.angle_locked {
return self.angle; return self.angle;
} }
@ -720,8 +745,6 @@ impl PathToolData {
handle_angle = (handle_angle / snap_resolution).round() * snap_resolution; handle_angle = (handle_angle / snap_resolution).round() * snap_resolution;
} }
// Cache the angle and handle id for lock angle
self.current_selected_handle_id = Some(handle_id);
self.angle = handle_angle; self.angle = handle_angle;
handle_angle handle_angle
@ -747,6 +770,7 @@ impl PathToolData {
origin: anchor_position, origin: anchor_position,
direction: handle_direction.normalize_or_zero(), direction: handle_direction.normalize_or_zero(),
}; };
self.snap_manager.constrained_snap(&snap_data, &snap_point, snap_constraint, Default::default()) self.snap_manager.constrained_snap(&snap_data, &snap_point, snap_constraint, Default::default())
} }
false => self.snap_manager.free_snap(&snap_data, &snap_point, Default::default()), false => self.snap_manager.free_snap(&snap_data, &snap_point, Default::default()),
@ -850,7 +874,17 @@ impl PathToolData {
let snapped_delta = if let Some((handle_pos, anchor_pos, handle_id)) = self.try_get_selected_handle_and_anchor(shape_editor, document) { let snapped_delta = if let Some((handle_pos, anchor_pos, handle_id)) = self.try_get_selected_handle_and_anchor(shape_editor, document) {
let cursor_pos = handle_pos + raw_delta; let cursor_pos = handle_pos + raw_delta;
let handle_angle = self.calculate_handle_angle(shape_editor, document, responses, handle_pos - anchor_pos, cursor_pos - anchor_pos, handle_id, lock_angle, snap_angle); let handle_angle = self.calculate_handle_angle(
shape_editor,
document,
responses,
handle_pos - anchor_pos,
cursor_pos - anchor_pos,
handle_id,
lock_angle,
snap_angle,
equidistant,
);
let constrained_direction = DVec2::new(handle_angle.cos(), handle_angle.sin()); let constrained_direction = DVec2::new(handle_angle.cos(), handle_angle.sin());
let projected_length = (cursor_pos - anchor_pos).dot(constrained_direction); let projected_length = (cursor_pos - anchor_pos).dot(constrained_direction);
@ -1109,17 +1143,18 @@ impl Fsm for PathToolFsmState {
extend_selection, extend_selection,
lasso_select, lasso_select,
handle_drag_from_anchor, handle_drag_from_anchor,
.. drag_restore_handle,
}, },
) => { ) => {
let extend_selection = input.keyboard.get(extend_selection as usize); let extend_selection = input.keyboard.get(extend_selection as usize);
let lasso_select = input.keyboard.get(lasso_select as usize); let lasso_select = input.keyboard.get(lasso_select as usize);
let handle_drag_from_anchor = input.keyboard.get(handle_drag_from_anchor as usize); let handle_drag_from_anchor = input.keyboard.get(handle_drag_from_anchor as usize);
let drag_zero_handle = input.keyboard.get(drag_restore_handle as usize);
tool_data.selection_mode = None; tool_data.selection_mode = None;
tool_data.lasso_polygon.clear(); tool_data.lasso_polygon.clear();
tool_data.mouse_down(shape_editor, document, input, responses, extend_selection, lasso_select, handle_drag_from_anchor) tool_data.mouse_down(shape_editor, document, input, responses, extend_selection, lasso_select, handle_drag_from_anchor, drag_zero_handle)
} }
( (
PathToolFsmState::Drawing { selection_shape }, PathToolFsmState::Drawing { selection_shape },
@ -1375,6 +1410,7 @@ impl Fsm for PathToolFsmState {
tool_data.saved_points_before_handle_drag.clear(); tool_data.saved_points_before_handle_drag.clear();
tool_data.handle_drag_toggle = false; tool_data.handle_drag_toggle = false;
} }
tool_data.angle_locked = false;
responses.add(DocumentMessage::AbortTransaction); responses.add(DocumentMessage::AbortTransaction);
tool_data.snap_manager.cleanup(responses); tool_data.snap_manager.cleanup(responses);
PathToolFsmState::Ready PathToolFsmState::Ready
@ -1443,6 +1479,7 @@ impl Fsm for PathToolFsmState {
tool_data.alt_dragging_from_anchor = false; tool_data.alt_dragging_from_anchor = false;
tool_data.alt_clicked_on_anchor = false; tool_data.alt_clicked_on_anchor = false;
tool_data.angle_locked = false;
if tool_data.select_anchor_toggled { if tool_data.select_anchor_toggled {
shape_editor.deselect_all_points(); shape_editor.deselect_all_points();
@ -1775,6 +1812,7 @@ fn calculate_lock_angle(
document: &DocumentMessageHandler, document: &DocumentMessageHandler,
vector_data: &VectorData, vector_data: &VectorData,
handle_id: ManipulatorPointId, handle_id: ManipulatorPointId,
tangent_to_neighboring_tangents: bool,
) -> Option<f64> { ) -> Option<f64> {
let anchor = handle_id.get_anchor(vector_data)?; let anchor = handle_id.get_anchor(vector_data)?;
let anchor_position = vector_data.point_domain.position_from_id(anchor); let anchor_position = vector_data.point_domain.position_from_id(anchor);
@ -1808,7 +1846,14 @@ fn calculate_lock_angle(
let angle_2 = calculate_segment_angle(anchor, segment, vector_data, false); let angle_2 = calculate_segment_angle(anchor, segment, vector_data, false);
match (angle_1, angle_2) { match (angle_1, angle_2) {
(Some(angle_1), Some(angle_2)) => Some((angle_1 + angle_2) / 2.0), (Some(angle_1), Some(angle_2)) => {
let angle = Some((angle_1 + angle_2) / 2.);
if tangent_to_neighboring_tangents {
angle.map(|angle| angle + std::f64::consts::FRAC_PI_2)
} else {
angle
}
}
(Some(angle_1), None) => Some(angle_1), (Some(angle_1), None) => Some(angle_1),
(None, Some(angle_2)) => Some(angle_2), (None, Some(angle_2)) => Some(angle_2),
(None, None) => None, (None, None) => None,