Skip to content

Add Path tool feature for angle locking upon pressing Ctrl while dragging handle over anchor #2612

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 10 commits into from
Apr 24, 2025
10 changes: 9 additions & 1 deletion editor/src/messages/tool/common_functionality/shape_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,9 @@ impl ShapeState {
let length = transform.transform_vector2(unselected_position - anchor).length();
let position = transform.inverse().transform_vector2(direction * length);
let modification_type = unselected_handle.set_relative_position(position);
responses.add(GraphOperationMessage::Vector { layer, modification_type });
if (anchor - selected_position).length() > 1e-6 {
responses.add(GraphOperationMessage::Vector { layer, modification_type });
}
}
// If both handles are selected, average the angles of the handles
else {
Expand Down Expand Up @@ -775,6 +777,7 @@ impl ShapeState {
in_viewport_space: bool,
was_alt_dragging: bool,
opposite_handle_position: Option<DVec2>,
skip_opposite_handle: bool,
responses: &mut VecDeque<Message>,
) {
for (&layer, state) in &self.selected_shape_state {
Expand Down Expand Up @@ -816,6 +819,11 @@ impl ShapeState {
responses.add(GraphOperationMessage::Vector { layer, modification_type });

let Some(other) = vector_data.other_colinear_handle(handle) else { continue };

if skip_opposite_handle {
continue;
}

if state.is_selected(other.to_manipulator_point()) {
// If two colinear handles are being dragged at the same time but not the anchor, it is necessary to break the colinear state.
let handles = [handle, other];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::messages::tool::common_functionality::graph_modification_utils::get_t
use glam::DVec2;
use graphene_core::renderer::Quad;
use graphene_core::text::{FontCache, load_face};
use graphene_std::vector::PointId;
use graphene_std::vector::{ManipulatorPointId, PointId, SegmentId, VectorData};

/// Determines if a path should be extended. Goal in viewport space. Returns the path and if it is extending from the start, if applicable.
pub fn should_extend(
Expand Down Expand Up @@ -66,3 +66,30 @@ pub fn text_bounding_box(layer: LayerNodeIdentifier, document: &DocumentMessageH

Quad::from_box([DVec2::ZERO, far])
}

pub fn calculate_segment_angle(anchor: PointId, segment: SegmentId, vector_data: &VectorData, pen_tool: bool) -> Option<f64> {
let is_start = |point: PointId, segment: SegmentId| vector_data.segment_start_from_id(segment) == Some(point);
let anchor_position = vector_data.point_domain.position_from_id(anchor)?;
let end_handle = ManipulatorPointId::EndHandle(segment).get_position(vector_data);
let start_handle = ManipulatorPointId::PrimaryHandle(segment).get_position(vector_data);

let start_point = if is_start(anchor, segment) {
vector_data.segment_end_from_id(segment).and_then(|id| vector_data.point_domain.position_from_id(id))
} else {
vector_data.segment_start_from_id(segment).and_then(|id| vector_data.point_domain.position_from_id(id))
};

let required_handle = if is_start(anchor, segment) {
start_handle
.filter(|&handle| pen_tool && handle != anchor_position)
.or(end_handle.filter(|&handle| Some(handle) != start_point))
.or(start_point)
} else {
end_handle
.filter(|&handle| pen_tool && handle != anchor_position)
.or(start_handle.filter(|&handle| Some(handle) != start_point))
.or(start_point)
};

required_handle.map(|handle| -(handle - anchor_position).angle_to(DVec2::X))
}
111 changes: 103 additions & 8 deletions editor/src/messages/tool/tool_messages/path_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ 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 graphene_core::renderer::Quad;
use graphene_core::vector::{ManipulatorPointId, PointId, VectorModificationType};
use graphene_std::vector::{HandleId, NoHashBuilder, SegmentId, VectorData};
Expand Down Expand Up @@ -380,6 +381,8 @@ struct PathToolData {
snapping_axis: Option<Axis>,
alt_clicked_on_anchor: bool,
alt_dragging_from_anchor: bool,
angle_locked: bool,
temporary_colinear_handles: bool,
}

impl PathToolData {
Expand Down Expand Up @@ -727,11 +730,38 @@ impl PathToolData {
Some((handle_position_document, anchor_position_document, handle_id))
}

fn calculate_handle_angle(&mut self, handle_vector: DVec2, handle_id: ManipulatorPointId, lock_angle: bool, snap_angle: bool) -> f64 {
#[allow(clippy::too_many_arguments)]
fn calculate_handle_angle(
&mut self,
shape_editor: &mut ShapeState,
document: &DocumentMessageHandler,
responses: &mut VecDeque<Message>,
relative_vector: DVec2,
handle_vector: DVec2,
handle_id: ManipulatorPointId,
lock_angle: bool,
snap_angle: bool,
) -> f64 {
let current_angle = -handle_vector.angle_to(DVec2::X);

if let Some(vector_data) = shape_editor
.selected_shape_state
.iter()
.next()
.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) {
self.angle = angle;
return angle;
}
}
}

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

if self.current_selected_handle_id == Some(handle_id) && lock_angle {
self.angle_locked = true;
return self.angle;
}

Expand Down Expand Up @@ -785,7 +815,7 @@ impl PathToolData {
let drag_start = self.drag_start_pos;
let opposite_delta = drag_start - current_mouse;

shape_editor.move_selected_points(None, document, opposite_delta, false, true, false, None, responses);
shape_editor.move_selected_points(None, document, opposite_delta, false, true, false, None, false, responses);

// Calculate the projected delta and shift the points along that delta
let delta = current_mouse - drag_start;
Expand All @@ -797,7 +827,7 @@ impl PathToolData {
_ => DVec2::new(delta.x, 0.),
};

shape_editor.move_selected_points(None, document, projected_delta, false, true, false, None, responses);
shape_editor.move_selected_points(None, document, projected_delta, false, true, false, None, false, responses);
}

fn stop_snap_along_axis(&mut self, shape_editor: &mut ShapeState, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque<Message>) {
Expand All @@ -813,12 +843,12 @@ impl PathToolData {
_ => DVec2::new(opposite_delta.x, 0.),
};

shape_editor.move_selected_points(None, document, opposite_projected_delta, false, true, false, None, responses);
shape_editor.move_selected_points(None, document, opposite_projected_delta, false, true, false, None, false, responses);

// Calculate what actually would have been the original delta for the point, and apply that
let delta = current_mouse - drag_start;

shape_editor.move_selected_points(None, document, delta, false, true, false, None, responses);
shape_editor.move_selected_points(None, document, delta, false, true, false, None, false, responses);

self.snapping_axis = None;
}
Expand Down Expand Up @@ -872,7 +902,7 @@ 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(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);

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 @@ -931,7 +961,14 @@ impl PathToolData {
self.alt_dragging_from_anchor = false;
self.alt_clicked_on_anchor = false;
}
shape_editor.move_selected_points(handle_lengths, document, snapped_delta, equidistant, true, was_alt_dragging, opposite, responses);

let mut skip_opposite = false;
if self.temporary_colinear_handles && !lock_angle {
shape_editor.disable_colinear_handles_state_on_selected(&document.network_interface, responses);
self.temporary_colinear_handles = false;
skip_opposite = true;
}
shape_editor.move_selected_points(handle_lengths, document, snapped_delta, equidistant, true, was_alt_dragging, opposite, skip_opposite, responses);
self.previous_mouse_position += document_to_viewport.inverse().transform_vector2(snapped_delta);
} else {
let Some(axis) = self.snapping_axis else { return };
Expand All @@ -940,7 +977,7 @@ impl PathToolData {
Axis::Y => DVec2::new(0., unsnapped_delta.y),
_ => DVec2::new(unsnapped_delta.x, 0.),
};
shape_editor.move_selected_points(handle_lengths, document, projected_delta, equidistant, true, false, opposite, responses);
shape_editor.move_selected_points(handle_lengths, document, projected_delta, equidistant, true, false, opposite, false, responses);
self.previous_mouse_position += document_to_viewport.inverse().transform_vector2(unsnapped_delta);
}

Expand Down Expand Up @@ -1238,6 +1275,10 @@ impl Fsm for PathToolFsmState {
let lock_angle_state = input.keyboard.get(lock_angle as usize);
let snap_angle_state = input.keyboard.get(snap_angle as usize);

if !lock_angle_state {
tool_data.angle_locked = false;
}

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,
Expand Down Expand Up @@ -1412,6 +1453,10 @@ impl Fsm for PathToolFsmState {
}
}

if tool_data.temporary_colinear_handles {
tool_data.temporary_colinear_handles = false;
}

if tool_data.handle_drag_toggle && drag_occurred {
shape_editor.deselect_all_points();
shape_editor.select_points_by_manipulator_id(&tool_data.saved_points_before_handle_drag);
Expand Down Expand Up @@ -1511,6 +1556,7 @@ impl Fsm for PathToolFsmState {
false,
false,
tool_data.opposite_handle_position,
false,
responses,
);

Expand Down Expand Up @@ -1749,3 +1795,52 @@ fn get_selection_status(network_interface: &NodeNetworkInterface, shape_state: &

SelectionStatus::None
}

fn calculate_lock_angle(
tool_data: &mut PathToolData,
shape_state: &mut ShapeState,
responses: &mut VecDeque<Message>,
document: &DocumentMessageHandler,
vector_data: &VectorData,
handle_id: ManipulatorPointId,
) -> Option<f64> {
let anchor = handle_id.get_anchor(vector_data)?;
let anchor_position = vector_data.point_domain.position_from_id(anchor);
let current_segment = handle_id.get_segment();
let points_connected = vector_data.connected_count(anchor);

let (anchor_position, segment) = anchor_position.zip(current_segment)?;
if points_connected == 1 {
calculate_segment_angle(anchor, segment, vector_data, false)
} else {
let opposite_handle = handle_id
.get_handle_pair(vector_data)
.iter()
.flatten()
.find(|&h| h.to_manipulator_point() != handle_id)
.copied()
.map(|h| h.to_manipulator_point());
let opposite_handle_position = opposite_handle.and_then(|h| h.get_position(vector_data)).filter(|pos| (pos - anchor_position).length() > 1e-6);

if let Some(opposite_pos) = opposite_handle_position {
if !vector_data.colinear_manipulators.iter().flatten().map(|h| h.to_manipulator_point()).any(|h| h == handle_id) {
shape_state.convert_selected_manipulators_to_colinear_handles(responses, document);
tool_data.temporary_colinear_handles = true;
}
Some(-(opposite_pos - anchor_position).angle_to(DVec2::X))
} else {
let angle_1 = vector_data
.adjacent_segment(&handle_id)
.and_then(|(_, adjacent_segment)| calculate_segment_angle(anchor, adjacent_segment, vector_data, false));

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), None) => Some(angle_1),
(None, Some(angle_2)) => Some(angle_2),
(None, None) => None,
}
}
}
}
31 changes: 3 additions & 28 deletions editor/src/messages/tool/tool_messages/pen_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::messages::tool::common_functionality::color_selector::{ToolColorOptio
use crate::messages::tool::common_functionality::graph_modification_utils::{self, merge_layers};
use crate::messages::tool::common_functionality::shape_editor::ShapeState;
use crate::messages::tool::common_functionality::snapping::{SnapCache, SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnapTypeConfiguration};
use crate::messages::tool::common_functionality::utility_functions::{closest_point, should_extend};
use crate::messages::tool::common_functionality::utility_functions::{calculate_segment_angle, closest_point, should_extend};
use bezier_rs::{Bezier, BezierHandles};
use graph_craft::document::NodeId;
use graphene_core::Color;
Expand Down Expand Up @@ -1295,31 +1295,8 @@ impl PenToolData {

match (self.handle_type, self.path_closed) {
(TargetHandle::FuturePreviewOutHandle, _) | (TargetHandle::PreviewInHandle, true) => {
let is_start = |point: PointId, segment: SegmentId| vector_data.segment_start_from_id(segment) == Some(point);

let end_handle = ManipulatorPointId::EndHandle(segment).get_position(vector_data);
let start_handle = ManipulatorPointId::PrimaryHandle(segment).get_position(vector_data);

let start_point = if is_start(anchor, segment) {
vector_data.segment_end_from_id(segment).and_then(|id| vector_data.point_domain.position_from_id(id))
} else {
vector_data.segment_start_from_id(segment).and_then(|id| vector_data.point_domain.position_from_id(id))
};

let required_handle = if is_start(anchor, segment) {
start_handle
.filter(|&handle| handle != anchor_position)
.or(end_handle.filter(|&handle| Some(handle) != start_point))
.or(start_point)
} else {
end_handle
.filter(|&handle| handle != anchor_position)
.or(start_handle.filter(|&handle| Some(handle) != start_point))
.or(start_point)
};

if let Some(required_handle) = required_handle {
self.angle = -(required_handle - anchor_position).angle_to(DVec2::X);
if let Some(required_handle) = calculate_segment_angle(anchor, segment, vector_data, true) {
self.angle = required_handle;
self.handle_mode = HandleMode::ColinearEquidistant;
}
}
Expand All @@ -1332,8 +1309,6 @@ impl PenToolData {
self.handle_mode = HandleMode::ColinearEquidistant;
}
}

// Closure to check if a point is the start or end of a segment
}

fn add_point_layer_position(&mut self, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>, layer: LayerNodeIdentifier, viewport: DVec2) {
Expand Down
7 changes: 7 additions & 0 deletions node-graph/gcore/src/vector/vector_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,13 @@ impl ManipulatorPointId {
_ => None,
}
}

pub fn get_segment(self) -> Option<SegmentId> {
match self {
ManipulatorPointId::PrimaryHandle(segment) | ManipulatorPointId::EndHandle(segment) => Some(segment),
_ => None,
}
}
}

/// The type of handle found on a bézier curve.
Expand Down
Loading