Skip to content

Add segment editing mode to the Path tool #2712

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 20 commits into from
Jun 30, 2025
Merged
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
2 changes: 1 addition & 1 deletion editor/Cargo.toml
Original file line number Diff line number Diff line change
@@ -36,9 +36,9 @@ thiserror = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
bezier-rs = { workspace = true }
kurbo = { workspace = true }
futures = { workspace = true }
glam = { workspace = true }
kurbo = { workspace = true }
derivative = { workspace = true }
specta = { workspace = true }
dyn-any = { workspace = true }
3 changes: 2 additions & 1 deletion editor/src/consts.rs
Original file line number Diff line number Diff line change
@@ -102,7 +102,7 @@ pub const MANIPULATOR_GROUP_MARKER_SIZE: f64 = 6.;
pub const SELECTION_THRESHOLD: f64 = 10.;
pub const HIDE_HANDLE_DISTANCE: f64 = 3.;
pub const HANDLE_ROTATE_SNAP_ANGLE: f64 = 15.;
pub const SEGMENT_INSERTION_DISTANCE: f64 = 8.;
pub const SEGMENT_INSERTION_DISTANCE: f64 = 5.;
pub const SEGMENT_OVERLAY_SIZE: f64 = 10.;
pub const HANDLE_LENGTH_FACTOR: f64 = 0.5;

@@ -133,6 +133,7 @@ pub const SCALE_EFFECT: f64 = 0.5;

// COLORS
pub const COLOR_OVERLAY_BLUE: &str = "#00a8ff";
pub const COLOR_OVERLAY_BLUE_50: &str = "rgba(0, 168, 255, 0.5)";
pub const COLOR_OVERLAY_YELLOW: &str = "#ffc848";
pub const COLOR_OVERLAY_GREEN: &str = "#63ce63";
pub const COLOR_OVERLAY_RED: &str = "#ef5454";
2 changes: 1 addition & 1 deletion editor/src/messages/input_mapper/input_mappings.rs
Original file line number Diff line number Diff line change
@@ -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, drag_restore_handle: Control }),
entry!(KeyDown(MouseLeft); action_dispatch=PathToolMessage::MouseDown { extend_selection: Shift, lasso_select: Control, handle_drag_from_anchor: Alt, drag_restore_handle: Control, molding_in_segment_edit: KeyA }),
entry!(KeyDown(MouseRight); action_dispatch=PathToolMessage::RightClick),
entry!(KeyDown(Escape); action_dispatch=PathToolMessage::Escape),
entry!(KeyDown(KeyG); action_dispatch=PathToolMessage::GRS { key: KeyG }),
Original file line number Diff line number Diff line change
@@ -124,8 +124,19 @@ pub fn path_overlays(document: &DocumentMessageHandler, draw_handles: DrawHandle
overlay_context.outline_vector(&vector_data, transform);
}

// Get the selected segments and then add a bold line overlay on them
for (segment_id, bezier, _, _) in vector_data.segment_bezier_iter() {
let Some(selected_shape_state) = shape_editor.selected_shape_state.get_mut(&layer) else {
continue;
};

if selected_shape_state.is_segment_selected(segment_id) {
overlay_context.outline_select_bezier(bezier, transform);
}
}

let selected = shape_editor.selected_shape_state.get(&layer);
let is_selected = |point: ManipulatorPointId| selected.is_some_and(|selected| selected.is_selected(point));
let is_selected = |point: ManipulatorPointId| selected.is_some_and(|selected| selected.is_point_selected(point));

