mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
Extend open paths from endpoints with the Freehand tool and skip hidden layers in path edits (#4508)
* Let the Freehand tool continue a selected open path from its endpoint and show endpoint overlays in the Freehand and Spline tools * Share the closest-point search with the open path endpoint lookup, tally endpoint connections in a single pass, and rename the endpoint overlay function * Skip hidden layers and use the feeds-aware transform in path point searches, path overlays, and Freehand point placement * Map the Spline tool's merge goal with the feeds-aware transform so it matches the endpoint candidates * Highlight the hovered open path endpoint in the Freehand and Spline tools
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
use super::utility_types::{DrawHandles, OverlayContext};
|
||||
use crate::consts::HIDE_HANDLE_DISTANCE;
|
||||
use crate::consts::{HIDE_HANDLE_DISTANCE, SNAP_POINT_TOLERANCE};
|
||||
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
|
||||
pub use crate::messages::portfolio::document::utility_types::text_metrics::text_width;
|
||||
use crate::messages::tool::common_functionality::shape_editor::{SelectedLayerState, ShapeState};
|
||||
use crate::messages::tool::common_functionality::utility_functions::closest_open_path_endpoint;
|
||||
use crate::messages::tool::tool_messages::tool_prelude::DocumentMessageHandler;
|
||||
use glam::{DAffine2, DVec2};
|
||||
use graphene_std::vector::misc::{BezierHandles, ManipulatorPointId, point_to_dvec2, segment_to_handles};
|
||||
@@ -132,7 +133,7 @@ pub fn path_overlays(document: &DocumentMessageHandler, draw_handles: DrawHandle
|
||||
let display_handles = overlay_context.visibility_settings.handles();
|
||||
let display_anchors = overlay_context.visibility_settings.anchors();
|
||||
|
||||
for layer in document.network_interface.selected_nodes().selected_layers(document.metadata()) {
|
||||
for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) {
|
||||
let Some(vector) = document.network_interface.compute_modified_vector(layer) else { continue };
|
||||
let transform = document.metadata().transform_to_viewport_if_feeds(layer, &document.network_interface);
|
||||
if display_path {
|
||||
@@ -201,6 +202,43 @@ pub fn path_overlays(document: &DocumentMessageHandler, draw_handles: DrawHandle
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws an anchor overlay at each endpoint of every open path on the selected visible layers, in the selected style for endpoints that are part of the path editing selection.
|
||||
/// Given a pointer position, the endpoint a press there would continue from is drawn in the hover style instead.
|
||||
pub fn open_path_endpoint_overlays(document: &DocumentMessageHandler, shape_editor: &ShapeState, pointer: Option<DVec2>, overlay_context: &mut OverlayContext) {
|
||||
if !overlay_context.visibility_settings.anchors() {
|
||||
return;
|
||||
}
|
||||
|
||||
let selected_nodes = document.network_interface.selected_nodes();
|
||||
let is_selected = |layer: LayerNodeIdentifier, id: PointId| {
|
||||
shape_editor
|
||||
.selected_shape_state
|
||||
.get(&layer)
|
||||
.is_some_and(|state| state.is_point_selected(ManipulatorPointId::Anchor(id)))
|
||||
};
|
||||
let hovered = pointer.and_then(|pointer| closest_open_path_endpoint(document, pointer, SNAP_POINT_TOLERANCE, selected_nodes.selected_visible_layers(&document.network_interface)));
|
||||
|
||||
for layer in selected_nodes.selected_visible_layers(&document.network_interface) {
|
||||
let Some(vector) = document.network_interface.compute_modified_vector(layer) else { continue };
|
||||
let transform = document.metadata().transform_to_viewport_if_feeds(layer, &document.network_interface);
|
||||
|
||||
for id in vector.anchor_endpoints() {
|
||||
if hovered.is_some_and(|(hovered_layer, hovered_id, _)| hovered_layer == layer && hovered_id == id) {
|
||||
continue;
|
||||
}
|
||||
let Some(position) = vector.point_domain.position_from_id(id) else { continue };
|
||||
|
||||
overlay_context.manipulator_anchor(transform.transform_point2(position), is_selected(layer, id), None);
|
||||
}
|
||||
}
|
||||
|
||||
// Drawn last so its halo sits above any other endpoint at the same spot
|
||||
if let Some((layer, id, position)) = hovered {
|
||||
let transform = document.metadata().transform_to_viewport_if_feeds(layer, &document.network_interface);
|
||||
overlay_context.hover_manipulator_anchor(transform.transform_point2(position), is_selected(layer, id));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hex_to_rgba_u8(hex: &str) -> [u8; 4] {
|
||||
let hex = hex.trim().trim_start_matches('#');
|
||||
if hex.len() != 6 && hex.len() != 8 {
|
||||
|
||||
@@ -841,6 +841,13 @@ impl NodeNetworkInterface {
|
||||
self.query(network_path, "is_visible", |view| view.is_visible(node_id)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether a layer in the document network is visible, which also requires every ancestor to be visible.
|
||||
pub fn is_layer_visible(&self, layer: LayerNodeIdentifier) -> bool {
|
||||
layer
|
||||
.ancestors(self.document_metadata())
|
||||
.all(|ancestor| ancestor == LayerNodeIdentifier::ROOT_PARENT || self.is_visible(&ancestor.to_node(), &[]))
|
||||
}
|
||||
|
||||
pub fn is_layer(&self, node_id: &NodeId, network_path: &[NodeId]) -> bool {
|
||||
self.query(network_path, "is_layer", |view| view.is_layer(node_id)).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -52,13 +52,7 @@ pub struct SelectedNodes(pub Vec<NodeId>);
|
||||
|
||||
impl SelectedNodes {
|
||||
pub fn layer_visible(&self, layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> bool {
|
||||
layer.ancestors(network_interface.document_metadata()).all(|layer| {
|
||||
if layer != LayerNodeIdentifier::ROOT_PARENT {
|
||||
network_interface.is_visible(&layer.to_node(), &[])
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
network_interface.is_layer_visible(layer)
|
||||
}
|
||||
|
||||
pub fn selected_visible_layers<'a>(&'a self, network_interface: &'a NodeNetworkInterface) -> impl Iterator<Item = LayerNodeIdentifier> + 'a {
|
||||
|
||||
@@ -23,6 +23,12 @@ pub fn should_extend(document: &DocumentMessageHandler, goal: DVec2, tolerance:
|
||||
closest_point(document, goal, tolerance, layers, |_| false)
|
||||
}
|
||||
|
||||
/// Finds the endpoint of an open path closest to the goal (in viewport space) across the given layers, if one lies within the tolerance.
|
||||
/// Only anchors with a single connected segment qualify, so closed paths are never matched. Returns the endpoint's position in the layer's local space.
|
||||
pub fn closest_open_path_endpoint(document: &DocumentMessageHandler, goal: DVec2, tolerance: f64, layers: impl Iterator<Item = LayerNodeIdentifier>) -> Option<(LayerNodeIdentifier, PointId, DVec2)> {
|
||||
closest_candidate_point(document, goal, tolerance, layers, |vector| vector.anchor_endpoints().collect())
|
||||
}
|
||||
|
||||
/// 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>(
|
||||
@@ -35,19 +41,32 @@ pub fn closest_point<T>(
|
||||
where
|
||||
T: Fn(PointId) -> bool,
|
||||
{
|
||||
closest_candidate_point(document, goal, max_distance, layers, |vector| vector.anchor_points().filter(|&id| !exclude(id)).collect())
|
||||
}
|
||||
|
||||
/// Determines the closest of each visible layer's candidate points to the goal (in viewport space) under max_distance. Returns the point's position in the layer's local space.
|
||||
fn closest_candidate_point(
|
||||
document: &DocumentMessageHandler,
|
||||
goal: DVec2,
|
||||
max_distance: f64,
|
||||
layers: impl Iterator<Item = LayerNodeIdentifier>,
|
||||
candidates: impl Fn(&Vector) -> Vec<PointId>,
|
||||
) -> Option<(LayerNodeIdentifier, PointId, DVec2)> {
|
||||
let mut best = None;
|
||||
let mut best_distance_squared = max_distance * max_distance;
|
||||
|
||||
for layer in layers {
|
||||
let viewspace = document.metadata().transform_to_viewport(layer);
|
||||
if !document.network_interface.is_layer_visible(layer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let viewspace = document.metadata().transform_to_viewport_if_feeds(layer, &document.network_interface);
|
||||
let Some(vector) = document.network_interface.compute_modified_vector(layer) else { continue };
|
||||
for id in vector.anchor_points() {
|
||||
if exclude(id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for id in candidates(&vector) {
|
||||
let Some(point) = vector.point_domain.position_from_id(id) else { continue };
|
||||
|
||||
let distance_squared = viewspace.transform_point2(point).distance_squared(goal);
|
||||
|
||||
if distance_squared < best_distance_squared {
|
||||
best = Some((layer, id, point));
|
||||
best_distance_squared = distance_squared;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::tool_prelude::*;
|
||||
use crate::consts::SNAP_POINT_TOLERANCE;
|
||||
use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_network_node_type;
|
||||
use crate::messages::portfolio::document::overlays::utility_functions::open_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::color_selector::{
|
||||
DrawingToolState, apply_fill_color_pick, apply_fill_enabled, apply_stroke_color_pick, apply_stroke_enabled, apply_working_colors, reset_colors_on_deactivation, swap_fill_and_stroke,
|
||||
@@ -8,6 +11,7 @@ use crate::messages::tool::common_functionality::color_selector::{
|
||||
use crate::messages::tool::common_functionality::graph_modification_utils;
|
||||
use crate::messages::tool::common_functionality::resize::translation_transform_set;
|
||||
use crate::messages::tool::common_functionality::stroke_options::{StrokeOptionsUpdate, apply_stroke_option, create_stroke_options_popover_widget};
|
||||
use crate::messages::tool::common_functionality::utility_functions::closest_open_path_endpoint;
|
||||
use glam::DVec2;
|
||||
use graph_craft::document::NodeId;
|
||||
use graphene_std::Color;
|
||||
@@ -39,6 +43,7 @@ impl Default for FreehandOptions {
|
||||
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub enum FreehandToolMessage {
|
||||
// Standard messages
|
||||
Overlays { context: OverlayContext },
|
||||
Abort,
|
||||
SelectionChanged,
|
||||
WorkingColorChanged,
|
||||
@@ -196,6 +201,7 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Free
|
||||
FreehandToolFsmState::Ready => actions!(FreehandToolMessageDiscriminant;
|
||||
DragStart,
|
||||
DragStop,
|
||||
PointerMove,
|
||||
),
|
||||
FreehandToolFsmState::Drawing => actions!(FreehandToolMessageDiscriminant;
|
||||
DragStop,
|
||||
@@ -209,6 +215,7 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Free
|
||||
impl ToolTransition for FreehandTool {
|
||||
fn event_to_message_map(&self) -> EventToMessageMap {
|
||||
EventToMessageMap {
|
||||
overlay_provider: Some(|context: OverlayContext| FreehandToolMessage::Overlays { context }.into()),
|
||||
tool_abort: Some(FreehandToolMessage::Abort.into()),
|
||||
selection_changed: Some(FreehandToolMessage::SelectionChanged.into()),
|
||||
graph_changed: Some(FreehandToolMessage::SelectionChanged.into()),
|
||||
@@ -240,10 +247,25 @@ impl Fsm for FreehandToolFsmState {
|
||||
tool_options: &Self::ToolOptions,
|
||||
responses: &mut VecDeque<Message>,
|
||||
) -> Self {
|
||||
let ToolActionMessageContext { document, input, viewport, .. } = tool_action_data;
|
||||
let ToolActionMessageContext {
|
||||
document,
|
||||
input,
|
||||
viewport,
|
||||
shape_editor,
|
||||
..
|
||||
} = tool_action_data;
|
||||
|
||||
let ToolMessage::Freehand(event) = event else { return self };
|
||||
match (self, event) {
|
||||
(_, FreehandToolMessage::Overlays { context: mut overlay_context }) => {
|
||||
let pointer = (self == FreehandToolFsmState::Ready).then_some(input.mouse.position);
|
||||
open_path_endpoint_overlays(document, shape_editor, pointer, &mut overlay_context);
|
||||
self
|
||||
}
|
||||
(FreehandToolFsmState::Ready, FreehandToolMessage::PointerMove) => {
|
||||
responses.add(OverlaysMessage::Draw);
|
||||
self
|
||||
}
|
||||
(FreehandToolFsmState::Ready, FreehandToolMessage::DragStart { append_to_selected }) => {
|
||||
responses.add(DocumentMessage::StartTransaction);
|
||||
|
||||
@@ -251,14 +273,25 @@ impl Fsm for FreehandToolFsmState {
|
||||
tool_data.end_point = None;
|
||||
tool_data.new_layer_viewport_start = None;
|
||||
|
||||
// Pressing on the endpoint of a selected open path continues that path in place, keeping the layer, its selection, and its style
|
||||
let selected_nodes = document.network_interface.selected_nodes();
|
||||
let selected_visible_layers = selected_nodes.selected_visible_layers(&document.network_interface);
|
||||
if let Some((layer, endpoint, position)) = closest_open_path_endpoint(document, input.mouse.position, SNAP_POINT_TOLERANCE, selected_visible_layers) {
|
||||
tool_data.layer = Some(layer);
|
||||
tool_data.end_point = Some((position, endpoint));
|
||||
|
||||
return FreehandToolFsmState::Drawing;
|
||||
}
|
||||
|
||||
if input.keyboard.key(append_to_selected) {
|
||||
let selected_nodes = document.network_interface.selected_nodes();
|
||||
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());
|
||||
let mut appendable_layers = selected_nodes
|
||||
.selected_visible_layers(&document.network_interface)
|
||||
.filter(|layer| !document.network_interface.is_artboard(&layer.to_node(), &[]));
|
||||
let existing_layer = appendable_layers.next().filter(|_| appendable_layers.next().is_none());
|
||||
if let Some(layer) = existing_layer {
|
||||
tool_data.layer = Some(layer);
|
||||
|
||||
let transform = document.metadata().transform_to_viewport(layer);
|
||||
let transform = document.metadata().transform_to_viewport_if_feeds(layer, &document.network_interface);
|
||||
let position = transform.inverse().transform_point2(input.mouse.position);
|
||||
|
||||
extend_path_with_next_segment(tool_data, position, false, responses);
|
||||
@@ -291,7 +324,7 @@ impl Fsm for FreehandToolFsmState {
|
||||
}
|
||||
(FreehandToolFsmState::Drawing, FreehandToolMessage::PointerMove) => {
|
||||
if let Some(layer) = tool_data.layer {
|
||||
let transform = document.metadata().transform_to_viewport(layer);
|
||||
let transform = document.metadata().transform_to_viewport_if_feeds(layer, &document.network_interface);
|
||||
|
||||
// For newly created layers, the deferred TransformSet may not yet be reflected
|
||||
// in the metadata, so compute local position from the known viewport start.
|
||||
@@ -401,7 +434,7 @@ mod test_freehand {
|
||||
use crate::messages::tool::common_functionality::stroke_options::StrokeOptionsUpdate;
|
||||
use crate::messages::tool::tool_messages::freehand_tool::FreehandOptionsUpdate;
|
||||
use crate::test_utils::test_prelude::*;
|
||||
use glam::{DAffine2, DVec2};
|
||||
use glam::{DAffine2, DMat2, DVec2};
|
||||
use graphene_std::vector::Vector;
|
||||
|
||||
async fn get_vector_and_transform_list(editor: &mut EditorTestUtils) -> Vec<(Vector, DAffine2)> {
|
||||
@@ -651,4 +684,274 @@ mod test_freehand {
|
||||
stroke_width.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
/// Draws a Freehand stroke through the given viewport positions and returns the layer it created, which the tool leaves selected.
|
||||
async fn draw_freehand_stroke(editor: &mut EditorTestUtils, points: &[DVec2]) -> LayerNodeIdentifier {
|
||||
editor.select_tool(ToolType::Freehand).await;
|
||||
editor.drag_path(points, ModifierKeys::empty()).await;
|
||||
editor.get_selected_layer().await.expect("The Freehand stroke should create and select a layer")
|
||||
}
|
||||
|
||||
fn layer_count(editor: &EditorTestUtils) -> usize {
|
||||
editor.active_document().metadata().all_layers().count()
|
||||
}
|
||||
|
||||
fn point_and_segment_counts(editor: &EditorTestUtils, layer: LayerNodeIdentifier) -> (usize, usize) {
|
||||
let vector = editor.active_document().network_interface.compute_modified_vector(layer).expect("Layer should have vector geometry");
|
||||
(vector.point_domain.ids().len(), vector.segment_domain.ids().len())
|
||||
}
|
||||
|
||||
/// The viewport positions of the layer's anchors, split into the endpoints of its open paths and every other anchor.
|
||||
fn endpoint_and_other_anchor_viewport_positions(editor: &EditorTestUtils, layer: LayerNodeIdentifier) -> (Vec<DVec2>, Vec<DVec2>) {
|
||||
let document = editor.active_document();
|
||||
let vector = document.network_interface.compute_modified_vector(layer).expect("Layer should have vector geometry");
|
||||
let transform = document.metadata().transform_to_viewport(layer);
|
||||
|
||||
let endpoints: Vec<_> = vector.anchor_endpoints().collect();
|
||||
let viewport_position = |id| vector.point_domain.position_from_id(id).map(|position| transform.transform_point2(position));
|
||||
|
||||
let endpoint_positions = endpoints.iter().filter_map(|&id| viewport_position(id)).collect();
|
||||
let other_positions = vector.anchor_points().filter(|id| !endpoints.contains(id)).filter_map(viewport_position).collect();
|
||||
(endpoint_positions, other_positions)
|
||||
}
|
||||
|
||||
const INITIAL_STROKE: [DVec2; 3] = [DVec2::new(100., 100.), DVec2::new(200., 150.), DVec2::new(300., 100.)];
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extend_open_path_from_endpoint() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
|
||||
let layer = draw_freehand_stroke(&mut editor, &INITIAL_STROKE).await;
|
||||
let (initial_point_count, initial_segment_count) = point_and_segment_counts(&editor, layer);
|
||||
assert_eq!(initial_segment_count, initial_point_count - 1, "The initial stroke should be a single open path");
|
||||
|
||||
// Press just inside the snapping tolerance of an endpoint, then keep drawing
|
||||
let (endpoints, _) = endpoint_and_other_anchor_viewport_positions(&editor, layer);
|
||||
let endpoint = *endpoints.last().expect("The stroke should have endpoints");
|
||||
let continuation = [endpoint + DVec2::new(2., -2.), DVec2::new(400., 150.), DVec2::new(500., 100.)];
|
||||
editor.drag_path(&continuation, ModifierKeys::empty()).await;
|
||||
|
||||
assert_eq!(layer_count(&editor), 1, "Continuing from an endpoint should not create a new layer");
|
||||
assert_eq!(editor.get_selected_layer().await, Some(layer), "The selection should be left as it was");
|
||||
|
||||
let (point_count, segment_count) = point_and_segment_counts(&editor, layer);
|
||||
assert_eq!(point_count, initial_point_count + continuation.len() - 1, "Each pointer move after the press should add a point");
|
||||
assert_eq!(segment_count, point_count - 1, "The continued stroke should form a single open path");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_stroke_away_from_endpoint_creates_new_layer() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
|
||||
let layer = draw_freehand_stroke(&mut editor, &INITIAL_STROKE).await;
|
||||
let initial_counts = point_and_segment_counts(&editor, layer);
|
||||
|
||||
editor.drag_path(&[DVec2::new(100., 300.), DVec2::new(200., 350.), DVec2::new(300., 300.)], ModifierKeys::empty()).await;
|
||||
|
||||
assert_eq!(layer_count(&editor), 2, "A stroke starting away from any endpoint should create a new layer");
|
||||
assert_eq!(point_and_segment_counts(&editor, layer), initial_counts, "The existing path should be untouched");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_middle_anchor_does_not_extend() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
|
||||
// Four pointer positions capture three points, so the path has an anchor that is not an endpoint
|
||||
let layer = draw_freehand_stroke(&mut editor, &[DVec2::new(100., 100.), DVec2::new(200., 150.), DVec2::new(300., 100.), DVec2::new(400., 150.)]).await;
|
||||
let initial_counts = point_and_segment_counts(&editor, layer);
|
||||
|
||||
let (_, other_anchors) = endpoint_and_other_anchor_viewport_positions(&editor, layer);
|
||||
let middle_anchor = *other_anchors.first().expect("The stroke should have an anchor between its endpoints");
|
||||
editor
|
||||
.drag_path(&[middle_anchor, middle_anchor + DVec2::new(0., 100.), middle_anchor + DVec2::new(0., 200.)], ModifierKeys::empty())
|
||||
.await;
|
||||
|
||||
assert_eq!(layer_count(&editor), 2, "Only the endpoints of a path should be continued from");
|
||||
assert_eq!(point_and_segment_counts(&editor, layer), initial_counts, "The existing path should be untouched");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_closed_path_never_extends() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
|
||||
editor.draw_rect(100., 100., 300., 300.).await;
|
||||
let rectangle = editor.get_selected_layer().await.expect("The rectangle should be selected");
|
||||
let (endpoints, corners) = endpoint_and_other_anchor_viewport_positions(&editor, rectangle);
|
||||
assert!(endpoints.is_empty(), "A closed path has no endpoints");
|
||||
let corner = *corners.first().expect("The rectangle should have corner anchors");
|
||||
|
||||
editor.select_tool(ToolType::Freehand).await;
|
||||
editor.drag_path(&[corner, corner + DVec2::new(100., 50.), corner + DVec2::new(200., 0.)], ModifierKeys::empty()).await;
|
||||
|
||||
assert_eq!(layer_count(&editor), 2, "Pressing on a closed path's anchor should start a new layer");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extend_after_layer_transform() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
|
||||
let layer = draw_freehand_stroke(&mut editor, &INITIAL_STROKE).await;
|
||||
let (initial_point_count, _) = point_and_segment_counts(&editor, layer);
|
||||
|
||||
editor
|
||||
.handle_message(GraphOperationMessage::TransformSet {
|
||||
layer,
|
||||
transform: DAffine2::from_scale_angle_translation(DVec2::new(1.5, 0.8), 0.3, DVec2::new(40., -25.)),
|
||||
transform_in: TransformIn::Local,
|
||||
skip_rerender: false,
|
||||
})
|
||||
.await;
|
||||
|
||||
let transform = editor.active_document().metadata().transform_to_viewport(layer);
|
||||
assert!(!transform.matrix2.abs_diff_eq(DMat2::IDENTITY, 1e-6), "The layer should be rotated and scaled");
|
||||
|
||||
// Press within tolerance of where an endpoint now sits in the viewport, then keep drawing
|
||||
let (endpoints, _) = endpoint_and_other_anchor_viewport_positions(&editor, layer);
|
||||
let endpoint = *endpoints.first().expect("The stroke should have endpoints");
|
||||
let continuation = [endpoint + DVec2::new(-2., 2.), endpoint + DVec2::new(80., 60.), endpoint + DVec2::new(160., 20.)];
|
||||
editor.drag_path(&continuation, ModifierKeys::empty()).await;
|
||||
|
||||
assert_eq!(layer_count(&editor), 1, "Continuing from a transformed layer's endpoint should not create a new layer");
|
||||
|
||||
let (point_count, segment_count) = point_and_segment_counts(&editor, layer);
|
||||
assert_eq!(point_count, initial_point_count + continuation.len() - 1, "Each pointer move after the press should add a point");
|
||||
assert_eq!(segment_count, point_count - 1, "The continued stroke should form a single open path");
|
||||
|
||||
// The new points are stored in the layer's local space, so they should sit under the pointer once transformed back to the viewport
|
||||
let document = editor.active_document();
|
||||
let vector = document.network_interface.compute_modified_vector(layer).expect("Layer should have vector geometry");
|
||||
let transform = document.metadata().transform_to_viewport(layer);
|
||||
for &pointer_position in &continuation[1..] {
|
||||
let under_pointer = vector
|
||||
.point_domain
|
||||
.positions()
|
||||
.iter()
|
||||
.any(|&position| transform.transform_point2(position).distance(pointer_position) < 1.);
|
||||
assert!(under_pointer, "Expected a point under the pointer at {pointer_position:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_endpoint_takes_priority_over_shift_append() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
|
||||
let layer = draw_freehand_stroke(&mut editor, &INITIAL_STROKE).await;
|
||||
let (initial_point_count, _) = point_and_segment_counts(&editor, layer);
|
||||
|
||||
let (endpoints, _) = endpoint_and_other_anchor_viewport_positions(&editor, layer);
|
||||
let endpoint = *endpoints.last().expect("The stroke should have endpoints");
|
||||
let continuation = [endpoint + DVec2::new(2., 2.), DVec2::new(400., 150.), DVec2::new(500., 100.)];
|
||||
editor.drag_path(&continuation, ModifierKeys::SHIFT).await;
|
||||
|
||||
assert_eq!(layer_count(&editor), 1, "Shift should keep drawing on the selected layer");
|
||||
|
||||
// Appending a disconnected subpath would leave the layer with one fewer segment than a single continued path has
|
||||
let (point_count, segment_count) = point_and_segment_counts(&editor, layer);
|
||||
assert_eq!(point_count, initial_point_count + continuation.len() - 1, "Each pointer move after the press should add a point");
|
||||
assert_eq!(segment_count, point_count - 1, "Shift on an endpoint should continue the path rather than append a new subpath");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_press_and_release_on_endpoint_leaves_path_unchanged() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
|
||||
let layer = draw_freehand_stroke(&mut editor, &INITIAL_STROKE).await;
|
||||
let initial_counts = point_and_segment_counts(&editor, layer);
|
||||
|
||||
let (endpoints, _) = endpoint_and_other_anchor_viewport_positions(&editor, layer);
|
||||
let endpoint = *endpoints.last().expect("The stroke should have endpoints");
|
||||
editor.move_mouse(endpoint.x, endpoint.y, ModifierKeys::empty(), MouseKeys::empty()).await;
|
||||
editor.left_mousedown(endpoint.x, endpoint.y, ModifierKeys::empty()).await;
|
||||
editor.left_mouseup(endpoint.x, endpoint.y, ModifierKeys::empty()).await;
|
||||
|
||||
assert_eq!(layer_count(&editor), 1, "Releasing without moving should not create a layer");
|
||||
assert_eq!(point_and_segment_counts(&editor, layer), initial_counts, "Releasing without moving should leave the path unchanged");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hidden_layer_endpoint_is_not_extended() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
|
||||
let layer = draw_freehand_stroke(&mut editor, &INITIAL_STROKE).await;
|
||||
let initial_counts = point_and_segment_counts(&editor, layer);
|
||||
let (endpoints, _) = endpoint_and_other_anchor_viewport_positions(&editor, layer);
|
||||
let endpoint = *endpoints.first().expect("The stroke should have endpoints");
|
||||
|
||||
// Where the hidden layer's endpoint would land without its transform
|
||||
let metadata = editor.active_document().metadata();
|
||||
let untransformed_endpoint = metadata
|
||||
.document_to_viewport
|
||||
.transform_point2(metadata.transform_to_viewport(layer).inverse().transform_point2(endpoint));
|
||||
|
||||
editor
|
||||
.handle_message(NodeGraphMessage::ToggleVisibility {
|
||||
node_id: layer.to_node(),
|
||||
network_path: Vec::new(),
|
||||
})
|
||||
.await;
|
||||
assert_eq!(editor.get_selected_layer().await, Some(layer), "Hiding the layer should leave it selected");
|
||||
|
||||
editor
|
||||
.drag_path(&[endpoint + DVec2::new(2., -2.), DVec2::new(400., 150.), DVec2::new(500., 100.)], ModifierKeys::empty())
|
||||
.await;
|
||||
assert_eq!(layer_count(&editor), 2, "A stroke at a hidden layer's endpoint should create a new layer");
|
||||
|
||||
editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await;
|
||||
editor
|
||||
.drag_path(
|
||||
&[untransformed_endpoint, untransformed_endpoint + DVec2::new(100., 50.), untransformed_endpoint + DVec2::new(200., 0.)],
|
||||
ModifierKeys::empty(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
layer_count(&editor),
|
||||
3,
|
||||
"A stroke where the hidden layer's untransformed endpoint would sit should also create a new layer"
|
||||
);
|
||||
|
||||
editor
|
||||
.handle_message(NodeGraphMessage::ToggleVisibility {
|
||||
node_id: layer.to_node(),
|
||||
network_path: Vec::new(),
|
||||
})
|
||||
.await;
|
||||
assert_eq!(point_and_segment_counts(&editor, layer), initial_counts, "The hidden layer should be untouched");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_locked_layer_endpoint_is_extended() {
|
||||
let mut editor = EditorTestUtils::create();
|
||||
editor.new_document().await;
|
||||
|
||||
let layer = draw_freehand_stroke(&mut editor, &INITIAL_STROKE).await;
|
||||
let (initial_point_count, _) = point_and_segment_counts(&editor, layer);
|
||||
let (endpoints, _) = endpoint_and_other_anchor_viewport_positions(&editor, layer);
|
||||
let endpoint = *endpoints.last().expect("The stroke should have endpoints");
|
||||
|
||||
editor
|
||||
.handle_message(NodeGraphMessage::ToggleLocked {
|
||||
node_id: layer.to_node(),
|
||||
network_path: Vec::new(),
|
||||
})
|
||||
.await;
|
||||
assert_eq!(editor.get_selected_layer().await, Some(layer), "Locking the layer should leave it selected");
|
||||
|
||||
let continuation = [endpoint + DVec2::new(2., -2.), DVec2::new(400., 150.), DVec2::new(500., 100.)];
|
||||
editor.drag_path(&continuation, ModifierKeys::empty()).await;
|
||||
|
||||
// Locking only blocks viewport picking by the Select tool, so a selected locked layer stays editable
|
||||
assert_eq!(layer_count(&editor), 1, "A selected locked layer's endpoint should still be continued from");
|
||||
|
||||
let (point_count, segment_count) = point_and_segment_counts(&editor, layer);
|
||||
assert_eq!(point_count, initial_point_count + continuation.len() - 1, "Each pointer move after the press should add a point");
|
||||
assert_eq!(segment_count, point_count - 1, "The continued stroke should form a single open path");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1587,7 +1587,7 @@ impl Fsm for PathToolFsmState {
|
||||
match (self, event) {
|
||||
(_, PathToolMessage::SelectionChanged) => {
|
||||
// Set the newly targeted layers to visible
|
||||
let target_layers = document.network_interface.selected_nodes().selected_layers(document.metadata()).collect();
|
||||
let target_layers = document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface).collect();
|
||||
|
||||
shape_editor.set_selected_layers(target_layers);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::tool_prelude::*;
|
||||
use crate::consts::{DRAG_THRESHOLD, PATH_JOIN_THRESHOLD, SNAP_POINT_TOLERANCE};
|
||||
use crate::messages::input_mapper::utility_types::pointer::MouseKeys;
|
||||
use crate::messages::portfolio::document::node_graph::document_node_definitions::{resolve_network_node_type, resolve_proto_node_type};
|
||||
use crate::messages::portfolio::document::overlays::utility_functions::open_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;
|
||||
@@ -302,12 +303,20 @@ impl Fsm for SplineToolFsmState {
|
||||
tool_options: &Self::ToolOptions,
|
||||
responses: &mut VecDeque<Message>,
|
||||
) -> Self {
|
||||
let ToolActionMessageContext { document, input, viewport, .. } = tool_action_data;
|
||||
let ToolActionMessageContext {
|
||||
document,
|
||||
input,
|
||||
viewport,
|
||||
shape_editor,
|
||||
..
|
||||
} = tool_action_data;
|
||||
|
||||
let ToolMessage::Spline(event) = event else { return self };
|
||||
match (self, event) {
|
||||
(_, SplineToolMessage::CanvasTransformed) => self,
|
||||
(_, SplineToolMessage::Overlays { context: mut overlay_context }) => {
|
||||
let pointer = (self == SplineToolFsmState::Ready).then_some(input.mouse.position);
|
||||
open_path_endpoint_overlays(document, shape_editor, pointer, &mut overlay_context);
|
||||
tool_data.snap_manager.draw_overlays(SnapData::new(document, input, viewport), &mut overlay_context);
|
||||
self
|
||||
}
|
||||
@@ -565,7 +574,10 @@ fn try_merging_lastest_endpoint(document: &DocumentMessageHandler, tool_data: &m
|
||||
.filter(|layer| !document.network_interface.is_artboard(&layer.to_node(), &[]));
|
||||
|
||||
let exclude = |p: PointId| preview_point.is_some_and(|pp| pp == p) || *last_endpoint == p;
|
||||
let position = document.metadata().transform_to_viewport(current_layer).transform_point2(*last_endpoint_position);
|
||||
let position = document
|
||||
.metadata()
|
||||
.transform_to_viewport_if_feeds(current_layer, &document.network_interface)
|
||||
.transform_point2(*last_endpoint_position);
|
||||
|
||||
let (layer, endpoint, _) = closest_point(document, position, PATH_JOIN_THRESHOLD, layers, exclude)?;
|
||||
tool_data.merge_layers.insert(layer);
|
||||
|
||||
@@ -584,7 +584,7 @@ impl MessageHandler<TransformLayerMessage, TransformLayerMessageContext<'_>> for
|
||||
self.mouse_position = input.mouse.position;
|
||||
}
|
||||
TransformLayerMessage::SelectionChanged => {
|
||||
let target_layers = document.network_interface.selected_nodes().selected_layers(document.metadata()).collect();
|
||||
let target_layers = document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface).collect();
|
||||
shape_editor.set_selected_layers(target_layers);
|
||||
}
|
||||
TransformLayerMessage::TypeBackspace => {
|
||||
|
||||
Reference in New Issue
Block a user