Graphite/editor/src/messages/tool/tool_messages/path_tool.rs

1326 lines
48 KiB
Rust

use super::select_tool::extend_lasso;
use super::tool_prelude::*;
use crate::consts::{
COLOR_OVERLAY_BLUE, DRAG_DIRECTION_MODE_DETERMINATION_THRESHOLD, DRAG_THRESHOLD, HANDLE_ROTATE_SNAP_ANGLE, INSERT_POINT_ON_SEGMENT_TOO_FAR_DISTANCE, SELECTION_THRESHOLD, SELECTION_TOLERANCE,
};
use crate::messages::portfolio::document::overlays::utility_functions::path_overlays;
use crate::messages::portfolio::document::overlays::utility_types::OverlayContext;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
use crate::messages::preferences::SelectionMode;
use crate::messages::tool::common_functionality::auto_panning::AutoPanning;
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 graphene_core::renderer::Quad;
use graphene_core::vector::ManipulatorPointId;
use graphene_std::vector::NoHashBuilder;
use std::vec;
#[derive(Default)]
pub struct PathTool {
fsm_state: PathToolFsmState,
tool_data: PathToolData,
}
#[impl_message(Message, ToolMessage, Path)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
pub enum PathToolMessage {
// Standard messages
Abort,
Overlays(OverlayContext),
SelectionChanged,
// Tool-specific messages
BreakPath,
DeselectAllPoints,
Delete,
DeleteAndBreakPath,
DragStop {
extend_selection: Key,
shrink_selection: Key,
},
Enter {
extend_selection: Key,
shrink_selection: Key,
},
Escape,
ClosePath,
FlipSmoothSharp,
GRS {
// Should be `Key::KeyG` (Grab), `Key::KeyR` (Rotate), or `Key::KeyS` (Scale)
key: Key,
},
ManipulatorMakeHandlesFree,
ManipulatorMakeHandlesColinear,
MouseDown {
direct_insert_without_sliding: Key,
extend_selection: Key,
lasso_select: Key,
},
NudgeSelectedPoints {
delta_x: f64,
delta_y: f64,
},
PointerMove {
equidistant: Key,
toggle_colinear: Key,
move_anchor_with_handles: Key,
snap_angle: Key,
lock_angle: Key,
},
PointerOutsideViewport {
equidistant: Key,
toggle_colinear: Key,
move_anchor_with_handles: Key,
snap_angle: Key,
lock_angle: Key,
},
RightClick,
SelectAllAnchors,
SelectedPointUpdated,
SelectedPointXChanged {
new_x: f64,
},
SelectedPointYChanged {
new_y: f64,
},
SwapSelectedHandles,
}
impl ToolMetadata for PathTool {
fn icon_name(&self) -> String {
"VectorPathTool".into()
}
fn tooltip(&self) -> String {
"Path Tool".into()
}
fn tool_type(&self) -> crate::messages::tool::utility_types::ToolType {
ToolType::Path
}
}
impl LayoutHolder for PathTool {
fn layout(&self) -> Layout {
let coordinates = self.tool_data.selection_status.as_one().as_ref().map(|point| point.coordinates);
let (x, y) = coordinates.map(|point| (Some(point.x), Some(point.y))).unwrap_or((None, None));
let selection_status = &self.tool_data.selection_status;
let manipulator_angle = selection_status.angle();
let x_location = NumberInput::new(x)
.unit(" px")
.label("X")
.min_width(120)
.disabled(x.is_none())
.min(-((1_u64 << f64::MANTISSA_DIGITS) as f64))
.max((1_u64 << f64::MANTISSA_DIGITS) as f64)
.on_update(move |number_input: &NumberInput| {
if let Some(new_x) = number_input.value.or(x) {
PathToolMessage::SelectedPointXChanged { new_x }.into()
} else {
Message::NoOp
}
})
.widget_holder();
let y_location = NumberInput::new(y)
.unit(" px")
.label("Y")
.min_width(120)
.disabled(y.is_none())
.min(-((1_u64 << f64::MANTISSA_DIGITS) as f64))
.max((1_u64 << f64::MANTISSA_DIGITS) as f64)
.on_update(move |number_input: &NumberInput| {
if let Some(new_y) = number_input.value.or(y) {
PathToolMessage::SelectedPointYChanged { new_y }.into()
} else {
Message::NoOp
}
})
.widget_holder();
let related_seperator = Separator::new(SeparatorType::Related).widget_holder();
let unrelated_seperator = Separator::new(SeparatorType::Unrelated).widget_holder();
let colinear_handles_tooltip = "Keep both handles unbent, each 180° apart, when moving either";
let colinear_handles_state = manipulator_angle.and_then(|angle| match angle {
ManipulatorAngle::Colinear => Some(true),
ManipulatorAngle::Free => Some(false),
ManipulatorAngle::Mixed => None,
})
// TODO: Remove `unwrap_or_default` once checkboxes are capable of displaying a mixed state
.unwrap_or_default();
let colinear_handle_checkbox = CheckboxInput::new(colinear_handles_state)
.disabled(self.tool_data.selection_status.is_none())
.on_update(|&CheckboxInput { checked, .. }| {
if checked {
PathToolMessage::ManipulatorMakeHandlesColinear.into()
} else {
PathToolMessage::ManipulatorMakeHandlesFree.into()
}
})
.tooltip(colinear_handles_tooltip)
.widget_holder();
let colinear_handles_label = TextLabel::new("Colinear Handles")
.disabled(self.tool_data.selection_status.is_none())
.tooltip(colinear_handles_tooltip)
.widget_holder();
Layout::WidgetLayout(WidgetLayout::new(vec![LayoutGroup::Row {
widgets: vec![
x_location,
related_seperator.clone(),
y_location,
unrelated_seperator,
colinear_handle_checkbox,
related_seperator,
colinear_handles_label,
],
}]))
}
}
impl<'a> MessageHandler<ToolMessage, &mut ToolActionHandlerData<'a>> for PathTool {
fn process_message(&mut self, message: ToolMessage, responses: &mut VecDeque<Message>, tool_data: &mut ToolActionHandlerData<'a>) {
let updating_point = message == ToolMessage::Path(PathToolMessage::SelectedPointUpdated);
match message {
ToolMessage::Path(PathToolMessage::ClosePath) => {
responses.add(DocumentMessage::AddTransaction);
tool_data.shape_editor.close_selected_path(tool_data.document, responses);
responses.add(DocumentMessage::EndTransaction);
responses.add(OverlaysMessage::Draw);
}
ToolMessage::Path(PathToolMessage::SwapSelectedHandles) => {
if tool_data.shape_editor.handle_with_pair_selected(&tool_data.document.network_interface) {
tool_data.shape_editor.alternate_selected_handles(&tool_data.document.network_interface);
responses.add(PathToolMessage::SelectedPointUpdated);
responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::None });
responses.add(OverlaysMessage::Draw);
}
}
_ => {
self.fsm_state.process_event(message, &mut self.tool_data, tool_data, &(), responses, true);
}
}
if updating_point {
self.send_layout(responses, LayoutTarget::ToolOptions);
}
}
// Different actions depending on state may be wanted:
fn actions(&self) -> ActionList {
match self.fsm_state {
PathToolFsmState::Ready => actions!(PathToolMessageDiscriminant;
FlipSmoothSharp,
MouseDown,
Delete,
NudgeSelectedPoints,
Enter,
SelectAllAnchors,
DeselectAllPoints,
BreakPath,
DeleteAndBreakPath,
ClosePath,
),
PathToolFsmState::Dragging(_) => actions!(PathToolMessageDiscriminant;
Escape,
RightClick,
FlipSmoothSharp,
DragStop,
PointerMove,
Delete,
BreakPath,
DeleteAndBreakPath,
SwapSelectedHandles,
),
PathToolFsmState::Drawing { .. } => actions!(PathToolMessageDiscriminant;
FlipSmoothSharp,
DragStop,
PointerMove,
Delete,
Enter,
BreakPath,
DeleteAndBreakPath,
Escape,
RightClick,
),
PathToolFsmState::InsertPoint => actions!(PathToolMessageDiscriminant;
Enter,
MouseDown,
PointerMove,
Escape,
Delete,
RightClick,
GRS,
),
}
}
}
impl ToolTransition for PathTool {
fn event_to_message_map(&self) -> EventToMessageMap {
EventToMessageMap {
tool_abort: Some(PathToolMessage::Abort.into()),
selection_changed: Some(PathToolMessage::SelectionChanged.into()),
overlay_provider: Some(|overlay_context| PathToolMessage::Overlays(overlay_context).into()),
..Default::default()
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct DraggingState {
point_select_state: PointSelectState,
colinear: ManipulatorAngle,
}
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
pub enum PointSelectState {
HandleWithPair,
#[default]
HandleNoPair,
Anchor,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum PathToolFsmState {
#[default]
Ready,
Dragging(DraggingState),
Drawing {
selection_shape: SelectionShapeType,
},
InsertPoint,
}
enum InsertEndKind {
Abort,
Add { extend_selection: bool },
}
#[derive(Default)]
struct PathToolData {
snap_manager: SnapManager,
lasso_polygon: Vec<DVec2>,
selection_mode: Option<SelectionMode>,
drag_start_pos: DVec2,
previous_mouse_position: DVec2,
toggle_colinear_debounce: bool,
opposing_handle_lengths: Option<OpposingHandleLengths>,
/// Describes information about the selected point(s), if any, across one or multiple shapes and manipulator point types (anchor or handle).
/// The available information varies depending on whether `None`, `One`, or `Multiple` points are currently selected.
selection_status: SelectionStatus,
segment: Option<ClosestSegment>,
snap_cache: SnapCache,
double_click_handled: bool,
auto_panning: AutoPanning,
saved_points_before_anchor_select_toggle: Vec<ManipulatorPointId>,
select_anchor_toggled: bool,
dragging_state: DraggingState,
current_selected_handle_id: Option<ManipulatorPointId>,
angle: f64,
}
impl PathToolData {
fn save_points_before_anchor_toggle(&mut self, points: Vec<ManipulatorPointId>) -> PathToolFsmState {
self.saved_points_before_anchor_select_toggle = points;
PathToolFsmState::Dragging(self.dragging_state)
}
fn remove_saved_points(&mut self) {
self.saved_points_before_anchor_select_toggle.clear();
}
pub fn selection_quad(&self) -> Quad {
let bbox = self.selection_box();
Quad::from_box(bbox)
}
pub fn calculate_selection_mode_from_direction(&mut self) -> SelectionMode {
let bbox = self.selection_box();
let above_threshold = bbox[1].distance_squared(bbox[0]) > DRAG_DIRECTION_MODE_DETERMINATION_THRESHOLD.powi(2);
if self.selection_mode.is_none() && above_threshold {
let mode = if bbox[1].x < bbox[0].x {
SelectionMode::Touched
} else {
// This also covers the case where they're equal: the area is zero, so we use `Enclosed` to ensure the selection ends up empty, as nothing will be enclosed by an empty area
SelectionMode::Enclosed
};
self.selection_mode = Some(mode);
}
self.selection_mode.unwrap_or(SelectionMode::Touched)
}
pub fn selection_box(&self) -> [DVec2; 2] {
if self.previous_mouse_position == self.drag_start_pos {
let tolerance = DVec2::splat(SELECTION_TOLERANCE);
[self.drag_start_pos - tolerance, self.drag_start_pos + tolerance]
} else {
[self.drag_start_pos, self.previous_mouse_position]
}
}
fn start_insertion(&mut self, responses: &mut VecDeque<Message>, segment: ClosestSegment) -> PathToolFsmState {
if self.segment.is_some() {
warn!("Segment was `Some(..)` before `start_insertion`")
}
self.segment = Some(segment);
responses.add(OverlaysMessage::Draw);
PathToolFsmState::InsertPoint
}
fn update_insertion(&mut self, shape_editor: &mut ShapeState, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>, input: &InputPreprocessorMessageHandler) -> PathToolFsmState {
if let Some(closed_segment) = &mut self.segment {
closed_segment.update_closest_point(document.metadata(), input.mouse.position);
if closed_segment.too_far(input.mouse.position, INSERT_POINT_ON_SEGMENT_TOO_FAR_DISTANCE, document.metadata()) {
self.end_insertion(shape_editor, responses, InsertEndKind::Abort)
} else {
PathToolFsmState::InsertPoint
}
} else {
warn!("Segment was `None` on `update_insertion`");
PathToolFsmState::Ready
}
}
fn end_insertion(&mut self, shape_editor: &mut ShapeState, responses: &mut VecDeque<Message>, kind: InsertEndKind) -> PathToolFsmState {
let mut commit_transaction = false;
match self.segment.as_mut() {
None => {
warn!("Segment was `None` before `end_insertion`")
}
Some(closed_segment) => {
if let InsertEndKind::Add { extend_selection } = kind {
closed_segment.adjusted_insert_and_select(shape_editor, responses, extend_selection);
commit_transaction = true;
}
}
}
self.segment = None;
if commit_transaction {
responses.add(DocumentMessage::EndTransaction);
} else {
responses.add(DocumentMessage::AbortTransaction);
}
responses.add(OverlaysMessage::Draw);
PathToolFsmState::Ready
}
#[allow(clippy::too_many_arguments)]
fn mouse_down(
&mut self,
shape_editor: &mut ShapeState,
document: &DocumentMessageHandler,
input: &InputPreprocessorMessageHandler,
responses: &mut VecDeque<Message>,
extend_selection: bool,
direct_insert_without_sliding: bool,
lasso_select: bool,
) -> PathToolFsmState {
self.double_click_handled = false;
self.opposing_handle_lengths = None;
self.drag_start_pos = input.mouse.position;
// Select the first point within the threshold (in pixels)
if let Some(selected_points) = shape_editor.change_point_selection(&document.network_interface, input.mouse.position, SELECTION_THRESHOLD, extend_selection) {
responses.add(DocumentMessage::StartTransaction);
if let Some(selected_points) = selected_points {
self.drag_start_pos = input.mouse.position;
self.start_dragging_point(selected_points, input, document, shape_editor);
responses.add(OverlaysMessage::Draw);
}
PathToolFsmState::Dragging(self.dragging_state)
}
// We didn't find a point nearby, so now we'll try to add a point into the closest path segment
else if let Some(closed_segment) = shape_editor.upper_closest_segment(&document.network_interface, input.mouse.position, SELECTION_TOLERANCE) {
responses.add(DocumentMessage::StartTransaction);
if direct_insert_without_sliding {
self.start_insertion(responses, closed_segment);
self.end_insertion(shape_editor, responses, InsertEndKind::Add { extend_selection })
} else {
self.start_insertion(responses, closed_segment)
}
}
// We didn't find a segment path, so consider selecting the nearest shape instead
else if let Some(layer) = document.click(input) {
if extend_selection {
responses.add(NodeGraphMessage::SelectedNodesAdd { nodes: vec![layer.to_node()] });
} else {
responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] });
}
self.drag_start_pos = input.mouse.position;
self.previous_mouse_position = document.metadata().document_to_viewport.inverse().transform_point2(input.mouse.position);
responses.add(DocumentMessage::StartTransaction);
PathToolFsmState::Dragging(self.dragging_state)
}
// Start drawing
else {
self.drag_start_pos = input.mouse.position;
self.previous_mouse_position = document.metadata().document_to_viewport.inverse().transform_point2(input.mouse.position);
let selection_shape = if lasso_select { SelectionShapeType::Lasso } else { SelectionShapeType::Box };
PathToolFsmState::Drawing { selection_shape }
}
}
fn start_dragging_point(&mut self, selected_points: SelectedPointsInfo, input: &InputPreprocessorMessageHandler, document: &DocumentMessageHandler, shape_editor: &mut ShapeState) {
let mut manipulators = HashMap::with_hasher(NoHashBuilder);
let mut unselected = Vec::new();
for (&layer, state) in &shape_editor.selected_shape_state {
let Some(vector_data) = document.network_interface.compute_modified_vector(layer) else {
continue;
};
let transform = document.metadata().transform_to_document(layer);
let mut layer_manipulators = HashSet::with_hasher(NoHashBuilder);
for point in state.selected() {
let Some(anchor) = point.get_anchor(&vector_data) else { continue };
layer_manipulators.insert(anchor);
}
for (&id, &position) in vector_data.point_domain.ids().iter().zip(vector_data.point_domain.positions()) {
if layer_manipulators.contains(&id) {
continue;
}
unselected.push(SnapCandidatePoint::handle(transform.transform_point2(position)))
}
if !layer_manipulators.is_empty() {
manipulators.insert(layer, layer_manipulators);
}
}
self.snap_cache = SnapCache { manipulators, unselected };
let viewport_to_document = document.metadata().document_to_viewport.inverse();
self.previous_mouse_position = viewport_to_document.transform_point2(input.mouse.position - selected_points.offset);
}
fn update_colinear(&mut self, equidistant: bool, toggle_colinear: bool, shape_editor: &mut ShapeState, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) -> bool {
// Check handle colinear state
let is_colinear = self
.selection_status
.angle()
.map(|angle| match angle {
ManipulatorAngle::Colinear => true,
ManipulatorAngle::Free | ManipulatorAngle::Mixed => false,
})
.unwrap_or(false);
// Check if the toggle_colinear key has just been pressed
if toggle_colinear && !self.toggle_colinear_debounce {
self.opposing_handle_lengths = None;
if is_colinear {
shape_editor.disable_colinear_handles_state_on_selected(&document.network_interface, responses);
} else {
shape_editor.convert_selected_manipulators_to_colinear_handles(responses, document);
}
self.toggle_colinear_debounce = true;
return true;
}
self.toggle_colinear_debounce = toggle_colinear;
if equidistant && self.opposing_handle_lengths.is_none() {
if !is_colinear {
// Try to get selected handle info
let Some((_, _, selected_handle_id)) = self.try_get_selected_handle_and_anchor(shape_editor, document) else {
self.opposing_handle_lengths = Some(shape_editor.opposing_handle_lengths(document));
return false;
};
let Some((layer, _)) = shape_editor.selected_shape_state.iter().next() else {
self.opposing_handle_lengths = Some(shape_editor.opposing_handle_lengths(document));
return false;
};
let Some(vector_data) = document.network_interface.compute_modified_vector(*layer) else {
self.opposing_handle_lengths = Some(shape_editor.opposing_handle_lengths(document));
return false;
};
// Check if handle has a pair (to ignore handles of edges of open paths)
if let Some(handle_pair) = selected_handle_id.get_handle_pair(&vector_data) {
let opposite_handle_length = handle_pair.iter().filter(|&&h| h.to_manipulator_point() != selected_handle_id).find_map(|&h| {
let opp_handle_pos = h.to_manipulator_point().get_position(&vector_data)?;
let opp_anchor_id = h.to_manipulator_point().get_anchor(&vector_data)?;
let opp_anchor_pos = vector_data.point_domain.position_from_id(opp_anchor_id)?;
Some((opp_handle_pos - opp_anchor_pos).length())
});
// Make handles colinear if opposite handle is zero length
if opposite_handle_length == Some(0.) {
shape_editor.convert_selected_manipulators_to_colinear_handles(responses, document);
return true;
}
}
}
self.opposing_handle_lengths = Some(shape_editor.opposing_handle_lengths(document));
}
false
}
/// Attempts to get a single selected handle. Also retrieves the position of the anchor it is connected to. Used for the purpose of snapping the angle.
fn try_get_selected_handle_and_anchor(&self, shape_editor: &ShapeState, document: &DocumentMessageHandler) -> Option<(DVec2, DVec2, ManipulatorPointId)> {
// Only count selections of a single layer
let (layer, selection) = shape_editor.selected_shape_state.iter().next()?;
// Do not allow selections of multiple points to count
if selection.selected_points_count() != 1 {
return None;
}
// Only count selected handles
let selected_handle = selection.selected().next()?.as_handle()?;
let handle_id = selected_handle.to_manipulator_point();
let layer_to_document = document.metadata().transform_to_document(*layer);
let vector_data = document.network_interface.compute_modified_vector(*layer)?;
let handle_position_local = selected_handle.to_manipulator_point().get_position(&vector_data)?;
let anchor_id = selected_handle.to_manipulator_point().get_anchor(&vector_data)?;
let anchor_position_local = vector_data.point_domain.position_from_id(anchor_id)?;
let handle_position_document = layer_to_document.transform_point2(handle_position_local);
let anchor_position_document = layer_to_document.transform_point2(anchor_position_local);
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 {
let current_angle = -handle_vector.angle_to(DVec2::X);
// When the angle is locked we use the old angle
if self.current_selected_handle_id == Some(handle_id) && lock_angle {
return self.angle;
}
// Round the angle to the closest increment
let mut handle_angle = current_angle;
if snap_angle && !lock_angle {
let snap_resolution = HANDLE_ROTATE_SNAP_ANGLE.to_radians();
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
}
#[allow(clippy::too_many_arguments)]
fn apply_snapping(
&mut self,
handle_direction: DVec2,
new_handle_position: DVec2,
anchor_position: DVec2,
using_angle_constraints: bool,
handle_position: DVec2,
document: &DocumentMessageHandler,
input: &InputPreprocessorMessageHandler,
) -> DVec2 {
let snap_data = SnapData::new(document, input);
let snap_point = SnapCandidatePoint::handle_neighbors(new_handle_position, [anchor_position]);
let snap_result = match using_angle_constraints {
true => {
let snap_constraint = SnapConstraint::Line {
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()),
};
self.snap_manager.update_indicator(snap_result.clone());
document.metadata().document_to_viewport.transform_vector2(snap_result.snapped_point_document - handle_position)
}
#[allow(clippy::too_many_arguments)]
fn drag(
&mut self,
equidistant: bool,
lock_angle: bool,
snap_angle: bool,
shape_editor: &mut ShapeState,
document: &DocumentMessageHandler,
input: &InputPreprocessorMessageHandler,
responses: &mut VecDeque<Message>,
) {
let document_to_viewport = document.metadata().document_to_viewport;
let previous_mouse = document_to_viewport.transform_point2(self.previous_mouse_position);
let current_mouse = input.mouse.position;
let raw_delta = document_to_viewport.inverse().transform_vector2(current_mouse - previous_mouse);
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 constrained_direction = DVec2::new(handle_angle.cos(), handle_angle.sin());
let projected_length = (cursor_pos - anchor_pos).dot(constrained_direction);
let constrained_target = anchor_pos + constrained_direction * projected_length;
let constrained_delta = constrained_target - handle_pos;
self.apply_snapping(constrained_direction, handle_pos + constrained_delta, anchor_pos, lock_angle || snap_angle, handle_pos, document, input)
} else {
shape_editor.snap(&mut self.snap_manager, &self.snap_cache, document, input, previous_mouse)
};
let handle_lengths = if equidistant { None } else { self.opposing_handle_lengths.take() };
shape_editor.move_selected_points(handle_lengths, document, snapped_delta, equidistant, responses, true);
self.previous_mouse_position += document_to_viewport.inverse().transform_vector2(snapped_delta);
}
}
impl Fsm for PathToolFsmState {
type ToolData = PathToolData;
type ToolOptions = ();
fn transition(self, event: ToolMessage, tool_data: &mut Self::ToolData, tool_action_data: &mut ToolActionHandlerData, _tool_options: &(), responses: &mut VecDeque<Message>) -> Self {
let ToolActionHandlerData { document, input, shape_editor, .. } = tool_action_data;
let ToolMessage::Path(event) = event else { return self };
match (self, event) {
(_, PathToolMessage::SelectionChanged) => {
// Set the newly targeted layers to visible
let target_layers = document.network_interface.selected_nodes(&[]).unwrap().selected_layers(document.metadata()).collect();
shape_editor.set_selected_layers(target_layers);
responses.add(OverlaysMessage::Draw);
responses.add(PathToolMessage::SelectedPointUpdated);
self
}
(_, PathToolMessage::Overlays(mut overlay_context)) => {
path_overlays(document, shape_editor, &mut overlay_context);
match self {
Self::Drawing { selection_shape } => {
let mut fill_color = graphene_std::Color::from_rgb_str(crate::consts::COLOR_OVERLAY_BLUE.strip_prefix('#').unwrap())
.unwrap()
.with_alpha(0.05)
.rgba_hex();
fill_color.insert(0, '#');
let fill_color = Some(fill_color.as_str());
let selection_mode = match tool_action_data.preferences.get_selection_mode() {
SelectionMode::Directional => tool_data.calculate_selection_mode_from_direction(),
selection_mode => selection_mode,
};
let quad = tool_data.selection_quad();
let polygon = &tool_data.lasso_polygon;
match (selection_shape, selection_mode) {
(SelectionShapeType::Box, SelectionMode::Enclosed) => overlay_context.dashed_quad(quad, fill_color, Some(4.), Some(4.), Some(0.5)),
(SelectionShapeType::Lasso, SelectionMode::Enclosed) => overlay_context.dashed_polygon(polygon, fill_color, Some(4.), Some(4.), Some(0.5)),
(SelectionShapeType::Box, _) => overlay_context.quad(quad, fill_color),
(SelectionShapeType::Lasso, _) => overlay_context.polygon(polygon, fill_color),
}
}
Self::Dragging(_) => {
tool_data.snap_manager.draw_overlays(SnapData::new(document, input), &mut overlay_context);
}
Self::InsertPoint => {
let state = tool_data.update_insertion(shape_editor, document, responses, input);
if let Some(closest_segment) = &tool_data.segment {
overlay_context.manipulator_anchor(closest_segment.closest_point_to_viewport(), false, Some(COLOR_OVERLAY_BLUE));
if let (Some(handle1), Some(handle2)) = closest_segment.handle_positions(document.metadata()) {
overlay_context.line(closest_segment.closest_point_to_viewport(), handle1, Some(COLOR_OVERLAY_BLUE));
overlay_context.line(closest_segment.closest_point_to_viewport(), handle2, Some(COLOR_OVERLAY_BLUE));
overlay_context.manipulator_handle(handle1, false, Some(COLOR_OVERLAY_BLUE));
overlay_context.manipulator_handle(handle2, false, Some(COLOR_OVERLAY_BLUE));
}
}
responses.add(PathToolMessage::SelectedPointUpdated);
return state;
}
_ => {}
}
responses.add(PathToolMessage::SelectedPointUpdated);
self
}
// `Self::InsertPoint` case:
(Self::InsertPoint, PathToolMessage::MouseDown { extend_selection, .. } | PathToolMessage::Enter { extend_selection, .. }) => {
tool_data.double_click_handled = true;
let extend_selection = input.keyboard.get(extend_selection as usize);
tool_data.end_insertion(shape_editor, responses, InsertEndKind::Add { extend_selection })
}
(Self::InsertPoint, PathToolMessage::PointerMove { .. }) => {
responses.add(OverlaysMessage::Draw);
// `tool_data.update_insertion` would be called on `OverlaysMessage::Draw`
// we anyway should to call it on `::Draw` because we can change scale by ctrl+scroll without `::PointerMove`
self
}
(Self::InsertPoint, PathToolMessage::Escape | PathToolMessage::Delete | PathToolMessage::RightClick) => tool_data.end_insertion(shape_editor, responses, InsertEndKind::Abort),
(Self::InsertPoint, PathToolMessage::GRS { key: _ }) => PathToolFsmState::InsertPoint,
// Mouse down
(
_,
PathToolMessage::MouseDown {
direct_insert_without_sliding,
extend_selection,
lasso_select,
},
) => {
let extend_selection = input.keyboard.get(extend_selection as usize);
let lasso_select = input.keyboard.get(lasso_select as usize);
let direct_insert_without_sliding = input.keyboard.get(direct_insert_without_sliding as usize);
tool_data.selection_mode = None;
tool_data.lasso_polygon.clear();
tool_data.mouse_down(shape_editor, document, input, responses, extend_selection, direct_insert_without_sliding, lasso_select)
}
(
PathToolFsmState::Drawing { selection_shape },
PathToolMessage::PointerMove {
equidistant,
toggle_colinear,
move_anchor_with_handles,
snap_angle,
lock_angle,
},
) => {
tool_data.previous_mouse_position = input.mouse.position;
if selection_shape == SelectionShapeType::Lasso {
extend_lasso(&mut tool_data.lasso_polygon, input.mouse.position);
}
responses.add(OverlaysMessage::Draw);
// Auto-panning
let messages = [
PathToolMessage::PointerOutsideViewport {
equidistant,
toggle_colinear,
move_anchor_with_handles,
snap_angle,
lock_angle,
}
.into(),
PathToolMessage::PointerMove {
equidistant,
toggle_colinear,
move_anchor_with_handles,
snap_angle,
lock_angle,
}
.into(),
];
tool_data.auto_panning.setup_by_mouse_position(input, &messages, responses);
PathToolFsmState::Drawing { selection_shape }
}
(
PathToolFsmState::Dragging(_),
PathToolMessage::PointerMove {
equidistant,
toggle_colinear,
move_anchor_with_handles,
snap_angle,
lock_angle,
},
) => {
if tool_data.selection_status.is_none() {
if let Some(layer) = document.click(input) {
shape_editor.select_all_anchors_in_layer(document, layer);
}
}
let anchor_and_handle_toggled = input.keyboard.get(move_anchor_with_handles as usize);
let initial_press = anchor_and_handle_toggled && !tool_data.select_anchor_toggled;
let released_from_toggle = tool_data.select_anchor_toggled && !anchor_and_handle_toggled;
if initial_press {
responses.add(PathToolMessage::SelectedPointUpdated);
tool_data.select_anchor_toggled = true;
tool_data.save_points_before_anchor_toggle(shape_editor.selected_points().cloned().collect());
shape_editor.select_handles_and_anchor_connected_to_current_handle(&document.network_interface);
} else if released_from_toggle {
responses.add(PathToolMessage::SelectedPointUpdated);
tool_data.select_anchor_toggled = false;
shape_editor.deselect_all_points();
shape_editor.select_points_by_manipulator_id(&tool_data.saved_points_before_anchor_select_toggle);
tool_data.remove_saved_points();
}
let toggle_colinear_state = input.keyboard.get(toggle_colinear as usize);
let equidistant_state = input.keyboard.get(equidistant as usize);
let lock_angle_state = input.keyboard.get(lock_angle as usize);
let snap_angle_state = input.keyboard.get(snap_angle as usize);
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,
lock_angle_state,
snap_angle_state,
tool_action_data.shape_editor,
tool_action_data.document,
input,
responses,
);
}
// Auto-panning
let messages = [
PathToolMessage::PointerOutsideViewport {
toggle_colinear,
equidistant,
move_anchor_with_handles,
snap_angle,
lock_angle,
}
.into(),
PathToolMessage::PointerMove {
toggle_colinear,
equidistant,
move_anchor_with_handles,
snap_angle,
lock_angle,
}
.into(),
];
tool_data.auto_panning.setup_by_mouse_position(input, &messages, responses);
PathToolFsmState::Dragging(tool_data.dragging_state)
}
(PathToolFsmState::Drawing { selection_shape: selection_type }, PathToolMessage::PointerOutsideViewport { .. }) => {
// Auto-panning
if let Some(offset) = tool_data.auto_panning.shift_viewport(input, responses) {
tool_data.drag_start_pos += offset;
}
PathToolFsmState::Drawing { selection_shape: selection_type }
}
(
PathToolFsmState::Dragging(dragging_state),
PathToolMessage::PointerOutsideViewport {
equidistant, snap_angle, lock_angle, ..
},
) => {
// Auto-panning
if tool_data.auto_panning.shift_viewport(input, responses).is_some() {
let equidistant = input.keyboard.get(equidistant as usize);
let snap_angle = input.keyboard.get(snap_angle as usize);
let lock_angle = input.keyboard.get(lock_angle as usize);
tool_data.drag(equidistant, lock_angle, snap_angle, shape_editor, document, input, responses);
}
PathToolFsmState::Dragging(dragging_state)
}
(
state,
PathToolMessage::PointerOutsideViewport {
equidistant,
toggle_colinear,
move_anchor_with_handles,
snap_angle,
lock_angle,
},
) => {
// Auto-panning
let messages = [
PathToolMessage::PointerOutsideViewport {
equidistant,
toggle_colinear,
move_anchor_with_handles,
snap_angle,
lock_angle,
}
.into(),
PathToolMessage::PointerMove {
equidistant,
toggle_colinear,
move_anchor_with_handles,
snap_angle,
lock_angle,
}
.into(),
];
tool_data.auto_panning.stop(&messages, responses);
state
}
(PathToolFsmState::Drawing { selection_shape }, PathToolMessage::Enter { extend_selection, shrink_selection }) => {
let extend_selection = input.keyboard.get(extend_selection as usize);
let shrink_selection = input.keyboard.get(shrink_selection as usize);
let selection_change = if shrink_selection {
SelectionChange::Shrink
} else if extend_selection {
SelectionChange::Extend
} else {
SelectionChange::Clear
};
if tool_data.drag_start_pos == tool_data.previous_mouse_position {
responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![] });
} else {
match selection_shape {
SelectionShapeType::Box => {
let bbox = [tool_data.drag_start_pos, tool_data.previous_mouse_position];
shape_editor.select_all_in_shape(&document.network_interface, SelectionShape::Box(bbox), selection_change);
}
SelectionShapeType::Lasso => shape_editor.select_all_in_shape(&document.network_interface, SelectionShape::Lasso(&tool_data.lasso_polygon), selection_change),
}
}
responses.add(OverlaysMessage::Draw);
PathToolFsmState::Ready
}
(PathToolFsmState::Dragging { .. }, PathToolMessage::Escape | PathToolMessage::RightClick) => {
responses.add(DocumentMessage::AbortTransaction);
tool_data.snap_manager.cleanup(responses);
PathToolFsmState::Ready
}
(PathToolFsmState::Drawing { .. }, PathToolMessage::Escape | PathToolMessage::RightClick) => {
tool_data.snap_manager.cleanup(responses);
PathToolFsmState::Ready
}
// Mouse up
(PathToolFsmState::Drawing { selection_shape }, PathToolMessage::DragStop { extend_selection, shrink_selection }) => {
let extend_selection = input.keyboard.get(extend_selection as usize);
let shrink_selection = input.keyboard.get(shrink_selection as usize);
let select_kind = if shrink_selection {
SelectionChange::Shrink
} else if extend_selection {
SelectionChange::Extend
} else {
SelectionChange::Clear
};
if tool_data.drag_start_pos == tool_data.previous_mouse_position {
responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![] });
} else {
match selection_shape {
SelectionShapeType::Box => {
let bbox = [tool_data.drag_start_pos, tool_data.previous_mouse_position];
shape_editor.select_all_in_shape(&document.network_interface, SelectionShape::Box(bbox), select_kind);
}
SelectionShapeType::Lasso => shape_editor.select_all_in_shape(&document.network_interface, SelectionShape::Lasso(&tool_data.lasso_polygon), select_kind),
}
}
responses.add(OverlaysMessage::Draw);
responses.add(PathToolMessage::SelectedPointUpdated);
PathToolFsmState::Ready
}
(_, PathToolMessage::DragStop { extend_selection, .. }) => {
if tool_data.select_anchor_toggled {
shape_editor.deselect_all_points();
shape_editor.select_points_by_manipulator_id(&tool_data.saved_points_before_anchor_select_toggle);
tool_data.remove_saved_points();
tool_data.select_anchor_toggled = false;
}
let extend_selection = input.keyboard.get(extend_selection as usize);
let nearest_point = shape_editor.find_nearest_point_indices(&document.network_interface, input.mouse.position, SELECTION_THRESHOLD);
if let Some((layer, nearest_point)) = nearest_point {
if tool_data.drag_start_pos.distance(input.mouse.position) <= DRAG_THRESHOLD && !extend_selection {
let clicked_selected = shape_editor.selected_points().any(|&point| nearest_point == point);
if clicked_selected {
shape_editor.deselect_all_points();
shape_editor.selected_shape_state.entry(layer).or_default().select_point(nearest_point);
responses.add(OverlaysMessage::Draw);
}
}
}
// Deselect all points if the user clicks the filled region of the shape
else if tool_data.drag_start_pos.distance(input.mouse.position) <= DRAG_THRESHOLD {
shape_editor.deselect_all_points();
}
responses.add(DocumentMessage::EndTransaction);
responses.add(PathToolMessage::SelectedPointUpdated);
tool_data.snap_manager.cleanup(responses);
PathToolFsmState::Ready
}
// Delete key
(_, PathToolMessage::Delete) => {
// Delete the selected points and clean up overlays
responses.add(DocumentMessage::AddTransaction);
shape_editor.delete_selected_points(document, responses);
responses.add(PathToolMessage::SelectionChanged);
PathToolFsmState::Ready
}
(_, PathToolMessage::BreakPath) => {
shape_editor.break_path_at_selected_point(document, responses);
PathToolFsmState::Ready
}
(_, PathToolMessage::DeleteAndBreakPath) => {
shape_editor.delete_point_and_break_path(document, responses);
PathToolFsmState::Ready
}
(_, PathToolMessage::FlipSmoothSharp) => {
// Double-clicked on a point
let nearest_point = shape_editor.find_nearest_point_indices(&document.network_interface, input.mouse.position, SELECTION_THRESHOLD);
if nearest_point.is_some() {
// 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.flip_smooth_sharp(&document.network_interface, input.mouse.position, SELECTION_TOLERANCE, responses);
responses.add(DocumentMessage::EndTransaction);
responses.add(PathToolMessage::SelectedPointUpdated);
}
return PathToolFsmState::Ready;
}
// Double-clicked on a filled region
if let Some(layer) = document.click(input) {
// Select all points in the layer
shape_editor.select_connected_anchors(document, layer, input.mouse.position);
}
PathToolFsmState::Ready
}
(_, PathToolMessage::Abort) => {
responses.add(OverlaysMessage::Draw);
PathToolFsmState::Ready
}
(_, PathToolMessage::PointerMove { .. }) => self,
(_, PathToolMessage::NudgeSelectedPoints { delta_x, delta_y }) => {
shape_editor.move_selected_points(tool_data.opposing_handle_lengths.take(), document, (delta_x, delta_y).into(), true, responses, false);
PathToolFsmState::Ready
}
(_, PathToolMessage::SelectAllAnchors) => {
shape_editor.select_all_anchors_in_selected_layers(document);
responses.add(OverlaysMessage::Draw);
PathToolFsmState::Ready
}
(_, PathToolMessage::DeselectAllPoints) => {
shape_editor.deselect_all_points();
responses.add(OverlaysMessage::Draw);
PathToolFsmState::Ready
}
(_, PathToolMessage::SelectedPointXChanged { new_x }) => {
if let Some(&SingleSelectedPoint { coordinates, id, layer, .. }) = tool_data.selection_status.as_one() {
shape_editor.reposition_control_point(&id, &document.network_interface, DVec2::new(new_x, coordinates.y), layer, responses);
}
PathToolFsmState::Ready
}
(_, PathToolMessage::SelectedPointYChanged { new_y }) => {
if let Some(&SingleSelectedPoint { coordinates, id, layer, .. }) = tool_data.selection_status.as_one() {
shape_editor.reposition_control_point(&id, &document.network_interface, DVec2::new(coordinates.x, new_y), layer, responses);
}
PathToolFsmState::Ready
}
(_, PathToolMessage::SelectedPointUpdated) => {
let colinear = shape_editor.selected_manipulator_angles(&document.network_interface);
tool_data.dragging_state = DraggingState {
point_select_state: shape_editor.get_dragging_state(&document.network_interface),
colinear,
};
tool_data.selection_status = get_selection_status(&document.network_interface, shape_editor);
self
}
(_, PathToolMessage::ManipulatorMakeHandlesColinear) => {
responses.add(DocumentMessage::StartTransaction);
shape_editor.convert_selected_manipulators_to_colinear_handles(responses, document);
responses.add(DocumentMessage::EndTransaction);
responses.add(PathToolMessage::SelectionChanged);
PathToolFsmState::Ready
}
(_, PathToolMessage::ManipulatorMakeHandlesFree) => {
responses.add(DocumentMessage::StartTransaction);
shape_editor.disable_colinear_handles_state_on_selected(&document.network_interface, responses);
responses.add(DocumentMessage::EndTransaction);
PathToolFsmState::Ready
}
(_, _) => PathToolFsmState::Ready,
}
}
fn update_hints(&self, responses: &mut VecDeque<Message>) {
let hint_data = match self {
PathToolFsmState::Ready => HintData(vec![
HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Select Point"), HintInfo::keys([Key::Shift], "Extend Selection").prepend_plus()]),
HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Select Area"), HintInfo::keys([Key::Control], "Lasso").prepend_plus()]),
HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Insert Point on Segment")]),
// TODO: Only show if at least one anchor is selected, and dynamically show either "Smooth" or "Sharp" based on the current state
HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDouble, "Make Anchor Smooth/Sharp")]),
// TODO: Only show the following hints if at least one point is selected
HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Drag Selected")]),
HintGroup(vec![HintInfo::multi_keys([[Key::KeyG], [Key::KeyR], [Key::KeyS]], "Grab/Rotate/Scale Selected")]),
HintGroup(vec![HintInfo::arrow_keys("Nudge Selected"), HintInfo::keys([Key::Shift], "10x").prepend_plus()]),
HintGroup(vec![
HintInfo::keys([Key::Delete], "Delete Selected"),
// TODO: Only show the following hints if at least one anchor is selected
HintInfo::keys([Key::Accel], "No Dissolve").prepend_plus(),
HintInfo::keys([Key::Shift], "Cut Anchor").prepend_plus(),
]),
]),
PathToolFsmState::Dragging(dragging_state) => {
let colinear = dragging_state.colinear;
let mut dragging_hint_data = HintData(Vec::new());
dragging_hint_data
.0
.push(HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()]));
let drag_anchor = HintInfo::keys([Key::Space], "Drag Anchor");
let point_select_state_hint_group = match dragging_state.point_select_state {
PointSelectState::HandleNoPair => {
let mut hints = vec![drag_anchor];
hints.push(HintInfo::keys([Key::Shift], "Snap 15°"));
hints.push(HintInfo::keys([Key::Control], "Lock Angle"));
hints
}
PointSelectState::HandleWithPair => {
let mut hints = vec![drag_anchor];
hints.push(HintInfo::keys([Key::Tab], "Swap Selected Handles"));
hints.push(HintInfo::keys(
[Key::KeyC],
if colinear == ManipulatorAngle::Colinear {
"Break Colinear Handles"
} else {
"Make Handles Colinear"
},
));
if colinear != ManipulatorAngle::Free {
hints.push(HintInfo::keys([Key::Alt], "Equidistant Handles"));
}
hints.push(HintInfo::keys([Key::Shift], "Snap 15°"));
hints.push(HintInfo::keys([Key::Control], "Lock Angle"));
hints
}
PointSelectState::Anchor => Vec::new(),
};
if !point_select_state_hint_group.is_empty() {
dragging_hint_data.0.push(HintGroup(point_select_state_hint_group));
}
dragging_hint_data
}
PathToolFsmState::Drawing { .. } => HintData(vec![
HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()]),
HintGroup(vec![
HintInfo::mouse(MouseMotion::LmbDrag, "Select Area"),
HintInfo::keys([Key::Shift], "Extend").prepend_plus(),
HintInfo::keys([Key::Alt], "Subtract").prepend_plus(),
]),
]),
PathToolFsmState::InsertPoint => HintData(vec![
HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()]),
HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Insert Point")]),
]),
};
responses.add(FrontendMessage::UpdateInputHints { hint_data });
}
fn update_cursor(&self, responses: &mut VecDeque<Message>) {
responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::Default });
}
}
#[derive(Debug, PartialEq, Default)]
enum SelectionStatus {
#[default]
None,
One(SingleSelectedPoint),
Multiple(MultipleSelectedPoints),
}
impl SelectionStatus {
fn is_none(&self) -> bool {
self == &SelectionStatus::None
}
fn as_one(&self) -> Option<&SingleSelectedPoint> {
match self {
SelectionStatus::One(one) => Some(one),
_ => None,
}
}
fn angle(&self) -> Option<ManipulatorAngle> {
match self {
Self::None => None,
Self::One(one) => Some(one.manipulator_angle),
Self::Multiple(one) => Some(one.manipulator_angle),
}
}
}
#[derive(Debug, PartialEq)]
struct MultipleSelectedPoints {
manipulator_angle: ManipulatorAngle,
}
#[derive(Debug, PartialEq)]
struct SingleSelectedPoint {
coordinates: DVec2,
id: ManipulatorPointId,
layer: LayerNodeIdentifier,
manipulator_angle: ManipulatorAngle,
}
/// Sets the cumulative description of the selected points: if `None` are selected, if `One` is selected, or if `Multiple` are selected.
/// Applies to any selected points, whether they are anchors or handles; and whether they are from a single shape or across multiple shapes.
fn get_selection_status(network_interface: &NodeNetworkInterface, shape_state: &mut ShapeState) -> SelectionStatus {
let mut selection_layers = shape_state.selected_shape_state.iter().map(|(k, v)| (*k, v.selected_points_count()));
let total_selected_points = selection_layers.clone().map(|(_, v)| v).sum::<usize>();
// Check to see if only one manipulator group in a single shape is selected
if total_selected_points == 1 {
let Some(layer) = selection_layers.find(|(_, v)| *v > 0).map(|(k, _)| k) else {
return SelectionStatus::None;
};
let Some(vector_data) = network_interface.compute_modified_vector(layer) else {
return SelectionStatus::None;
};
let Some(&point) = shape_state.selected_points().next() else {
return SelectionStatus::None;
};
let Some(local_position) = point.get_position(&vector_data) else {
return SelectionStatus::None;
};
let coordinates = network_interface.document_metadata().transform_to_document(layer).transform_point2(local_position);
let manipulator_angle = if vector_data.colinear(point) { ManipulatorAngle::Colinear } else { ManipulatorAngle::Free };
return SelectionStatus::One(SingleSelectedPoint {
coordinates,
layer,
id: point,
manipulator_angle,
});
};
// Check to see if multiple manipulator groups are selected
if total_selected_points > 1 {
return SelectionStatus::Multiple(MultipleSelectedPoints {
manipulator_angle: shape_state.selected_manipulator_angles(network_interface),
});
}
SelectionStatus::None
}