diff --git a/editor/src/messages/tool/common_functionality/shapes/polygon_shape.rs b/editor/src/messages/tool/common_functionality/shapes/polygon_shape.rs index 7b22144a71..affc2e1a2e 100644 --- a/editor/src/messages/tool/common_functionality/shapes/polygon_shape.rs +++ b/editor/src/messages/tool/common_functionality/shapes/polygon_shape.rs @@ -191,3 +191,64 @@ impl Polygon { responses.add(NodeGraphMessage::RunDocumentGraph); } } +#[cfg(test)] +mod test_polygon { + use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; + use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer; + use crate::test_utils::test_prelude::*; + use graph_craft::document::value::TaggedValue; + struct ResolvedPolygon { + vertices: u32, + radius: f64, + } + async fn get_polygons(editor: &mut EditorTestUtils) -> Vec { + let document = editor.active_document(); + let network_interface = &document.network_interface; + document + .metadata() + .all_layers() + .filter_map(|layer| { + let node_inputs = NodeGraphLayer::new(layer, network_interface) + .find_node_inputs(&DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::regular_polygon::IDENTIFIER))?; + let Some(&TaggedValue::U32(vertices)) = node_inputs[1].as_value() else { + return None; + }; + let Some(&TaggedValue::F64(radius)) = node_inputs[2].as_value() else { + return None; + }; + Some(ResolvedPolygon { vertices, radius }) + }) + .collect() + } + #[tokio::test] + async fn polygon_draw_simple() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.drag_tool(ToolType::Shape, 0., 0., 60., 60., ModifierKeys::empty()).await; + assert_eq!(editor.active_document().metadata().all_layers().count(), 1); + let polys = get_polygons(&mut editor).await; + assert_eq!(polys.len(), 1); + // Default vertices count (6 for Shape tool default) + assert!(polys[0].vertices >= 3, "polygon should have at least 3 vertices"); + // For a 60×60 drag both dimensions equal → radius = smaller_dim / 2 = 30 + assert!((polys[0].radius - 30.).abs() < 1e-10); + } + #[tokio::test] + async fn polygon_draw_non_square() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + // Drag a non-square region: 40 wide, 60 tall → smaller dimension is 40 → radius = 20 + editor.drag_tool(ToolType::Shape, 0., 0., 40., 60., ModifierKeys::empty()).await; + let polys = get_polygons(&mut editor).await; + assert_eq!(polys.len(), 1); + assert!((polys[0].radius - 20.).abs() < 1e-10); + } + #[tokio::test] + async fn polygon_cancel() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.drag_tool_cancel_rmb(ToolType::Shape).await; + let polys = get_polygons(&mut editor).await; + assert_eq!(polys.len(), 0); + } +} diff --git a/editor/src/messages/tool/common_functionality/shapes/rectangle_shape.rs b/editor/src/messages/tool/common_functionality/shapes/rectangle_shape.rs index 787684a8fa..9dac3ff734 100644 --- a/editor/src/messages/tool/common_functionality/shapes/rectangle_shape.rs +++ b/editor/src/messages/tool/common_functionality/shapes/rectangle_shape.rs @@ -53,3 +53,64 @@ impl Rectangle { } } } +#[cfg(test)] +mod test_polygon { + use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; + use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer; + use crate::test_utils::test_prelude::*; + use graph_craft::document::value::TaggedValue; + struct ResolvedPolygon { + vertices: u32, + radius: f64, + } + async fn get_polygons(editor: &mut EditorTestUtils) -> Vec { + let document = editor.active_document(); + let network_interface = &document.network_interface; + document + .metadata() + .all_layers() + .filter_map(|layer| { + let node_inputs = NodeGraphLayer::new(layer, network_interface) + .find_node_inputs(&DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::regular_polygon::IDENTIFIER))?; + let Some(&TaggedValue::U32(vertices)) = node_inputs[1].as_value() else { + return None; + }; + let Some(&TaggedValue::F64(radius)) = node_inputs[2].as_value() else { + return None; + }; + Some(ResolvedPolygon { vertices, radius }) + }) + .collect() + } + #[tokio::test] + async fn polygon_draw_simple() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.drag_tool(ToolType::Shape, 0., 0., 60., 60., ModifierKeys::empty()).await; + assert_eq!(editor.active_document().metadata().all_layers().count(), 1); + let polys = get_polygons(&mut editor).await; + assert_eq!(polys.len(), 1); + // Default vertices count (6 for Shape tool default) + assert!(polys[0].vertices >= 3, "polygon should have at least 3 vertices"); + // For a 60×60 drag both dimensions equal → radius = smaller_dim / 2 = 30 + assert!((polys[0].radius - 30.).abs() < 1e-10); + } + #[tokio::test] + async fn polygon_draw_non_square() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + // Drag a non-square region: 40 wide, 60 tall → smaller dimension is 40 → radius = 20 + editor.drag_tool(ToolType::Shape, 0., 0., 40., 60., ModifierKeys::empty()).await; + let polys = get_polygons(&mut editor).await; + assert_eq!(polys.len(), 1); + assert!((polys[0].radius - 20.).abs() < 1e-10); + } + #[tokio::test] + async fn polygon_cancel() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.drag_tool_cancel_rmb(ToolType::Shape).await; + let polys = get_polygons(&mut editor).await; + assert_eq!(polys.len(), 0); + } +} diff --git a/editor/src/messages/tool/common_functionality/snapping/snap_results.rs b/editor/src/messages/tool/common_functionality/snapping/snap_results.rs index 415ead679f..bdb9be713b 100644 --- a/editor/src/messages/tool/common_functionality/snapping/snap_results.rs +++ b/editor/src/messages/tool/common_functionality/snapping/snap_results.rs @@ -122,3 +122,123 @@ pub struct SnappedCurve { pub point: SnappedPoint, pub document_curve: PathSeg, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::messages::portfolio::document::utility_types::misc::{BoundingBoxSnapSource, SnapSource}; + use glam::DVec2; + #[test] + fn is_snapped_with_finite_distance() { + let point = SnappedPoint { distance: 3.0, ..Default::default() }; + assert!(point.is_snapped()); + } + #[test] + fn is_not_snapped_with_infinite_distance() { + let point = SnappedPoint::infinite_snap(DVec2::ZERO); + assert!(!point.is_snapped()); + } + #[test] + fn infinite_snap_sets_position_and_infinite_distance() { + let pos = DVec2::new(10., 20.); + let point = SnappedPoint::infinite_snap(pos); + assert_eq!(point.snapped_point_document, pos); + assert!(!point.is_snapped()); + assert!(point.distance.is_infinite()); + } + #[test] + fn from_source_point_sets_position_and_source() { + let pos = DVec2::new(5., 15.); + let source = SnapSource::BoundingBox(BoundingBoxSnapSource::CenterPoint); + let point = SnappedPoint::from_source_point(pos, source); + assert_eq!(point.snapped_point_document, pos); + assert_eq!(point.source, source); + } + #[test] + fn align_returns_true_with_horizontal_target() { + let point = SnappedPoint { + alignment_target_horizontal: Some(DVec2::ZERO), + ..Default::default() + }; + assert!(point.align()); + } + #[test] + fn align_returns_true_with_vertical_target() { + let point = SnappedPoint { + alignment_target_vertical: Some(DVec2::ZERO), + ..Default::default() + }; + assert!(point.align()); + } + #[test] + fn align_returns_false_with_no_targets() { + assert!(!SnappedPoint::default().align()); + } + #[test] + fn other_snap_better_self_infinite_other_finite() { + let self_snap = SnappedPoint::infinite_snap(DVec2::ZERO); + let other_snap = SnappedPoint { distance: 1.0, ..Default::default() }; + assert!(self_snap.other_snap_better(&other_snap)); + } + #[test] + fn other_snap_better_self_finite_other_infinite() { + let self_snap = SnappedPoint { distance: 1.0, ..Default::default() }; + let other_snap = SnappedPoint::infinite_snap(DVec2::ZERO); + assert!(!self_snap.other_snap_better(&other_snap)); + } + #[test] + fn other_snap_better_both_infinite() { + let self_snap = SnappedPoint::infinite_snap(DVec2::ZERO); + let other_snap = SnappedPoint::infinite_snap(DVec2::ONE); + // Neither finite, so other_closer check: INF < INF + bias = false. Result: false. + assert!(!self_snap.other_snap_better(&other_snap)); + } + #[test] + fn other_snap_better_when_other_significantly_closer() { + let self_snap = SnappedPoint { distance: 5.0, ..Default::default() }; + let other_snap = SnappedPoint { distance: 1.0, ..Default::default() }; + // 1.0 < 5.0 + 0.01 → other_closer = true + assert!(self_snap.other_snap_better(&other_snap)); + } + #[test] + fn other_snap_better_when_self_significantly_closer() { + let self_snap = SnappedPoint { distance: 1.0, ..Default::default() }; + let other_snap = SnappedPoint { distance: 5.0, ..Default::default() }; + // 5.0 < 1.0 + 0.01 → false + assert!(!self_snap.other_snap_better(&other_snap)); + } + #[test] + fn other_snap_better_constrained_beats_unconstrained_even_if_further() { + let self_snap = SnappedPoint { distance: 1.0, constrained: false, ..Default::default() }; + let other_snap = SnappedPoint { distance: 2.0, constrained: true, ..Default::default() }; + // other_more_constrained = true; self_more_constrained = false → other wins + assert!(self_snap.other_snap_better(&other_snap)); + } + #[test] + fn other_snap_better_self_constrained_blocks_other() { + let self_snap = SnappedPoint { distance: 2.0, constrained: true, ..Default::default() }; + let other_snap = SnappedPoint { distance: 1.0, constrained: false, ..Default::default() }; + // self_more_constrained = true → result is false regardless of distance + assert!(!self_snap.other_snap_better(&other_snap)); + } + #[test] + fn other_snap_better_prefers_non_intersection_at_same_position() { + let pos = DVec2::new(3., 4.); + let self_snap = SnappedPoint { + snapped_point_document: pos, + distance: 1.0, + constrained: true, + at_intersection: true, + ..Default::default() + }; + let other_snap = SnappedPoint { + snapped_point_document: pos, + distance: 1.0, + constrained: true, + at_intersection: false, + ..Default::default() + }; + // Both constrained at same position; self is intersection, other is not → other wins + assert!(self_snap.other_snap_better(&other_snap)); + } +} diff --git a/editor/src/messages/tool/common_functionality/utility_functions.rs b/editor/src/messages/tool/common_functionality/utility_functions.rs index e8d4293721..fc18a0131b 100644 --- a/editor/src/messages/tool/common_functionality/utility_functions.rs +++ b/editor/src/messages/tool/common_functionality/utility_functions.rs @@ -24,7 +24,21 @@ use kurbo::{CubicBez, DEFAULT_ACCURACY, Line, ParamCurve, PathSeg, Point, QuadBe /// 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) -> Option<(LayerNodeIdentifier, PointId, DVec2)> { - closest_point(document, goal, tolerance, layers, |_| false) + let mut best = None; + let mut best_distance_squared = tolerance * tolerance; + for layer in layers { + let viewspace = document.metadata().transform_to_viewport(layer); + let Some(vector) = document.network_interface.compute_modified_vector(layer) else { continue }; + for id in vector.anchor_endpoints() { + 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; + } + } + } + best } /// Determine the closest point to the goal point under max_distance. diff --git a/node-graph/nodes/vector/src/generator_nodes.rs b/node-graph/nodes/vector/src/generator_nodes.rs index 66c8009e02..ac47f4a057 100644 --- a/node-graph/nodes/vector/src/generator_nodes.rs +++ b/node-graph/nodes/vector/src/generator_nodes.rs @@ -42,40 +42,66 @@ impl CornerRadius for [f64; 4] { /// Generates a circle shape with a chosen radius. #[node_macro::node(category("Vector: Shape"))] fn circle( - _: impl Ctx, - _primary: (), - #[unit(" px")] - #[default(50.)] - radius: f64, + _: impl Ctx, + _primary: (), + #[unit(" px")] + #[default(50.)] + radius: f64, ) -> Table { - let radius = radius.abs(); - Table::new_from_element(Vector::from_subpath(subpath::Subpath::new_ellipse(DVec2::splat(-radius), DVec2::splat(radius)))) + let radius = radius.abs(); + // 1. Create the vector + let mut circle = Vector::from_subpath(subpath::Subpath::new_ellipse(DVec2::splat(-radius), DVec2::splat(radius))); + + // Created the collinear_manipulators so that all handles are linked, making it easier to edit the circle as a circle instead of a 4 point shape. + let ids = circle.segment_domain.ids(); + let len = ids.len(); + for i in 0..len { + circle.colinear_manipulators.push([ + HandleId::end(ids[i]), + HandleId::primary(ids[(i + 1) % len]), + ]); + } + + Table::new_from_element(circle) } /// Generates an arc shape forming a portion of a circle which may be open, closed, or a pie slice. #[node_macro::node(category("Vector: Shape"))] fn arc( - _: impl Ctx, - _primary: (), - #[unit(" px")] - #[default(50.)] - radius: f64, - start_angle: Angle, - #[default(270.)] - #[range((0., 360.))] - sweep_angle: Angle, - arc_type: ArcType, + _: impl Ctx, + _primary: (), + #[unit(" px")] + #[default(50.)] + radius: f64, + start_angle: Angle, + #[default(270.)] + #[range((0., 360.))] + sweep_angle: Angle, + arc_type: ArcType, ) -> Table { - Table::new_from_element(Vector::from_subpath(subpath::Subpath::new_arc( - radius, - start_angle / 360. * std::f64::consts::TAU, - sweep_angle / 360. * std::f64::consts::TAU, - match arc_type { - ArcType::Open => subpath::ArcType::Open, - ArcType::Closed => subpath::ArcType::Closed, - ArcType::PieSlice => subpath::ArcType::PieSlice, - }, - ))) + let mut arc_vector = Vector::from_subpath(subpath::Subpath::new_arc( + radius, + start_angle / 360. * std::f64::consts::TAU, + sweep_angle / 360. * std::f64::consts::TAU, + match arc_type { + ArcType::Open => subpath::ArcType::Open, + ArcType::Closed => subpath::ArcType::Closed, + ArcType::PieSlice => subpath::ArcType::PieSlice, + }, + )); + + // 2. Link handles only if both adjacent segments are cubic beziers + let len = arc_vector.segment_domain.ids().len(); + for i in 0..len.saturating_sub(1) { + if arc_vector.segment_domain.handles()[i].is_cubic() && arc_vector.segment_domain.handles()[i + 1].is_cubic() { + arc_vector.colinear_manipulators.push([ + HandleId::end(arc_vector.segment_domain.ids()[i]), + HandleId::primary(arc_vector.segment_domain.ids()[i + 1]) + ]); + } + } + + Table::new_from_element(arc_vector) } /// Generates a spiral shape that winds from an inner to an outer radius. @@ -90,14 +116,29 @@ fn spiral( #[default(25)] outer_radius: f64, #[default(90.)] angular_resolution: f64, ) -> Table { - Table::new_from_element(Vector::from_subpath(subpath::Subpath::new_spiral( + + let mut spiral_vector = Vector::from_subpath(subpath::Subpath::new_spiral( inner_radius, outer_radius, turns, start_angle.to_radians(), angular_resolution.to_radians(), spiral_type, - ))) + )); + + + let len = spiral_vector.segment_domain.ids().len(); + for i in 0..len.saturating_sub(1) { + // Ensure both segments meeting at the anchor point are cubic beziers + if spiral_vector.segment_domain.handles()[i].is_cubic() && spiral_vector.segment_domain.handles()[i + 1].is_cubic() { + spiral_vector.colinear_manipulators.push([ + HandleId::end(spiral_vector.segment_domain.ids()[i]), + HandleId::primary(spiral_vector.segment_domain.ids()[i + 1]) + ]); + } + } + + Table::new_from_element(spiral_vector) } /// Generates an ellipse shape (an oval or stretched circle) with the chosen radii.