Add snap and lock angle modifiers for handle dragging to the Path tool (#2160)

* added snap and lock angle to path tool

* fixed breakage of `tab` and `space` functionality
- Previous implementation broke functionality of using Tab to swap the being-dragged handle to its opposing handle, Now fixed.
- Previous implementation broke functionality of using space to drag the manipulator group (anchor + handles) while dragging a handle, Now fixed.

* fixed the angle snapping and locking when used together
Now, if `shift` is used to snap to a 15° increment, then `ctrl` is used to preserve the angle, releasing the `shift` key will still preserve the angle.

* Fix snapping angle logic

* Improve transforms

* added functionality for `alt` key
Now, temporarily converts selected handles to colinear if they are not already colinear.

* Revert "added functionality for `alt` key"

This reverts commit f12ba6fdbf261291ced0e042a2dfe12cddd2f9e8.

* Code review

---------

Co-authored-by: hypercube <0hypercube@gmail.com>
Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
Ayush Chauhan 2025-01-01 01:18:14 +05:30 committed by GitHub
parent 606be8a74b
commit 39a7b76ade
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 166 additions and 15 deletions

View File

@ -62,6 +62,7 @@ pub const MANIPULATOR_GROUP_MARKER_SIZE: f64 = 6.;
pub const SELECTION_THRESHOLD: f64 = 10.; pub const SELECTION_THRESHOLD: f64 = 10.;
pub const HIDE_HANDLE_DISTANCE: f64 = 3.; pub const HIDE_HANDLE_DISTANCE: f64 = 3.;
pub const INSERT_POINT_ON_SEGMENT_TOO_FAR_DISTANCE: f64 = 50.; pub const INSERT_POINT_ON_SEGMENT_TOO_FAR_DISTANCE: f64 = 50.;
pub const HANDLE_ROTATE_SNAP_ANGLE: f64 = 15.;
// Pen tool // Pen tool
pub const CREATE_CURVE_THRESHOLD: f64 = 5.; pub const CREATE_CURVE_THRESHOLD: f64 = 5.;

View File

@ -206,8 +206,8 @@ pub fn input_mappings() -> Mapping {
// PathToolMessage // PathToolMessage
entry!(KeyDown(Delete); modifiers=[Accel], action_dispatch=PathToolMessage::DeleteAndBreakPath), entry!(KeyDown(Delete); modifiers=[Accel], action_dispatch=PathToolMessage::DeleteAndBreakPath),
entry!(KeyDown(Backspace); modifiers=[Accel], action_dispatch=PathToolMessage::DeleteAndBreakPath), entry!(KeyDown(Backspace); modifiers=[Accel], action_dispatch=PathToolMessage::DeleteAndBreakPath),
entry!(KeyDown(Delete); modifiers=[Accel, Shift], action_dispatch=PathToolMessage::BreakPath), entry!(KeyDown(Delete); modifiers=[Shift], action_dispatch=PathToolMessage::BreakPath),
entry!(KeyDown(Backspace); modifiers=[Accel, Shift], action_dispatch=PathToolMessage::BreakPath), entry!(KeyDown(Backspace); modifiers=[Shift], action_dispatch=PathToolMessage::BreakPath),
entry!(KeyDown(Tab); action_dispatch=PathToolMessage::SwapSelectedHandles), entry!(KeyDown(Tab); action_dispatch=PathToolMessage::SwapSelectedHandles),
entry!(KeyDown(MouseLeft); action_dispatch=PathToolMessage::MouseDown { direct_insert_without_sliding: Control, extend_selection: Shift }), entry!(KeyDown(MouseLeft); action_dispatch=PathToolMessage::MouseDown { direct_insert_without_sliding: Control, extend_selection: Shift }),
entry!(KeyDown(MouseRight); action_dispatch=PathToolMessage::RightClick), entry!(KeyDown(MouseRight); action_dispatch=PathToolMessage::RightClick),
@ -215,7 +215,7 @@ pub fn input_mappings() -> Mapping {
entry!(KeyDown(KeyG); action_dispatch=PathToolMessage::GRS { key: KeyG }), entry!(KeyDown(KeyG); action_dispatch=PathToolMessage::GRS { key: KeyG }),
entry!(KeyDown(KeyR); action_dispatch=PathToolMessage::GRS { key: KeyR }), entry!(KeyDown(KeyR); action_dispatch=PathToolMessage::GRS { key: KeyR }),
entry!(KeyDown(KeyS); action_dispatch=PathToolMessage::GRS { key: KeyS }), entry!(KeyDown(KeyS); action_dispatch=PathToolMessage::GRS { key: KeyS }),
entry!(PointerMove; refresh_keys=[KeyC, Shift, Alt, Space], action_dispatch=PathToolMessage::PointerMove { toggle_colinear: KeyC, equidistant: Alt, move_anchor_with_handles: Space}), entry!(PointerMove; refresh_keys=[KeyC, Space, Control, Shift, Alt], action_dispatch=PathToolMessage::PointerMove { toggle_colinear: KeyC, equidistant: Alt, move_anchor_with_handles: Space, snap_angle: Shift, lock_angle: Control }),
entry!(KeyDown(Delete); action_dispatch=PathToolMessage::Delete), entry!(KeyDown(Delete); action_dispatch=PathToolMessage::Delete),
entry!(KeyDown(KeyA); modifiers=[Accel], action_dispatch=PathToolMessage::SelectAllAnchors), entry!(KeyDown(KeyA); modifiers=[Accel], action_dispatch=PathToolMessage::SelectAllAnchors),
entry!(KeyDown(KeyA); modifiers=[Accel, Shift], action_dispatch=PathToolMessage::DeselectAllPoints), entry!(KeyDown(KeyA); modifiers=[Accel, Shift], action_dispatch=PathToolMessage::DeselectAllPoints),

View File

@ -1,12 +1,12 @@
use super::tool_prelude::*; use super::tool_prelude::*;
use crate::consts::{COLOR_OVERLAY_YELLOW, DRAG_THRESHOLD, INSERT_POINT_ON_SEGMENT_TOO_FAR_DISTANCE, SELECTION_THRESHOLD, SELECTION_TOLERANCE}; use crate::consts::{COLOR_OVERLAY_YELLOW, DRAG_THRESHOLD, HANDLE_ROTATE_SNAP_ANGLE, INSERT_POINT_ON_SEGMENT_TOO_FAR_DISTANCE, SELECTION_THRESHOLD, SELECTION_TOLERANCE};
use crate::messages::portfolio::document::overlays::utility_functions::path_overlays; use crate::messages::portfolio::document::overlays::utility_functions::path_overlays;
use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
use crate::messages::tool::common_functionality::auto_panning::AutoPanning; use crate::messages::tool::common_functionality::auto_panning::AutoPanning;
use crate::messages::tool::common_functionality::shape_editor::{ClosestSegment, ManipulatorAngle, OpposingHandleLengths, SelectedPointsInfo, ShapeState}; use crate::messages::tool::common_functionality::shape_editor::{ClosestSegment, ManipulatorAngle, OpposingHandleLengths, SelectedPointsInfo, ShapeState};
use crate::messages::tool::common_functionality::snapping::{SnapCache, SnapCandidatePoint, SnapData, SnapManager}; use crate::messages::tool::common_functionality::snapping::{SnapCache, SnapCandidatePoint, SnapConstraint, SnapData, SnapManager};
use graphene_core::renderer::Quad; use graphene_core::renderer::Quad;
use graphene_core::vector::ManipulatorPointId; use graphene_core::vector::ManipulatorPointId;
@ -59,11 +59,15 @@ pub enum PathToolMessage {
equidistant: Key, equidistant: Key,
toggle_colinear: Key, toggle_colinear: Key,
move_anchor_with_handles: Key, move_anchor_with_handles: Key,
snap_angle: Key,
lock_angle: Key,
}, },
PointerOutsideViewport { PointerOutsideViewport {
equidistant: Key, equidistant: Key,
toggle_colinear: Key, toggle_colinear: Key,
move_anchor_with_handles: Key, move_anchor_with_handles: Key,
snap_angle: Key,
lock_angle: Key,
}, },
RightClick, RightClick,
SelectAllAnchors, SelectAllAnchors,
@ -294,6 +298,7 @@ struct PathToolData {
saved_points_before_anchor_select_toggle: Vec<ManipulatorPointId>, saved_points_before_anchor_select_toggle: Vec<ManipulatorPointId>,
select_anchor_toggled: bool, select_anchor_toggled: bool,
dragging_state: DraggingState, dragging_state: DraggingState,
angle: f64,
} }
impl PathToolData { impl PathToolData {
@ -466,13 +471,114 @@ impl PathToolData {
false false
} }
fn drag(&mut self, equidistant: bool, shape_editor: &mut ShapeState, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque<Message>) { /// Attempts to get a single selected handle. Also retrieves the position of the anchor it is connected to. Used for the purpose of snapping the angle.
// Move the selected points with the mouse fn try_get_selected_handle_and_anchor(&self, shape_editor: &ShapeState, document: &DocumentMessageHandler) -> Option<(DVec2, DVec2)> {
let previous_mouse = document.metadata().document_to_viewport.transform_point2(self.previous_mouse_position); // Only count selections of a single layer
let snapped_delta = shape_editor.snap(&mut self.snap_manager, &self.snap_cache, document, input, previous_mouse); let (layer, selection) = shape_editor.selected_shape_state.iter().next()?;
// Do not allow selections of multiple points to count
if selection.selected_points_count() != 1 {
return None;
}
// Only count selected handles
let selected_handle = selection.selected().next()?.as_handle()?;
let layer_to_document = document.metadata().transform_to_document(*layer);
let vector_data = document.network_interface.compute_modified_vector(*layer)?;
let handle_position_local = selected_handle.to_manipulator_point().get_position(&vector_data)?;
let anchor_id = selected_handle.to_manipulator_point().get_anchor(&vector_data)?;
let anchor_position_local = vector_data.point_domain.position_from_id(anchor_id)?;
let handle_position_document = layer_to_document.transform_point2(handle_position_local);
let anchor_position_document = layer_to_document.transform_point2(anchor_position_local);
Some((handle_position_document, anchor_position_document))
}
fn calculate_handle_angle(&mut self, handle_vector: DVec2, lock_angle: bool, snap_angle: bool) -> f64 {
let mut handle_angle = -handle_vector.angle_to(DVec2::X);
// When the angle is locked we use the old angle
if lock_angle {
handle_angle = self.angle
}
// Round the angle to the closest increment
if snap_angle {
let snap_resolution = HANDLE_ROTATE_SNAP_ANGLE.to_radians();
handle_angle = (handle_angle / snap_resolution).round() * snap_resolution;
}
// Cache the old handle angle for the lock angle.
self.angle = handle_angle;
handle_angle
}
fn apply_snapping(
&mut self,
handle_direction: DVec2,
new_handle_position: DVec2,
anchor_position: DVec2,
using_angle_constraints: bool,
handle_position: DVec2,
document: &DocumentMessageHandler,
input: &InputPreprocessorMessageHandler,
) -> DVec2 {
let snap_data = SnapData::new(document, input);
let snap_point = SnapCandidatePoint::handle_neighbors(new_handle_position, [anchor_position]);
let snap_result = match using_angle_constraints {
true => {
let snap_constraint = SnapConstraint::Line {
origin: anchor_position,
direction: handle_direction.normalize_or_zero(),
};
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()),
};
self.snap_manager.update_indicator(snap_result.clone());
document.metadata().document_to_viewport.transform_vector2(snap_result.snapped_point_document - handle_position)
}
fn drag(
&mut self,
equidistant: bool,
lock_angle: bool,
snap_angle: bool,
shape_editor: &mut ShapeState,
document: &DocumentMessageHandler,
input: &InputPreprocessorMessageHandler,
responses: &mut VecDeque<Message>,
) {
let document_to_viewport = document.metadata().document_to_viewport;
let previous_mouse = document_to_viewport.transform_point2(self.previous_mouse_position);
let current_mouse = input.mouse.position;
let raw_delta = document_to_viewport.inverse().transform_vector2(current_mouse - previous_mouse);
let snapped_delta = if let Some((handle_pos, anchor_pos)) = self.try_get_selected_handle_and_anchor(shape_editor, document) {
let cursor_pos = handle_pos + raw_delta;
let handle_angle = self.calculate_handle_angle(cursor_pos - anchor_pos, lock_angle, snap_angle);
let constrained_direction = DVec2::new(handle_angle.cos(), handle_angle.sin());
let projected_length = (cursor_pos - anchor_pos).dot(constrained_direction);
let constrained_target = anchor_pos + constrained_direction * projected_length;
let constrained_delta = constrained_target - handle_pos;
self.apply_snapping(constrained_direction, handle_pos + constrained_delta, anchor_pos, lock_angle || snap_angle, handle_pos, document, input)
} else {
shape_editor.snap(&mut self.snap_manager, &self.snap_cache, document, input, previous_mouse)
};
let handle_lengths = if equidistant { None } else { self.opposing_handle_lengths.take() }; let handle_lengths = if equidistant { None } else { self.opposing_handle_lengths.take() };
shape_editor.move_selected_points(handle_lengths, document, snapped_delta, equidistant, responses, true); shape_editor.move_selected_points(handle_lengths, document, snapped_delta, equidistant, responses, true);
self.previous_mouse_position += document.metadata().document_to_viewport.inverse().transform_vector2(snapped_delta); self.previous_mouse_position += document_to_viewport.inverse().transform_vector2(snapped_delta);
} }
} }
@ -574,6 +680,8 @@ impl Fsm for PathToolFsmState {
equidistant, equidistant,
toggle_colinear, toggle_colinear,
move_anchor_with_handles, move_anchor_with_handles,
snap_angle,
lock_angle,
}, },
) => { ) => {
tool_data.previous_mouse_position = input.mouse.position; tool_data.previous_mouse_position = input.mouse.position;
@ -585,12 +693,16 @@ impl Fsm for PathToolFsmState {
equidistant, equidistant,
toggle_colinear, toggle_colinear,
move_anchor_with_handles, move_anchor_with_handles,
snap_angle,
lock_angle,
} }
.into(), .into(),
PathToolMessage::PointerMove { PathToolMessage::PointerMove {
equidistant, equidistant,
toggle_colinear, toggle_colinear,
move_anchor_with_handles, move_anchor_with_handles,
snap_angle,
lock_angle,
} }
.into(), .into(),
]; ];
@ -604,6 +716,8 @@ impl Fsm for PathToolFsmState {
equidistant, equidistant,
toggle_colinear, toggle_colinear,
move_anchor_with_handles, move_anchor_with_handles,
snap_angle,
lock_angle,
}, },
) => { ) => {
if tool_data.selection_status.is_none() { if tool_data.selection_status.is_none() {
@ -631,8 +745,19 @@ impl Fsm for PathToolFsmState {
let toggle_colinear_state = input.keyboard.get(toggle_colinear as usize); let toggle_colinear_state = input.keyboard.get(toggle_colinear as usize);
let equidistant_state = input.keyboard.get(equidistant as usize); let equidistant_state = input.keyboard.get(equidistant as usize);
if !tool_data.update_colinear(equidistant_state, toggle_colinear_state, shape_editor, document, responses) { let lock_angle_state = input.keyboard.get(lock_angle as usize);
tool_data.drag(equidistant_state, shape_editor, document, input, responses); let snap_angle_state = input.keyboard.get(snap_angle as usize);
if !tool_data.update_colinear(equidistant_state, toggle_colinear_state, tool_action_data.shape_editor, tool_action_data.document, responses) {
tool_data.drag(
equidistant_state,
lock_angle_state,
snap_angle_state,
tool_action_data.shape_editor,
tool_action_data.document,
input,
responses,
);
} }
// Auto-panning // Auto-panning
@ -641,12 +766,16 @@ impl Fsm for PathToolFsmState {
toggle_colinear, toggle_colinear,
equidistant, equidistant,
move_anchor_with_handles, move_anchor_with_handles,
snap_angle,
lock_angle,
} }
.into(), .into(),
PathToolMessage::PointerMove { PathToolMessage::PointerMove {
toggle_colinear, toggle_colinear,
equidistant, equidistant,
move_anchor_with_handles, move_anchor_with_handles,
snap_angle,
lock_angle,
} }
.into(), .into(),
]; ];
@ -662,11 +791,19 @@ impl Fsm for PathToolFsmState {
PathToolFsmState::DrawingBox PathToolFsmState::DrawingBox
} }
(PathToolFsmState::Dragging(dragging_state), PathToolMessage::PointerOutsideViewport { equidistant, .. }) => { (
PathToolFsmState::Dragging(dragging_state),
PathToolMessage::PointerOutsideViewport {
equidistant, snap_angle, lock_angle, ..
},
) => {
// Auto-panning // Auto-panning
if tool_data.auto_panning.shift_viewport(input, responses).is_some() { if tool_data.auto_panning.shift_viewport(input, responses).is_some() {
let equidistant = input.keyboard.get(equidistant as usize); let equidistant = input.keyboard.get(equidistant as usize);
tool_data.drag(equidistant, shape_editor, document, input, responses); let snap_angle = input.keyboard.get(snap_angle as usize);
let lock_angle = input.keyboard.get(lock_angle as usize);
tool_data.drag(equidistant, lock_angle, snap_angle, shape_editor, document, input, responses);
} }
PathToolFsmState::Dragging(dragging_state) PathToolFsmState::Dragging(dragging_state)
@ -677,6 +814,8 @@ impl Fsm for PathToolFsmState {
equidistant, equidistant,
toggle_colinear, toggle_colinear,
move_anchor_with_handles, move_anchor_with_handles,
snap_angle,
lock_angle,
}, },
) => { ) => {
// Auto-panning // Auto-panning
@ -685,12 +824,16 @@ impl Fsm for PathToolFsmState {
equidistant, equidistant,
toggle_colinear, toggle_colinear,
move_anchor_with_handles, move_anchor_with_handles,
snap_angle,
lock_angle,
} }
.into(), .into(),
PathToolMessage::PointerMove { PathToolMessage::PointerMove {
equidistant, equidistant,
toggle_colinear, toggle_colinear,
move_anchor_with_handles, move_anchor_with_handles,
snap_angle,
lock_angle,
} }
.into(), .into(),
]; ];
@ -890,7 +1033,12 @@ impl Fsm for PathToolFsmState {
let drag_anchor = HintInfo::keys([Key::Space], "Drag Anchor"); let drag_anchor = HintInfo::keys([Key::Space], "Drag Anchor");
let point_select_state_hint_group = match dragging_state.point_select_state { let point_select_state_hint_group = match dragging_state.point_select_state {
PointSelectState::HandleNoPair => vec![drag_anchor], PointSelectState::HandleNoPair => {
let mut hints = vec![drag_anchor];
hints.push(HintInfo::keys([Key::Shift], "Snap 15°"));
hints.push(HintInfo::keys([Key::Control], "Lock Angle"));
hints
}
PointSelectState::HandleWithPair => { PointSelectState::HandleWithPair => {
let mut hints = vec![drag_anchor]; let mut hints = vec![drag_anchor];
hints.push(HintInfo::keys([Key::Tab], "Swap Selected Handles")); hints.push(HintInfo::keys([Key::Tab], "Swap Selected Handles"));
@ -905,6 +1053,8 @@ impl Fsm for PathToolFsmState {
if colinear != ManipulatorAngle::Free { if colinear != ManipulatorAngle::Free {
hints.push(HintInfo::keys([Key::Alt], "Equidistant Handles")); hints.push(HintInfo::keys([Key::Alt], "Equidistant Handles"));
} }
hints.push(HintInfo::keys([Key::Shift], "Snap 15°"));
hints.push(HintInfo::keys([Key::Control], "Lock Angle"));
hints hints
} }
PointSelectState::Anchor => Vec::new(), PointSelectState::Anchor => Vec::new(),