Skip to content

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

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

Merged
merged 14 commits into from
May 17, 2025
Merged
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
1 change: 1 addition & 0 deletions editor/src/consts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ pub const HIDE_HANDLE_DISTANCE: f64 = 3.;
pub const HANDLE_ROTATE_SNAP_ANGLE: f64 = 15.;
pub const SEGMENT_INSERTION_DISTANCE: f64 = 7.5;
pub const SEGMENT_OVERLAY_SIZE: f64 = 10.;
pub const HANDLE_LENGTH_FACTOR: f64 = 0.5;

// PEN TOOL
pub const CREATE_CURVE_THRESHOLD: f64 = 5.;
Expand Down
2 changes: 1 addition & 1 deletion editor/src/messages/input_mapper/input_mappings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ pub fn input_mappings() -> Mapping {
entry!(KeyDown(Delete); modifiers=[Shift], action_dispatch=PathToolMessage::BreakPath),
entry!(KeyDown(Backspace); modifiers=[Shift], action_dispatch=PathToolMessage::BreakPath),
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(Escape); action_dispatch=PathToolMessage::Escape),
entry!(KeyDown(KeyG); action_dispatch=PathToolMessage::GRS { key: KeyG }),
Expand Down
79 changes: 56 additions & 23 deletions editor/src/messages/tool/common_functionality/shape_editor.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use super::graph_modification_utils::{self, merge_layers};
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::misc::{PathSnapSource, SnapSource};
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
Expand Down Expand Up @@ -728,23 +730,33 @@ impl ShapeState {
return;
};
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
let points = handles.iter().map(|handle| vector_data.other_point(handle.segment, point_id));
let anchor_positions = points
.map(|point| point.and_then(|point| ManipulatorPointId::Anchor(point).get_position(vector_data)))
.collect::<Vec<_>>();

// Use the position relative to the anchor
let mut directions = anchor_positions
.iter()
.map(|position| position.map(|position| (position - anchor_position)).and_then(DVec2::try_normalize));
let mut segment_angle = 0.;
let mut segment_count = 0.;

for segment in &handle_segments {
let Some(angle) = calculate_segment_angle(point_id, *segment, vector_data, false) else {
continue;
};
segment_angle += angle;
segment_count += 1.;
}

