Skip to content

Make the Path tool support multi-point conversion between smooth/sharp on double-click #2498

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
May 20, 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
3 changes: 3 additions & 0 deletions editor/src/consts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,6 @@ pub const DEFAULT_DOCUMENT_NAME: &str = "Untitled Document";
pub const FILE_SAVE_SUFFIX: &str = ".graphite";
pub const MAX_UNDO_HISTORY_LEN: usize = 100; // TODO: Add this to user preferences
pub const AUTO_SAVE_TIMEOUT_SECONDS: u64 = 15;

// INPUT
pub const DOUBLE_CLICK_MILLISECONDS: u64 = 500;
72 changes: 38 additions & 34 deletions editor/src/messages/tool/common_functionality/shape_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ impl SelectedLayerState {
}

pub fn ignore_handles(&mut self, status: bool) {
if self.ignore_handles == !status {
if self.ignore_handles != status {
return;
}

Expand All @@ -86,7 +86,7 @@ impl SelectedLayerState {
}

pub fn ignore_anchors(&mut self, status: bool) {
if self.ignore_anchors == !status {
if self.ignore_anchors != status {
return;
}

Expand Down Expand Up @@ -774,7 +774,7 @@ impl ShapeState {

// 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 /= segment_count;
segment_angle += std::f64::consts::FRAC_PI_2;
DVec2::new(segment_angle.cos(), segment_angle.sin())
} else {
Expand All @@ -801,7 +801,7 @@ impl ShapeState {
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)
.get_position(vector_data)
.and_then(|position| (position - anchor_position).try_normalize())
else {
return;
Expand Down Expand Up @@ -1538,6 +1538,7 @@ impl ShapeState {
}
}
}

/// Converts a nearby clicked anchor point's handles between sharp (zero-length handles) and smooth (pulled-apart handle(s)).
/// If both handles aren't zero-length, they are set that. If both are zero-length, they are stretched apart by a reasonable amount.
/// This can can be activated by double clicking on an anchor with the Path tool.
Expand Down Expand Up @@ -1568,44 +1569,47 @@ impl ShapeState {
.count();

// Check by comparing the handle positions to the anchor if this manipulator group is a point
if positions != 0 {
self.convert_manipulator_handles_to_colinear(&vector_data, id, responses, layer);
} else {
for handle in vector_data.all_connected(id) {
let Some(bezier) = vector_data.segment_from_id(handle.segment) else { continue };

match bezier.handles {
BezierHandles::Linear => {}
BezierHandles::Quadratic { .. } => {
let segment = handle.segment;
// Convert to linear
let modification_type = VectorModificationType::SetHandles { segment, handles: [None; 2] };
responses.add(GraphOperationMessage::Vector { layer, modification_type });
for point in self.selected_points() {
let Some(point_id) = point.as_anchor() else { continue };
if positions != 0 {
self.convert_manipulator_handles_to_colinear(&vector_data, point_id, responses, layer);
} else {
for handle in vector_data.all_connected(point_id) {
let Some(bezier) = vector_data.segment_from_id(handle.segment) else { continue };

match bezier.handles {
BezierHandles::Linear => {}
BezierHandles::Quadratic { .. } => {
let segment = handle.segment;
// Convert to linear
let modification_type = VectorModificationType::SetHandles { segment, handles: [None; 2] };
responses.add(GraphOperationMessage::Vector { layer, modification_type });

// Set the manipulator to have non-colinear handles
for &handles in &vector_data.colinear_manipulators {
if handles.contains(&HandleId::primary(segment)) {
let modification_type = VectorModificationType::SetG1Continuous { handles, enabled: false };
responses.add(GraphOperationMessage::Vector { layer, modification_type });
// Set the manipulator to have non-colinear handles
for &handles in &vector_data.colinear_manipulators {
if handles.contains(&HandleId::primary(segment)) {
let modification_type = VectorModificationType::SetG1Continuous { handles, enabled: false };
responses.add(GraphOperationMessage::Vector { layer, modification_type });
}
}
}
}
BezierHandles::Cubic { .. } => {
// Set handle position to anchor position
let modification_type = handle.set_relative_position(DVec2::ZERO);
responses.add(GraphOperationMessage::Vector { layer, modification_type });
BezierHandles::Cubic { .. } => {
// Set handle position to anchor position
let modification_type = handle.set_relative_position(DVec2::ZERO);
responses.add(GraphOperationMessage::Vector { layer, modification_type });

// Set the manipulator to have non-colinear handles
for &handles in &vector_data.colinear_manipulators {
if handles.contains(&handle) {
let modification_type = VectorModificationType::SetG1Continuous { handles, enabled: false };
responses.add(GraphOperationMessage::Vector { layer, modification_type });
// Set the manipulator to have non-colinear handles
for &handles in &vector_data.colinear_manipulators {
if handles.contains(&handle) {
let modification_type = VectorModificationType::SetG1Continuous { handles, enabled: false };
responses.add(GraphOperationMessage::Vector { layer, modification_type });
}
}
}
}
}
}
};
};
}

Some(true)
};
Expand Down
24 changes: 22 additions & 2 deletions editor/src/messages/tool/tool_messages/path_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,8 @@ struct PathToolData {
select_anchor_toggled: bool,
saved_points_before_handle_drag: Vec<ManipulatorPointId>,
handle_drag_toggle: bool,
saved_points_before_anchor_convert_smooth_sharp: HashSet<ManipulatorPointId>,
last_click_time: u64,
dragging_state: DraggingState,
angle: f64,
opposite_handle_position: Option<DVec2>,
Expand Down Expand Up @@ -448,6 +450,12 @@ impl PathToolData {

self.drag_start_pos = input.mouse.position;

if !self.saved_points_before_anchor_convert_smooth_sharp.is_empty() && (input.time - self.last_click_time > 500) {
self.saved_points_before_anchor_convert_smooth_sharp.clear();
}

self.last_click_time = input.time;

let old_selection = shape_editor.selected_points().cloned().collect::<Vec<_>>();

// Check if the point is already selected; if not, select the first point within the threshold (in pixels)
Expand Down Expand Up @@ -489,7 +497,7 @@ impl PathToolData {
let modification_type = handle.set_relative_position(DVec2::ZERO);
responses.add(GraphOperationMessage::Vector { layer, modification_type });
for &handles in &vector_data.colinear_manipulators {
if handles.contains(&handle) {
if handles.contains(handle) {
let modification_type = VectorModificationType::SetG1Continuous { handles, enabled: false };
responses.add(GraphOperationMessage::Vector { layer, modification_type });
}
Expand All @@ -506,7 +514,7 @@ 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))))
.map(|(layer, point)| (point.as_anchor(), document.network_interface.compute_modified_vector(layer)))
{
let handles = vector_data
.all_connected(point)
Expand Down Expand Up @@ -1296,6 +1304,10 @@ impl Fsm for PathToolFsmState {
(PathToolFsmState::Ready, PathToolMessage::PointerMove { delete_segment, .. }) => {
tool_data.delete_segment_pressed = input.keyboard.get(delete_segment as usize);

if !tool_data.saved_points_before_anchor_convert_smooth_sharp.is_empty() {
tool_data.saved_points_before_anchor_convert_smooth_sharp.clear();
}

// If there is a point nearby, then remove the overlay
if shape_editor
.find_nearest_point_indices(&document.network_interface, input.mouse.position, SELECTION_THRESHOLD)
Expand Down Expand Up @@ -1490,6 +1502,10 @@ impl Fsm for PathToolFsmState {
if !drag_occurred && !extend_selection {
let clicked_selected = shape_editor.selected_points().any(|&point| nearest_point == point);
if clicked_selected {
if tool_data.saved_points_before_anchor_convert_smooth_sharp.is_empty() {
tool_data.saved_points_before_anchor_convert_smooth_sharp = shape_editor.selected_points().copied().collect::<HashSet<_>>();
}

shape_editor.deselect_all_points();
shape_editor.selected_shape_state.entry(layer).or_default().select_point(nearest_point);
responses.add(OverlaysMessage::Draw);
Expand Down Expand Up @@ -1537,7 +1553,11 @@ impl Fsm for PathToolFsmState {
// Flip the selected point between smooth and sharp
if !tool_data.double_click_handled && tool_data.drag_start_pos.distance(input.mouse.position) <= DRAG_THRESHOLD {
responses.add(DocumentMessage::StartTransaction);

shape_editor.select_points_by_manipulator_id(&tool_data.saved_points_before_anchor_convert_smooth_sharp.iter().copied().collect::<Vec<_>>());
shape_editor.flip_smooth_sharp(&document.network_interface, input.mouse.position, SELECTION_TOLERANCE, responses);
tool_data.saved_points_before_anchor_convert_smooth_sharp.clear();

responses.add(DocumentMessage::EndTransaction);
responses.add(PathToolMessage::SelectedPointUpdated);
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/io-managers/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
if (textToolInteractiveInputElement) return;

// Allow only double-clicks
if (e.detail !== 2) return;
if (e.detail % 2 == 1) return;

// `e.buttons` is always 0 in the `mouseup` event, so we have to convert from `e.button` instead
let buttons = 1;
Expand Down
Loading