Implement extending, joining, and creating new subpaths with the Spline tool (#2203)

* visualize spline end points using overlays

* implement for spline tool to extend path by draging end points

* allow holding Shift to begin drawing a new spline subpath in the same layer

* implement spline tool to join two endpoints

* fix naming

* refactor spline tool

* impl spline tool snapping and overlays

* fix joining path and refactor

* improve join_path comment

* fix snapping overlays flickering by ignoring snapping in current layer

* fix inserting single point on aborting spline tool

* add snapping for endpoint even when regular snapping is disabled

* fix extending

* fix inserting new point instead of extending and Add hint for Shift to append

* fix grammatical errors and code style

* Code review

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
Priyanshu 2025-01-25 16:54:08 +05:30 committed by GitHub
parent 33ac141fb8
commit f7b7f6b9f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 163 additions and 36 deletions

View file

@ -71,6 +71,9 @@ pub const HANDLE_ROTATE_SNAP_ANGLE: f64 = 15.;
// Pen tool
pub const CREATE_CURVE_THRESHOLD: f64 = 5.;
// Spline tool
pub const PATH_JOIN_THRESHOLD: f64 = 5.;
// Line tool
pub const LINE_ROTATE_SNAP_ANGLE: f64 = 15.;

View file

@ -270,7 +270,7 @@ pub fn input_mappings() -> Mapping {
//
// SplineToolMessage
entry!(PointerMove; action_dispatch=SplineToolMessage::PointerMove),
entry!(KeyDown(MouseLeft); action_dispatch=SplineToolMessage::DragStart),
entry!(KeyDown(MouseLeft); action_dispatch=SplineToolMessage::DragStart { append_to_selected: Shift }),
entry!(KeyUp(MouseLeft); action_dispatch=SplineToolMessage::DragStop),
entry!(KeyDown(MouseRight); action_dispatch=SplineToolMessage::Confirm),
entry!(KeyDown(Escape); action_dispatch=SplineToolMessage::Confirm),

View file

@ -7,14 +7,32 @@ use glam::DVec2;
/// 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(document: &DocumentMessageHandler, goal: DVec2, tolerance: f64, layers: impl Iterator<Item = LayerNodeIdentifier>) -> Option<(LayerNodeIdentifier, PointId, DVec2)> {
closest_point(document, goal, tolerance, layers, |_| false)
}
/// Determine the closest point to the goal point under max_distance.
/// Additionally exclude checking closeness to the point which given to exclude() returns true.
pub fn closest_point<T>(
document: &DocumentMessageHandler,
goal: DVec2,
max_distance: f64,
layers: impl Iterator<Item = LayerNodeIdentifier>,
exclude: T,
) -> Option<(LayerNodeIdentifier, PointId, DVec2)>
where
T: Fn(PointId) -> bool,
{
let mut best = None;
let mut best_distance_squared = tolerance * tolerance;
let mut best_distance_squared = max_distance * max_distance;
for layer in layers {
let viewspace = document.metadata().transform_to_viewport(layer);
let Some(vector_data) = document.network_interface.compute_modified_vector(layer) else {
continue;
};
for id in vector_data.single_connected_points() {
if exclude(id) {
continue;
}
let Some(point) = vector_data.point_domain.position_from_id(id) else { continue };
let distance_squared = viewspace.transform_point2(point).distance_squared(goal);

View file

@ -1061,7 +1061,7 @@ impl Fsm for PathToolFsmState {
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], "Break Anchor").prepend_plus(),
HintInfo::keys([Key::Shift], "Cut Anchor").prepend_plus(),
]),
]),
PathToolFsmState::Dragging(dragging_state) => {

View file

@ -1,11 +1,14 @@
use super::tool_prelude::*;
use crate::consts::{DEFAULT_STROKE_WIDTH, DRAG_THRESHOLD};
use crate::consts::{DEFAULT_STROKE_WIDTH, DRAG_THRESHOLD, PATH_JOIN_THRESHOLD, SNAP_POINT_TOLERANCE};
use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_node_type;
use crate::messages::portfolio::document::overlays::utility_functions::path_endpoint_overlays;
use crate::messages::portfolio::document::overlays::utility_types::OverlayContext;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::tool::common_functionality::auto_panning::AutoPanning;
use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, ToolColorType};
use crate::messages::tool::common_functionality::graph_modification_utils;
use crate::messages::tool::common_functionality::snapping::SnapManager;
use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapData, SnapManager, SnapTypeConfiguration, SnappedPoint};
use crate::messages::tool::common_functionality::utility_functions::{closest_point, should_extend};
use graph_craft::document::{NodeId, NodeInput};
use graphene_core::Color;
@ -38,13 +41,14 @@ impl Default for SplineOptions {
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
pub enum SplineToolMessage {
// Standard messages
Overlays(OverlayContext),
CanvasTransformed,
Abort,
WorkingColorChanged,
// Tool-specific messages
Confirm,
DragStart,
DragStart { append_to_selected: Key },
DragStop,
PointerMove,
PointerOutsideViewport,
@ -152,6 +156,7 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionHandlerData<'a>> for SplineT
Undo,
DragStart,
DragStop,
PointerMove,
Confirm,
Abort,
),
@ -168,6 +173,7 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionHandlerData<'a>> for SplineT
impl ToolTransition for SplineTool {
fn event_to_message_map(&self) -> EventToMessageMap {
EventToMessageMap {
overlay_provider: Some(|overlay_context: OverlayContext| SplineToolMessage::Overlays(overlay_context).into()),
canvas_transformed: Some(SplineToolMessage::CanvasTransformed.into()),
tool_abort: Some(SplineToolMessage::Abort.into()),
working_color_changed: Some(SplineToolMessage::WorkingColorChanged.into()),
@ -178,7 +184,7 @@ impl ToolTransition for SplineTool {
#[derive(Clone, Debug, Default)]
struct SplineToolData {
/// Points that are inserted.
/// List of points inserted.
points: Vec<(PointId, DVec2)>,
/// Point to be inserted.
next_point: DVec2,
@ -186,32 +192,94 @@ struct SplineToolData {
preview_point: Option<PointId>,
/// Segment that was inserted temporarily to show preview.
preview_segment: Option<SegmentId>,
extend: bool,
weight: f64,
layer: Option<LayerNodeIdentifier>,
snap_manager: SnapManager,
auto_panning: AutoPanning,
}
impl SplineToolData {
fn cleanup(&mut self) {
self.layer = None;
self.preview_point = None;
self.preview_segment = None;
self.extend = false;
self.points = Vec::new();
}
/// Get the snapped point while ignoring current layer
fn snapped_point(&mut self, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler) -> SnappedPoint {
let point = SnapCandidatePoint::handle(document.metadata().document_to_viewport.inverse().transform_point2(input.mouse.position));
let ignore = if let Some(layer) = self.layer { vec![layer] } else { vec![] };
let snap_data = SnapData::ignore(document, input, &ignore);
self.snap_manager.free_snap(&snap_data, &point, SnapTypeConfiguration::default())
}
}
impl Fsm for SplineToolFsmState {
type ToolData = SplineToolData;
type ToolOptions = SplineOptions;
fn transition(self, event: ToolMessage, tool_data: &mut Self::ToolData, tool_action_data: &mut ToolActionHandlerData, tool_options: &Self::ToolOptions, responses: &mut VecDeque<Message>) -> Self {
let ToolActionHandlerData {
document, global_tool_data, input, ..
document,
global_tool_data,
input,
shape_editor,
..
} = tool_action_data;
let ToolMessage::Spline(event) = event else { return self };
match (self, event) {
(_, SplineToolMessage::CanvasTransformed) => self,
(SplineToolFsmState::Ready, SplineToolMessage::DragStart) => {
(_, SplineToolMessage::Overlays(mut overlay_context)) => {
path_endpoint_overlays(document, shape_editor, &mut overlay_context);
tool_data.snap_manager.draw_overlays(SnapData::new(document, input), &mut overlay_context);
self
}
(SplineToolFsmState::Ready, SplineToolMessage::DragStart { append_to_selected }) => {
responses.add(DocumentMessage::StartTransaction);
tool_data.cleanup();
tool_data.weight = tool_options.line_weight;
let point = SnapCandidatePoint::handle(document.metadata().document_to_viewport.inverse().transform_point2(input.mouse.position));
let snapped = tool_data.snap_manager.free_snap(&SnapData::new(document, input), &point, SnapTypeConfiguration::default());
let viewport = document.metadata().document_to_viewport.transform_point2(snapped.snapped_point_document);
// Extend an endpoint of the selected path
let selected_nodes = document.network_interface.selected_nodes(&[]).unwrap();
if let Some((layer, point, position)) = should_extend(document, viewport, SNAP_POINT_TOLERANCE, selected_nodes.selected_layers(document.metadata())) {
tool_data.layer = Some(layer);
tool_data.points.push((point, position));
tool_data.next_point = position;
tool_data.extend = true;
extend_spline(tool_data, true, responses);
return SplineToolFsmState::Drawing;
}
// Create new path in the same layer when shift is down
if input.keyboard.key(append_to_selected) {
let mut selected_layers_except_artboards = selected_nodes.selected_layers_except_artboards(&document.network_interface);
let existing_layer = selected_layers_except_artboards.next().filter(|_| selected_layers_except_artboards.next().is_none());
if let Some(layer) = existing_layer {
tool_data.layer = Some(layer);
let transform = document.metadata().transform_to_viewport(layer);
let position = transform.inverse().transform_point2(input.mouse.position);
tool_data.next_point = position;
return SplineToolFsmState::Drawing;
}
}
responses.add(DocumentMessage::DeselectAllLayers);
let parent = document.new_layer_bounding_artboard(input);
tool_data.weight = tool_options.line_weight;
let path_node_type = resolve_document_node_type("Path").expect("Path node does not exist");
let path_node = path_node_type.default_node_template();
let spline_node_type = resolve_document_node_type("Splines from Points").expect("Spline from Points node does not exist");
@ -228,33 +296,41 @@ impl Fsm for SplineToolFsmState {
SplineToolFsmState::Drawing
}
(SplineToolFsmState::Drawing, SplineToolMessage::DragStop) => {
responses.add(DocumentMessage::EndTransaction);
let Some(layer) = tool_data.layer else {
// The first DragStop event will be ignored to prevent insertion of new point.
if tool_data.extend {
tool_data.extend = false;
return SplineToolFsmState::Drawing;
}
if tool_data.layer.is_none() {
return SplineToolFsmState::Ready;
};
let snapped_position = input.mouse.position;
let transform = document.metadata().transform_to_viewport(layer);
let pos = transform.inverse().transform_point2(snapped_position);
if tool_data.points.last().map_or(true, |last_pos| last_pos.1.distance(pos) > DRAG_THRESHOLD) {
tool_data.next_point = pos;
if join_path(document, input.mouse.position, tool_data, responses) {
responses.add(DocumentMessage::EndTransaction);
return SplineToolFsmState::Ready;
}
tool_data.next_point = tool_data.snapped_point(document, input).snapped_point_document;
if tool_data.points.last().map_or(true, |last_pos| last_pos.1.distance(tool_data.next_point) > DRAG_THRESHOLD) {
extend_spline(tool_data, false, responses);
}
update_spline(tool_data, false, responses);
SplineToolFsmState::Drawing
}
(SplineToolFsmState::Drawing, SplineToolMessage::PointerMove) => {
let Some(layer) = tool_data.layer else {
return SplineToolFsmState::Ready;
};
let snapped_position = input.mouse.position; // tool_data.snap_manager.snap_position(responses, document, input.mouse.position);
let transform = document.metadata().transform_to_viewport(layer);
let pos = transform.inverse().transform_point2(snapped_position);
tool_data.next_point = pos;
let Some(layer) = tool_data.layer else { return SplineToolFsmState::Ready };
let ignore = |cp: PointId| tool_data.preview_point.is_some_and(|pp| pp == cp) || tool_data.points.last().is_some_and(|(ep, _)| *ep == cp);
let join_point = closest_point(document, input.mouse.position, PATH_JOIN_THRESHOLD, vec![layer].into_iter(), ignore);
update_spline(tool_data, true, responses);
// Endpoints snapping
if let Some((_, _, point)) = join_point {
tool_data.next_point = point;
tool_data.snap_manager.clear_indicator();
} else {
let snapped_point = tool_data.snapped_point(document, input);
tool_data.next_point = snapped_point.snapped_point_document;
tool_data.snap_manager.update_indicator(snapped_point);
}
extend_spline(tool_data, true, responses);
// Auto-panning
let messages = [SplineToolMessage::PointerOutsideViewport.into(), SplineToolMessage::PointerMove.into()];
@ -262,6 +338,11 @@ impl Fsm for SplineToolFsmState {
SplineToolFsmState::Drawing
}
(_, SplineToolMessage::PointerMove) => {
tool_data.snap_manager.preview_draw(&SnapData::new(document, input), input.mouse.position);
responses.add(OverlaysMessage::Draw);
self
}
(SplineToolFsmState::Drawing, SplineToolMessage::PointerOutsideViewport) => {
// Auto-panning
let _ = tool_data.auto_panning.shift_viewport(input, responses);
@ -283,11 +364,8 @@ impl Fsm for SplineToolFsmState {
responses.add(DocumentMessage::AbortTransaction);
}
tool_data.layer = None;
tool_data.preview_point = None;
tool_data.preview_segment = None;
tool_data.points.clear();
tool_data.snap_manager.cleanup(responses);
tool_data.cleanup();
SplineToolFsmState::Ready
}
@ -304,7 +382,10 @@ impl Fsm for SplineToolFsmState {
fn update_hints(&self, responses: &mut VecDeque<Message>) {
let hint_data = match self {
SplineToolFsmState::Ready => HintData(vec![HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Draw Spline")])]),
SplineToolFsmState::Ready => HintData(vec![HintGroup(vec![
HintInfo::mouse(MouseMotion::Lmb, "Draw Spline"),
HintInfo::keys([Key::Shift], "Append to Selected Layer").prepend_plus(),
])]),
SplineToolFsmState::Drawing => HintData(vec![
HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()]),
HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Extend Spline")]),
@ -320,7 +401,32 @@ impl Fsm for SplineToolFsmState {
}
}
fn update_spline(tool_data: &mut SplineToolData, show_preview: bool, responses: &mut VecDeque<Message>) {
/// Return `true` only if new segment is inserted to connect two end points in the selected layer otherwise `false`.
fn join_path(document: &DocumentMessageHandler, mouse_pos: DVec2, tool_data: &mut SplineToolData, responses: &mut VecDeque<Message>) -> bool {
let Some(&(endpoint, _)) = tool_data.points.last() else { return false };
let preview_point = tool_data.preview_point;
let selected_nodes = document.network_interface.selected_nodes(&[]).unwrap();
let selected_layers = selected_nodes.selected_layers(document.metadata());
// Get the closest point to mouse position which is not preview_point or end_point.
let closest_point = closest_point(document, mouse_pos, PATH_JOIN_THRESHOLD, selected_layers, |cp| {
preview_point.is_some_and(|pp| pp == cp) || cp == endpoint
});
let Some((layer, join_point, _)) = closest_point else { return false };
// Last end point inserted was the preview point and segment therefore we delete it before joining the end_point & join_point.
delete_preview(tool_data, responses);
let points = [endpoint, join_point];
let id = SegmentId::generate();
let modification_type = VectorModificationType::InsertSegment { id, points, handles: [None, None] };
responses.add(GraphOperationMessage::Vector { layer, modification_type });
true
}
fn extend_spline(tool_data: &mut SplineToolData, show_preview: bool, responses: &mut VecDeque<Message>) {
delete_preview(tool_data, responses);
let Some(layer) = tool_data.layer else { return };