if display_handles {
let opposite_handles_data: Vec<(PointId, SegmentId)> = shape_editor.selected_points().filter_map(|point_id| vector_data.adjacent_segment(point_id)).collect();
@@ -187,7 +198,7 @@ pub fn path_endpoint_overlays(document: &DocumentMessageHandler, shape_editor: &
//let document_to_viewport = document.navigation_handler.calculate_offset_transform(overlay_context.size / 2., &document.document_ptz);
let transform = document.metadata().transform_to_viewport(layer);
let selected = shape_editor.selected_shape_state.get(&layer);
let is_selected = |selected: Option<&SelectedLayerState>, point: ManipulatorPointId| selected.is_some_and(|selected| selected.is_selected(point));
let is_selected = |selected: Option<&SelectedLayerState>, point: ManipulatorPointId| selected.is_some_and(|selected| selected.is_point_selected(point));

for point in vector_data.extendable_points(preferences.vector_meshes) {
let Some(position) = vector_data.point_domain.position_from_id(point) else { continue };
33 changes: 31 additions & 2 deletions editor/src/messages/portfolio/document/overlays/utility_types.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use super::utility_functions::overlay_canvas_context;
use crate::consts::{
COLOR_OVERLAY_BLUE, COLOR_OVERLAY_GREEN, COLOR_OVERLAY_RED, COLOR_OVERLAY_WHITE, COLOR_OVERLAY_YELLOW, COMPASS_ROSE_ARROW_SIZE, COMPASS_ROSE_HOVER_RING_DIAMETER, COMPASS_ROSE_MAIN_RING_DIAMETER,
COMPASS_ROSE_RING_INNER_DIAMETER, MANIPULATOR_GROUP_MARKER_SIZE, PIVOT_CROSSHAIR_LENGTH, PIVOT_CROSSHAIR_THICKNESS, PIVOT_DIAMETER,
COLOR_OVERLAY_BLUE, COLOR_OVERLAY_BLUE_50, COLOR_OVERLAY_GREEN, COLOR_OVERLAY_RED, COLOR_OVERLAY_WHITE, COLOR_OVERLAY_YELLOW, COMPASS_ROSE_ARROW_SIZE, COMPASS_ROSE_HOVER_RING_DIAMETER,
COMPASS_ROSE_MAIN_RING_DIAMETER, COMPASS_ROSE_RING_INNER_DIAMETER, MANIPULATOR_GROUP_MARKER_SIZE, PIVOT_CROSSHAIR_LENGTH, PIVOT_CROSSHAIR_THICKNESS, PIVOT_DIAMETER,
};
use crate::messages::prelude::Message;
use bezier_rs::{Bezier, Subpath};
@@ -581,6 +581,35 @@ impl OverlayContext {
self.end_dpi_aware_transform();
}

/// Used by the path tool segment mode in order to show the selected segments.
pub fn outline_select_bezier(&mut self, bezier: Bezier, transform: DAffine2) {
self.start_dpi_aware_transform();

self.render_context.begin_path();
self.bezier_command(bezier, transform, true);
self.render_context.set_stroke_style_str(COLOR_OVERLAY_BLUE);
self.render_context.set_line_width(4.);
self.render_context.stroke();

self.render_context.set_line_width(1.);

self.end_dpi_aware_transform();
}

pub fn outline_overlay_bezier(&mut self, bezier: Bezier, transform: DAffine2) {
self.start_dpi_aware_transform();

self.render_context.begin_path();
self.bezier_command(bezier, transform, true);
self.render_context.set_stroke_style_str(COLOR_OVERLAY_BLUE_50);
self.render_context.set_line_width(4.);
self.render_context.stroke();

self.render_context.set_line_width(1.);

self.end_dpi_aware_transform();
}

fn bezier_command(&self, bezier: Bezier, transform: DAffine2, move_to: bool) {
self.start_dpi_aware_transform();

Original file line number Diff line number Diff line change
@@ -88,6 +88,18 @@ impl OriginalTransforms {
let Some(selected_points) = shape_editor.selected_points_in_layer(layer) else {
continue;
};
let Some(selected_segments) = shape_editor.selected_segments_in_layer(layer) else {
continue;
};

let mut selected_points = selected_points.clone();

for (segment_id, _, start, end) in vector_data.segment_bezier_iter() {
if selected_segments.contains(&segment_id) {
selected_points.insert(ManipulatorPointId::Anchor(start));
selected_points.insert(ManipulatorPointId::Anchor(end));
}
}

// Anchors also move their handles
let anchor_ids = selected_points.iter().filter_map(|point| point.as_anchor());
220 changes: 172 additions & 48 deletions editor/src/messages/tool/common_functionality/shape_editor.rs

Large diffs are not rendered by default.

81 changes: 74 additions & 7 deletions editor/src/messages/tool/common_functionality/utility_functions.rs
Original file line number Diff line number Diff line change
@@ -8,11 +8,12 @@ use crate::messages::tool::common_functionality::graph_modification_utils::get_t
use crate::messages::tool::common_functionality::transformation_cage::SelectedEdges;
use crate::messages::tool::tool_messages::path_tool::PathOverlayMode;
use crate::messages::tool::utility_types::ToolType;
use bezier_rs::Bezier;
use bezier_rs::{Bezier, BezierHandles};
use glam::{DAffine2, DVec2};
use graphene_std::renderer::Quad;
use graphene_std::text::{FontCache, load_face};
use graphene_std::vector::{HandleExt, HandleId, ManipulatorPointId, PointId, SegmentId, VectorData, VectorModificationType};
use kurbo::{CubicBez, Line, ParamCurveExtrema, PathSeg, Point, QuadBez};

/// 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(
@@ -204,6 +205,71 @@ pub fn is_visible_point(
}
}

/// Function to find the bounding box of bezier (uses method from kurbo)
pub fn calculate_bezier_bbox(bezier: Bezier) -> [DVec2; 2] {
let start = Point::new(bezier.start.x, bezier.start.y);
let end = Point::new(bezier.end.x, bezier.end.y);
let bbox = match bezier.handles {
BezierHandles::Cubic { handle_start, handle_end } => {
let p1 = Point::new(handle_start.x, handle_start.y);
let p2 = Point::new(handle_end.x, handle_end.y);
CubicBez::new(start, p1, p2, end).bounding_box()
}
BezierHandles::Quadratic { handle } => {
let p1 = Point::new(handle.x, handle.y);
QuadBez::new(start, p1, end).bounding_box()
}
BezierHandles::Linear => Line::new(start, end).bounding_box(),
};
[DVec2::new(bbox.x0, bbox.y0), DVec2::new(bbox.x1, bbox.y1)]
}

pub fn is_intersecting(bezier: Bezier, quad: [DVec2; 2], transform: DAffine2) -> bool {
let to_layerspace = transform.inverse();
let quad = [to_layerspace.transform_point2(quad[0]), to_layerspace.transform_point2(quad[1])];
let start = Point::new(bezier.start.x, bezier.start.y);
let end = Point::new(bezier.end.x, bezier.end.y);
let segment = match bezier.handles {
BezierHandles::Cubic { handle_start, handle_end } => {
let p1 = Point::new(handle_start.x, handle_start.y);
let p2 = Point::new(handle_end.x, handle_end.y);
PathSeg::Cubic(CubicBez::new(start, p1, p2, end))
}
BezierHandles::Quadratic { handle } => {
let p1 = Point::new(handle.x, handle.y);
PathSeg::Quad(QuadBez::new(start, p1, end))
}
BezierHandles::Linear => PathSeg::Line(Line::new(start, end)),
};

// Create a list of all the sides
let sides = [
Line::new((quad[0].x, quad[0].y), (quad[1].x, quad[0].y)),
Line::new((quad[0].x, quad[0].y), (quad[0].x, quad[1].y)),
Line::new((quad[1].x, quad[1].y), (quad[1].x, quad[0].y)),
Line::new((quad[1].x, quad[1].y), (quad[0].x, quad[1].y)),
];

let mut is_intersecting = false;
for line in sides {
let intersections = segment.intersect_line(line);
let mut intersects = false;
for intersection in intersections {
if intersection.line_t <= 1. && intersection.line_t >= 0. && intersection.segment_t <= 1. && intersection.segment_t >= 0. {
// There is a valid intersection point
intersects = true;
break;
}
}
if intersects {
is_intersecting = true;
break;
}
}
is_intersecting
}

#[allow(clippy::too_many_arguments)]
pub fn resize_bounds(
document: &DocumentMessageHandler,
responses: &mut VecDeque<Message>,
@@ -221,7 +287,7 @@ pub fn resize_bounds(
let snap = Some(SizeSnapData {
manager: snap_manager,
points: snap_candidates,
snap_data: SnapData::ignore(document, input, &dragging_layers),
snap_data: SnapData::ignore(document, input, dragging_layers),
});
let (position, size) = movement.new_size(input.mouse.position, bounds.original_bound_transform, center, constrain, snap);
let (delta, mut pivot) = movement.bounds_to_scale_transform(position, size);
@@ -238,11 +304,12 @@ pub fn resize_bounds(
}
});

let mut selected = Selected::new(&mut bounds.original_transforms, &mut pivot, &dragging_layers, responses, &document.network_interface, None, &tool, None);
let mut selected = Selected::new(&mut bounds.original_transforms, &mut pivot, dragging_layers, responses, &document.network_interface, None, &tool, None);
selected.apply_transformation(bounds.original_bound_transform * transformation * bounds.original_bound_transform.inverse(), None);
}
}

#[allow(clippy::too_many_arguments)]
pub fn rotate_bounds(
document: &DocumentMessageHandler,
responses: &mut VecDeque<Message>,
@@ -280,7 +347,7 @@ pub fn rotate_bounds(
let mut selected = Selected::new(
&mut bounds.original_transforms,
&mut bounds.center_of_transformation,
&dragging_layers,
dragging_layers,
responses,
&document.network_interface,
None,
@@ -313,7 +380,7 @@ pub fn skew_bounds(
}
});

let mut selected = Selected::new(&mut bounds.original_transforms, &mut pivot, &layers, responses, &document.network_interface, None, &tool, None);
let mut selected = Selected::new(&mut bounds.original_transforms, &mut pivot, layers, responses, &document.network_interface, None, &tool, None);
selected.apply_transformation(bounds.original_bound_transform * transformation * bounds.original_bound_transform.inverse(), None);
}
}
@@ -365,7 +432,7 @@ pub fn transforming_transform_cage(
let mut selected = Selected::new(
&mut bounds.original_transforms,
&mut bounds.center_of_transformation,
&layers_dragging,
layers_dragging,
responses,
&document.network_interface,
None,
@@ -423,7 +490,7 @@ pub fn transforming_transform_cage(
}

// No resize, rotate, or skew
return (false, false, false);
(false, false, false)
}

/// Calculates similarity metric between new bezier curve and two old beziers by using sampled points.
401 changes: 281 additions & 120 deletions editor/src/messages/tool/tool_messages/path_tool.rs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -180,14 +180,28 @@ impl MessageHandler<TransformLayerMessage, TransformData<'_>> for TransformLayer
*selected.pivot = selected.mean_average_of_pivots();
self.local_pivot = document.metadata().document_to_viewport.inverse().transform_point2(*selected.pivot);
self.grab_target = document.metadata().document_to_viewport.inverse().transform_point2(selected.mean_average_of_pivots());
} else if let Some(vector_data) = selected_layers.first().and_then(|&layer| document.network_interface.compute_modified_vector(layer)) {
}
// Here vector data from all layers is not considered which can be a problem in pivot calculation
else if let Some(vector_data) = selected_layers.first().and_then(|&layer| document.network_interface.compute_modified_vector(layer)) {
*selected.original_transforms = OriginalTransforms::default();

let viewspace = document.metadata().transform_to_viewport(selected_layers[0]);
let selected_points = shape_editor.selected_points().collect::<Vec<_>>();

let selected_segments = shape_editor.selected_segments().collect::<HashSet<_>>();

let mut affected_points = shape_editor.selected_points().copied().collect::<Vec<_>>();

for (segment_id, _, start, end) in vector_data.segment_bezier_iter() {
if selected_segments.contains(&segment_id) {
affected_points.push(ManipulatorPointId::Anchor(start));
affected_points.push(ManipulatorPointId::Anchor(end));
}
}

let affected_point_refs = affected_points.iter().collect();

let get_location = |point: &&ManipulatorPointId| point.get_position(&vector_data).map(|position| viewspace.transform_point2(position));
if let Some((new_pivot, grab_target)) = calculate_pivot(&selected_points, &vector_data, viewspace, |point: &ManipulatorPointId| get_location(&point)) {
if let Some((new_pivot, grab_target)) = calculate_pivot(&affected_point_refs, &vector_data, viewspace, |point: &ManipulatorPointId| get_location(&point)) {
*selected.pivot = new_pivot;

self.local_pivot = document_to_viewport.inverse().transform_point2(*selected.pivot);
@@ -390,7 +404,8 @@ impl MessageHandler<TransformLayerMessage, TransformData<'_>> for TransformLayer
}
TransformLayerMessage::BeginGRS { transform_type } => {
let selected_points: Vec<&ManipulatorPointId> = shape_editor.selected_points().collect();
if (using_path_tool && selected_points.is_empty())
let selected_segments = shape_editor.selected_segments().collect::<Vec<_>>();
if (using_path_tool && selected_points.is_empty() && selected_segments.is_empty())
|| (!using_path_tool && !using_select_tool && !using_pen_tool && !using_shape_tool)
|| selected_layers.is_empty()
|| transform_type.equivalent_to(self.transform_operation)
3 changes: 3 additions & 0 deletions frontend/assets/icon-12px-solid/dot.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions frontend/src/utility-functions/icons.ts
Original file line number Diff line number Diff line change
@@ -13,6 +13,7 @@ import Checkmark from "@graphite-frontend/assets/icon-12px-solid/checkmark.svg";
import Clipped from "@graphite-frontend/assets/icon-12px-solid/clipped.svg";
import CloseX from "@graphite-frontend/assets/icon-12px-solid/close-x.svg";
import Delay from "@graphite-frontend/assets/icon-12px-solid/delay.svg";
import Dot from "@graphite-frontend/assets/icon-12px-solid/dot.svg";
import DropdownArrow from "@graphite-frontend/assets/icon-12px-solid/dropdown-arrow.svg";
import Edit12px from "@graphite-frontend/assets/icon-12px-solid/edit-12px.svg";
import Empty12px from "@graphite-frontend/assets/icon-12px-solid/empty-12px.svg";
@@ -55,6 +56,7 @@ const SOLID_12PX = {
Clipped: { svg: Clipped, size: 12 },
CloseX: { svg: CloseX, size: 12 },
Delay: { svg: Delay, size: 12 },
Dot: { svg: Dot, size: 12 },
DropdownArrow: { svg: DropdownArrow, size: 12 },
Edit12px: { svg: Edit12px, size: 12 },
Empty12px: { svg: Empty12px, size: 12 },
3 changes: 2 additions & 1 deletion node-graph/gcore/src/vector/vector_data.rs
Original file line number Diff line number Diff line change
@@ -12,12 +12,13 @@ use crate::vector::click_target::{ClickTargetType, FreePoint};
use crate::{AlphaBlending, Color, GraphicGroupTable};
pub use attributes::*;
use bezier_rs::ManipulatorGroup;
use core::borrow::Borrow;
use core::hash::Hash;
use dyn_any::DynAny;
use glam::{DAffine2, DVec2};
pub use indexed::VectorDataIndex;
use kurbo::{Affine, Rect, Shape};
pub use modification::*;
use std::borrow::Borrow;
use std::collections::HashMap;

// TODO: Eventually remove this migration document upgrade code