// 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)
let mut handle_direction = match (directions.next().flatten(), directions.next().flatten()) {
(Some(previous), Some(next)) => (previous - next).try_normalize().unwrap_or(next.perp()),
(Some(val), None) | (None, Some(val)) => val,
(None, None) => return,
// 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
Expand All @@ -762,20 +774,41 @@ impl ShapeState {
handle_direction *= -1.;
}

// Push both in and out handles into the correct position
for ((handle, sign), other_anchor) in handles.iter().zip([1., -1.]).zip(&anchor_positions) {
// To find the length of the new tangent we just take the distance to the anchor and divide by 3 (pretty arbitrary)
let Some(length) = other_anchor.map(|position| (position - anchor_position).length() / 3.) else {
continue;
if non_zero_handles != 0 {
let [a, b] = handles.as_slice() else { return };
let (non_zero_handle, zero_handle) = if a.length(vector_data) > 1e-6 { (a, b) } else { (b, a) };
let Some(direction) = non_zero_handle
.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 modification_type = handle.set_relative_position(new_position);
let new_position = -direction * non_zero_handle.length(vector_data);
let modification_type = zero_handle.set_relative_position(new_position);
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;
};

let Some(unit_vector) = anchor_vector.try_normalize() else {
continue;
};

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

// 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 });
}
}
}
}
Expand Down Expand Up @@ -1503,13 +1536,13 @@ impl ShapeState {

let (id, anchor) = result?;
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(|&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
let already_sharp = positions.next().is_none();
if already_sharp {
if positions != 0 {
self.convert_manipulator_handles_to_colinear(&vector_data, id, responses, layer);
} else {
for handle in vector_data.all_connected(id) {
Expand Down
67 changes: 56 additions & 11 deletions editor/src/messages/tool/tool_messages/path_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ pub enum PathToolMessage {
extend_selection: Key,
lasso_select: Key,
handle_drag_from_anchor: Key,
drag_restore_handle: Key,
},
NudgeSelectedPoints {
delta_x: f64,
Expand Down Expand Up @@ -362,7 +363,6 @@ struct PathToolData {
saved_points_before_handle_drag: Vec<ManipulatorPointId>,
handle_drag_toggle: bool,
dragging_state: DraggingState,
current_selected_handle_id: Option<ManipulatorPointId>,
angle: f64,
opposite_handle_position: Option<DVec2>,
last_clicked_point_was_selected: bool,
Expand Down Expand Up @@ -438,6 +438,7 @@ impl PathToolData {
extend_selection: bool,
lasso_select: bool,
handle_drag_from_anchor: bool,
drag_zero_handle: bool,
) -> PathToolFsmState {
self.double_click_handled = false;
self.opposing_handle_lengths = None;
Expand Down Expand Up @@ -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);
responses.add(OverlaysMessage::Draw);
}
Expand Down Expand Up @@ -689,6 +708,7 @@ impl PathToolData {
handle_id: ManipulatorPointId,
lock_angle: bool,
snap_angle: bool,
tangent_to_neighboring_tangents: bool,
) -> f64 {
let current_angle = -handle_vector.angle_to(DVec2::X);

Expand All @@ -699,17 +719,22 @@ impl PathToolData {
.and_then(|(layer, _)| document.network_interface.compute_modified_vector(*layer))
{
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_locked = true;
return angle;
}
}
}

// When the angle is locked we use the old angle

if self.current_selected_handle_id == Some(handle_id) && lock_angle {
if lock_angle && !self.angle_locked {
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;
}

Expand All @@ -720,8 +745,6 @@ impl PathToolData {
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;

handle_angle
Expand All @@ -747,6 +770,7 @@ impl PathToolData {
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()),
Expand Down Expand Up @@ -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 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 projected_length = (cursor_pos - anchor_pos).dot(constrained_direction);
Expand Down Expand Up @@ -1109,17 +1143,18 @@ impl Fsm for PathToolFsmState {
extend_selection,
lasso_select,
handle_drag_from_anchor,
..
drag_restore_handle,
},
) => {
let extend_selection = input.keyboard.get(extend_selection 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 drag_zero_handle = input.keyboard.get(drag_restore_handle as usize);

tool_data.selection_mode = None;
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 },
Expand Down Expand Up @@ -1375,6 +1410,7 @@ impl Fsm for PathToolFsmState {
tool_data.saved_points_before_handle_drag.clear();
tool_data.handle_drag_toggle = false;
}
tool_data.angle_locked = false;
responses.add(DocumentMessage::AbortTransaction);
tool_data.snap_manager.cleanup(responses);
PathToolFsmState::Ready
Expand Down Expand Up @@ -1443,6 +1479,7 @@ impl Fsm for PathToolFsmState {

tool_data.alt_dragging_from_anchor = false;
tool_data.alt_clicked_on_anchor = false;
tool_data.angle_locked = false;

if tool_data.select_anchor_toggled {
shape_editor.deselect_all_points();
Expand Down Expand Up @@ -1775,6 +1812,7 @@ fn calculate_lock_angle(
document: &DocumentMessageHandler,
vector_data: &VectorData,
handle_id: ManipulatorPointId,
tangent_to_neighboring_tangents: bool,
) -> Option<f64> {
let anchor = handle_id.get_anchor(vector_data)?;
let anchor_position = vector_data.point_domain.position_from_id(anchor);
Expand Down Expand Up @@ -1808,7 +1846,14 @@ fn calculate_lock_angle(
let angle_2 = calculate_segment_angle(anchor, segment, vector_data, false);

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),
(None, Some(angle_2)) => Some(angle_2),
(None, None) => None,
Expand Down
Loading