Improve the Path tool's segment editing mode and make hovering manipulators react contextually (#2860)

* Improve path editing mode

* Code review

* Tidy up UI

* Update path selection behaviour

* Fix linting

* Remove frozen flag

* Code review

* Fix segment split

* Fix deleting segments

* Add requred methods in vello overlay context

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
Adesh Gupta
2025-08-01 23:58:35 -07:00
committed by GitHub
co-authored by Keavon Chambers
parent 523132da17
commit 668acd3c30
7 changed files with 555 additions and 95 deletions
@@ -302,9 +302,16 @@ impl ClosestSegment {
(midpoint, segment_ids)
}
pub fn adjusted_insert_and_select(&self, shape_editor: &mut ShapeState, responses: &mut VecDeque<Message>, extend_selection: bool) {
let (id, _) = self.adjusted_insert(responses);
shape_editor.select_anchor_point_by_id(self.layer, id, extend_selection)
pub fn adjusted_insert_and_select(&self, shape_editor: &mut ShapeState, responses: &mut VecDeque<Message>, extend_selection: bool, point_mode: bool, is_segment_selected: bool) {
let (id, segments) = self.adjusted_insert(responses);
if point_mode || is_segment_selected {
shape_editor.select_anchor_point_by_id(self.layer, id, extend_selection);
}
if is_segment_selected {
let Some(state) = shape_editor.selected_shape_state.get_mut(&self.layer) else { return };
segments.iter().for_each(|segment| state.select_segment(*segment));
}
}
pub fn calculate_perp(&self, document: &DocumentMessageHandler) -> DVec2 {
@@ -551,7 +558,7 @@ impl ShapeState {
select_threshold: f64,
extend_selection: bool,
path_overlay_mode: PathOverlayMode,
frontier_handles_info: Option<HashMap<SegmentId, Vec<PointId>>>,
frontier_handles_info: &Option<HashMap<SegmentId, Vec<PointId>>>,
) -> Option<Option<SelectedPointsInfo>> {
if self.selected_shape_state.is_empty() {
return None;
@@ -600,18 +607,18 @@ impl ShapeState {
mouse_position: DVec2,
select_threshold: f64,
path_overlay_mode: PathOverlayMode,
frontier_handles_info: Option<HashMap<SegmentId, Vec<PointId>>>,
frontier_handles_info: &Option<HashMap<SegmentId, Vec<PointId>>>,
point_editing_mode: bool,
) -> Option<(bool, Option<SelectedPointsInfo>)> {
if self.selected_shape_state.is_empty() {
return None;
}
if !point_editing_mode {
return None;
}
if let Some((layer, manipulator_point_id)) = self.find_nearest_point_indices(network_interface, mouse_position, select_threshold) {
// If not point editing mode then only handles are allowed to be dragged
if !point_editing_mode && matches!(manipulator_point_id, ManipulatorPointId::Anchor(_)) {
return None;
}
let vector_data = network_interface.compute_modified_vector(layer)?;
let point_position = manipulator_point_id.get_position(&vector_data)?;
@@ -1483,6 +1490,23 @@ impl ShapeState {
}
}
pub fn delete_hanging_selected_anchors(&mut self, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) {
for (&layer, state) in &self.selected_shape_state {
let Some(vector_data) = document.network_interface.compute_modified_vector(layer) else {
continue;
};
for point in &state.selected_points {
if let ManipulatorPointId::Anchor(anchor) = point {
if vector_data.all_connected(*anchor).all(|segment| state.is_segment_selected(segment.segment)) {
let modification_type = VectorModificationType::RemovePoint { id: *anchor };
responses.add(GraphOperationMessage::Vector { layer, modification_type });
}
}
}
}
}
pub fn break_path_at_selected_point(&self, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) {
for (&layer, state) in &self.selected_shape_state {
let Some(vector_data) = document.network_interface.compute_modified_vector(layer) else { continue };
@@ -1600,7 +1624,7 @@ impl ShapeState {
mouse_position: DVec2,
select_threshold: f64,
path_overlay_mode: PathOverlayMode,
frontier_handles_info: Option<HashMap<SegmentId, Vec<PointId>>>,
frontier_handles_info: &Option<HashMap<SegmentId, Vec<PointId>>>,
) -> Option<(LayerNodeIdentifier, ManipulatorPointId)> {
if self.selected_shape_state.is_empty() {
return None;
@@ -1968,20 +1992,91 @@ impl ShapeState {
selection_shape: SelectionShape,
selection_change: SelectionChange,
path_overlay_mode: PathOverlayMode,
frontier_handles_info: Option<HashMap<SegmentId, Vec<PointId>>>,
frontier_handles_info: &Option<HashMap<SegmentId, Vec<PointId>>>,
select_segments: bool,
select_points: bool,
// Here, "selection mode" represents touched or enclosed, not to be confused with editing modes
selection_mode: SelectionMode,
) {
let (points_inside, segments_inside) = self.get_inside_points_and_segments(
network_interface,
selection_shape,
path_overlay_mode,
frontier_handles_info,
select_segments,
select_points,
selection_mode,
);
if selection_change == SelectionChange::Clear {
self.deselect_all_points();
self.deselect_all_segments();
}
for (layer, points) in points_inside {
let Some(state) = self.selected_shape_state.get_mut(&layer) else { continue };
let Some(vector_data) = network_interface.compute_modified_vector(layer) else { continue };
for point in points {
match (point, selection_change) {
(_, SelectionChange::Shrink) => state.deselect_point(point),
(ManipulatorPointId::EndHandle(_) | ManipulatorPointId::PrimaryHandle(_), _) => {
let handle = point.as_handle().expect("Handle cannot be converted");
if handle.length(&vector_data) > 0. {
state.select_point(point);
}
}
(_, _) => state.select_point(point),
}
}
}
for (layer, segments) in segments_inside {
let Some(state) = self.selected_shape_state.get_mut(&layer) else { continue };
match selection_change {
SelectionChange::Shrink => segments.iter().for_each(|segment| state.deselect_segment(*segment)),
_ => segments.iter().for_each(|segment| state.select_segment(*segment)),
}
// Also select/deselect the endpoints of respective segments
let Some(vector_data) = network_interface.compute_modified_vector(layer) else { continue };
if !select_points && select_segments {
vector_data
.segment_bezier_iter()
.filter(|(segment, _, _, _)| segments.contains(segment))
.for_each(|(_, _, start, end)| match selection_change {
SelectionChange::Shrink => {
state.deselect_point(ManipulatorPointId::Anchor(start));
state.deselect_point(ManipulatorPointId::Anchor(end));
}
_ => {
state.select_point(ManipulatorPointId::Anchor(start));
state.select_point(ManipulatorPointId::Anchor(end));
}
});
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn get_inside_points_and_segments(
&mut self,
network_interface: &NodeNetworkInterface,
selection_shape: SelectionShape,
path_overlay_mode: PathOverlayMode,
frontier_handles_info: &Option<HashMap<SegmentId, Vec<PointId>>>,
select_segments: bool,
select_points: bool,
// Represents if the box/lasso selection touches or encloses the targets (not to be confused with editing modes).
selection_mode: SelectionMode,
) -> (HashMap<LayerNodeIdentifier, HashSet<ManipulatorPointId>>, HashMap<LayerNodeIdentifier, HashSet<SegmentId>>) {
let selected_points = self.selected_points().cloned().collect::<HashSet<_>>();
let selected_segments = selected_segments(network_interface, self);
for (&layer, state) in &mut self.selected_shape_state {
if selection_change == SelectionChange::Clear {
state.clear_points();
state.clear_segments();
}
let mut points_inside: HashMap<LayerNodeIdentifier, HashSet<ManipulatorPointId>> = HashMap::new();
let mut segments_inside: HashMap<LayerNodeIdentifier, HashSet<SegmentId>> = HashMap::new();
for &layer in self.selected_shape_state.keys() {
let vector_data = network_interface.compute_modified_vector(layer);
let Some(vector_data) = vector_data else { continue };
let transform = network_interface.document_metadata().transform_to_viewport_if_feeds(layer, network_interface);
@@ -1997,7 +2092,7 @@ impl ShapeState {
let polygon_subpath = if let SelectionShape::Lasso(polygon) = selection_shape {
if polygon.len() < 2 {
return;
return (points_inside, segments_inside);
}
let polygon: Subpath<PointId> = Subpath::from_anchors_linear(polygon.to_vec(), true);
Some(polygon)
@@ -2037,10 +2132,7 @@ impl ShapeState {
};
if select {
match selection_change {
SelectionChange::Shrink => state.deselect_segment(id),
_ => state.select_segment(id),
}
segments_inside.entry(layer).or_default().insert(id);
}
}
@@ -2057,21 +2149,11 @@ impl ShapeState {
.contains_point(transformed_position),
};
if select {
let is_visible_handle = is_visible_point(id, &vector_data, path_overlay_mode, frontier_handles_info.clone(), selected_segments.clone(), &selected_points);
if select && select_points {
let is_visible_handle = is_visible_point(id, &vector_data, path_overlay_mode, frontier_handles_info, selected_segments.clone(), &selected_points);
if is_visible_handle {
match selection_change {
SelectionChange::Shrink => state.deselect_point(id),
_ => {
// Select only the handles which are of nonzero length
if let Some(handle) = id.as_handle() {
if handle.length(&vector_data) > 0. {
state.select_point(id)
}
}
}
}
points_inside.entry(layer).or_default().insert(id);
}
}
}
@@ -2089,13 +2171,12 @@ impl ShapeState {
.contains_point(transformed_position),
};
if select {
match selection_change {
SelectionChange::Shrink => state.deselect_point(ManipulatorPointId::Anchor(id)),
_ => state.select_point(ManipulatorPointId::Anchor(id)),
}
if select && select_points {
points_inside.entry(layer).or_default().insert(ManipulatorPointId::Anchor(id));
}
}
}
(points_inside, segments_inside)
}
}
@@ -175,7 +175,7 @@ pub fn is_visible_point(
manipulator_point_id: ManipulatorPointId,
vector_data: &VectorData,
path_overlay_mode: PathOverlayMode,
frontier_handles_info: Option<HashMap<SegmentId, Vec<PointId>>>,
frontier_handles_info: &Option<HashMap<SegmentId, Vec<PointId>>>,
selected_segments: Vec<SegmentId>,
selected_points: &HashSet<ManipulatorPointId>,
) -> bool {
@@ -201,7 +201,7 @@ pub fn is_visible_point(
warn!("No anchor for selected handle");
return false;
};
let Some(frontier_handles) = &frontier_handles_info else {
let Some(frontier_handles) = frontier_handles_info else {
warn!("No frontier handles info provided");
return false;
};