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 11 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
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_zero_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
47 changes: 31 additions & 16 deletions editor/src/messages/tool/common_functionality/shape_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -728,7 +728,7 @@ 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();
// 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
Expand Down Expand Up @@ -762,20 +762,35 @@ 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 = non_zero_handle.length(vector_data) * direction * -1.;
let modification_type = zero_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);
} 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) {
// To find the length of the new tangent we just take the distance to the anchor and divide by 3 (pretty arbitrary)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What changes with different values than 3? I don't see what part of this feature relies on an arbitrary constant.

let Some(length) = other_anchor.map(|position| (position - anchor_position).length() / 3.) else {
continue;
};
let new_position = handle_direction * length * 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 +1518,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_zero_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_zero_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_zero_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.0);
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