From 29f06ea9a4c9ba68b64d06959ef45e895a7d69e0 Mon Sep 17 00:00:00 2001 From: Siddhartha Singh Date: Wed, 30 Sep 2026 04:27:08 +0530 Subject: [PATCH 1/3] Bake rectangle and ellipse scale into the shape size --- .../graph_operation_message.rs | 4 + .../graph_operation_message_handler.rs | 5 + .../document/graph_operation/utility_types.rs | 125 ++++++++++++- .../tool/tool_messages/select_tool.rs | 7 + .../messages/tool/tool_messages/shape_tool.rs | 7 + .../transform_layer_message_handler.rs | 171 +++++++++++++++--- 6 files changed, 290 insertions(+), 29 deletions(-) diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index dbc166c3656..440f0052a2a 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -107,6 +107,10 @@ pub enum GraphOperationMessage { transform_in: TransformIn, skip_rerender: bool, }, + /// Move a rectangle or ellipse layer's scale off the Transform node and into its size inputs. + BakeShapeScale { + layer: LayerNodeIdentifier, + }, Vector { layer: LayerNodeIdentifier, modification_type: VectorModificationType, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index f7992021971..330724937ef 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -164,6 +164,11 @@ impl MessageHandler> for modify_inputs.transform_set(transform, transform_in, skip_rerender); } } + GraphOperationMessage::BakeShapeScale { layer } => { + if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { + modify_inputs.bake_scale_into_shape_generator(); + } + } GraphOperationMessage::Vector { layer, modification_type } => { if layer == LayerNodeIdentifier::ROOT_PARENT { log::error!("Cannot run Vector on ROOT_PARENT"); diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index b54d5aaff27..d28aefe3a7b 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -6,7 +6,7 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye use crate::messages::portfolio::document::utility_types::network_interface::{self, FlowType, InputConnector, NodeNetworkInterface, OutputConnector}; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils::{ - ReplaceablePaintChain, get_fill_input_node_id, get_upstream_gradient_value_node_id, gradient_chain_target_input, replaceable_paint_chain, + ReplaceablePaintChain, get_ellipse_id, get_fill_input_node_id, get_rectangle_id, get_upstream_gradient_value_node_id, gradient_chain_target_input, replaceable_paint_chain, }; use glam::{DAffine2, DVec2, IVec2}; use graph_craft::application_io::resource::ResourceId; @@ -15,6 +15,7 @@ use graph_craft::document::{NodeId, NodeInput}; use graph_craft::{ProtoNodeIdentifier, list}; use graphene_std::raster::BlendMode; use graphene_std::text::{Font, TypesettingConfig}; +use graphene_std::transform::Transform; use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, PaintOrder, Stroke}; use graphene_std::vector::{Gradient, GradientRamp, Vector, VectorModification, VectorModificationType}; use graphene_std::{Artboard, Color, Graphic}; @@ -28,6 +29,13 @@ pub enum TransformIn { Viewport, } +fn literal_number(input: Option<&NodeInput>) -> Option { + match input.and_then(|input| input.as_value()) { + Some(TaggedValue::Number(value)) => Some(*value), + _ => None, + } +} + // This struct is helpful to prevent passing the same arguments to multiple functions // Should only be used by GraphOperationMessage, since it only affects the document network. pub struct ModifyInputsContext<'a> { @@ -1001,6 +1009,121 @@ impl<'a> ModifyInputsContext<'a> { ); } + /// Fold scale from this layer's Transform node into a rectangle or ellipse whose size inputs are plain numbers. + /// Position and rotation stay on the Transform node. Connected inputs, skew, and non-uniform scale on a rounded rectangle are left alone. + pub fn bake_scale_into_shape_generator(&mut self) { + let Some(layer) = self.layer_node else { return }; + // A group has no single rectangle or ellipse size. Its scale has to stay on the group Transform. + if layer.children(self.network_interface.document_metadata()).next().is_some() { + return; + } + let Some(transform_node_id) = self.existing_proto_node_id(graphene_std::transform_nodes::transform::IDENTIFIER, false) else { + return; + }; + + let transform = { + let Some(node) = self.network_interface.document_network().nodes.get(&transform_node_id) else { + return; + }; + let literal_transform = (1..=4).all(|index| node.inputs.get(index).and_then(|input| input.as_value()).is_some()); + if !literal_transform { + return; + } + transform_utils::get_current_transform(&node.inputs) + }; + + let (rotation, scale, skew) = transform.decompose_rotation_scale_skew(); + if !scale.x.is_finite() || !scale.y.is_finite() || skew.abs() > 1e-4 { + return; + } + if (scale.x.abs() - 1.).abs() < 1e-8 && (scale.y.abs() - 1.).abs() < 1e-8 { + return; + } + + let baked = if let Some(node_id) = get_rectangle_id(layer, self.network_interface) { + self.bake_rectangle_scale(node_id, scale) + } else if let Some(node_id) = get_ellipse_id(layer, self.network_interface) { + self.bake_ellipse_scale(node_id, scale) + } else { + false + }; + if !baked { + return; + } + + let signed_scale = DVec2::new(if scale.x < 0. { -1. } else { 1. }, if scale.y < 0. { -1. } else { 1. }); + let without_scale = DAffine2::from_scale_angle_translation(signed_scale, rotation, transform.translation); + transform_utils::update_transform(self.network_interface, &transform_node_id, without_scale); + self.responses.add(PropertiesPanelMessage::Refresh); + self.responses.add(NodeGraphMessage::RunDocumentGraph); + } + + fn bake_rectangle_scale(&mut self, node_id: NodeId, scale: DVec2) -> bool { + use graphene_std::vector::generator_nodes::rectangle; + + let (width, height, corners) = { + let Some(node) = self.network_interface.document_network().nodes.get(&node_id) else { + return false; + }; + let Some(width) = literal_number(node.input(rectangle::WidthInput)) else { return false }; + let Some(height) = literal_number(node.input(rectangle::HeightInput)) else { return false }; + let Some(TaggedValue::BoxCorners(corners)) = node.input(rectangle::CornerRadiusInput).and_then(|input| input.as_value()) else { + return false; + }; + (width, height, corners.clone()) + }; + + let uniform = (scale.x.abs() - scale.y.abs()).abs() <= 1e-6 * scale.x.abs().max(scale.y.abs()).max(1.); + if !uniform && corners.iter().any(|radius| radius.abs() > 1e-9) { + return false; + } + + self.network_interface.set_input( + &InputConnector::node(node_id, rectangle::WidthInput), + NodeInput::value(TaggedValue::Number(width * scale.x.abs()), false), + &[], + ); + self.network_interface.set_input( + &InputConnector::node(node_id, rectangle::HeightInput), + NodeInput::value(TaggedValue::Number(height * scale.y.abs()), false), + &[], + ); + if uniform && corners.iter().any(|radius| radius.abs() > 1e-9) { + let scaled_corners = corners.iter().map(|radius| radius * scale.x.abs()).collect::>(); + self.network_interface.set_input( + &InputConnector::node(node_id, rectangle::CornerRadiusInput), + NodeInput::value(TaggedValue::BoxCorners(scaled_corners), false), + &[], + ); + } + true + } + + fn bake_ellipse_scale(&mut self, node_id: NodeId, scale: DVec2) -> bool { + use graphene_std::vector::generator_nodes::ellipse; + + let (radius_x, radius_y) = { + let Some(node) = self.network_interface.document_network().nodes.get(&node_id) else { + return false; + }; + let Some(radius_x) = literal_number(node.input(ellipse::RadiusXInput)) else { return false }; + let Some(radius_y) = literal_number(node.input(ellipse::RadiusYInput)) else { return false }; + (radius_x, radius_y) + }; + + self.network_interface.set_input( + &InputConnector::node(node_id, ellipse::RadiusXInput), + NodeInput::value(TaggedValue::Number(radius_x * scale.x.abs()), false), + &[], + ); + self.network_interface.set_input( + &InputConnector::node(node_id, ellipse::RadiusYInput), + NodeInput::value(TaggedValue::Number(radius_y * scale.y.abs()), false), + &[], + ); + true + } + /// Set the input, refresh the Properties panel, and run the document graph if skip_rerender is false pub fn set_input_with_refresh(&mut self, input_connector: InputConnector, input: NodeInput, skip_rerender: bool) { self.network_interface.set_input(&input_connector, input, &[]); diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs index b9b91e8090b..d2239f9f66a 100644 --- a/editor/src/messages/tool/tool_messages/select_tool.rs +++ b/editor/src/messages/tool/tool_messages/select_tool.rs @@ -1619,6 +1619,13 @@ impl Fsm for SelectToolFsmState { ) => { let drag_too_small = input.mouse.position.distance(tool_data.drag_start) < 10. * f64::EPSILON; let response = if drag_too_small { DocumentMessage::AbortTransaction } else { DocumentMessage::EndTransaction }; + if !drag_too_small && matches!(self, SelectToolFsmState::ResizingBounds) { + for &layer in &tool_data.layers_dragging { + if layer != LayerNodeIdentifier::ROOT_PARENT { + responses.add(GraphOperationMessage::BakeShapeScale { layer }); + } + } + } let pivot_gizmo = tool_data.pivot_gizmo(); responses.add(TransformLayerMessage::SetPivotGizmo { pivot_gizmo }); diff --git a/editor/src/messages/tool/tool_messages/shape_tool.rs b/editor/src/messages/tool/tool_messages/shape_tool.rs index 33c58fb9ece..ce69db33591 100644 --- a/editor/src/messages/tool/tool_messages/shape_tool.rs +++ b/editor/src/messages/tool/tool_messages/shape_tool.rs @@ -1322,6 +1322,13 @@ impl Fsm for ShapeToolFsmState { | ShapeToolFsmState::ModifyingGizmo, ShapeToolMessage::DragStop, ) => { + if matches!(self, ShapeToolFsmState::ResizingBounds) { + for &layer in &tool_data.layers_dragging { + if layer != LayerNodeIdentifier::ROOT_PARENT { + responses.add(GraphOperationMessage::BakeShapeScale { layer }); + } + } + } input.mouse.finish_transaction(tool_data.data.drag_start, responses); tool_data.data.cleanup(responses); diff --git a/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs b/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs index c803140775d..dee671fec31 100644 --- a/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs +++ b/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs @@ -323,6 +323,11 @@ impl MessageHandler> for selected.responses.add(PenToolMessage::Confirm); } else { update_colinear_handles(&selected_layers, document, responses); + if final_transform { + for &layer in &selected_layers { + responses.add(GraphOperationMessage::BakeShapeScale { layer }); + } + } responses.add(DocumentMessage::EndTransaction); responses.add(ToolMessage::UpdateHints); responses.add(NodeGraphMessage::RunDocumentGraph); @@ -927,14 +932,15 @@ mod test_transform_layer { editor.handle_message(TransformLayerMessage::ApplyTransformOperation { final_transform: true }).await; let final_transform = get_layer_transform(&mut editor, layer).await.unwrap(); - - // Check scaling components have changed - let scale_diff_x = (final_transform.matrix2.x_axis.x - original_transform.matrix2.x_axis.x).abs(); - let scale_diff_y = (final_transform.matrix2.y_axis.y - original_transform.matrix2.y_axis.y).abs(); + let (width, height) = rectangle_size(&editor, layer); assert!( - scale_diff_x > 0.1 || scale_diff_y > 0.1, - "Scaling should have changed the transform matrix. Diffs: x={scale_diff_x}, y={scale_diff_y}" + (width - 100.).abs() > 1. || (height - 100.).abs() > 1., + "Scaling should change the rectangle size instead of leaving it at 100x100, got {width}x{height}" + ); + assert!( + (final_transform.matrix2.x_axis.length() - original_transform.matrix2.x_axis.length()).abs() < 0.05, + "Scaling a rectangle should not leave the size change on the Transform node" ); } @@ -1031,9 +1037,14 @@ mod test_transform_layer { editor.handle_message(TransformLayerMessage::ApplyTransformOperation { final_transform: true }).await; let final_transform = get_layer_transform(&mut editor, layer).await.unwrap(); + let (width, height) = rectangle_size(&editor, layer); - assert!(final_transform.abs_diff_eq(after_scale_transform, 1e-5), "Final transform should match the transform before committing"); - assert!(!final_transform.abs_diff_eq(original_transform, 1e-5), "Final transform should be different from original transform"); + assert!(width > 100. && height > 100., "Committed scale should be stored on the rectangle, got {width}x{height}"); + assert!( + (final_transform.matrix2.x_axis.length() - 1.).abs() < 0.05, + "Committed rectangle scale should not stay on the Transform node" + ); + assert!(!final_transform.abs_diff_eq(original_transform, 1e-5), "Rotation from the chained transform should remain"); } #[tokio::test] @@ -1054,12 +1065,14 @@ mod test_transform_layer { editor.handle_message(TransformLayerMessage::ApplyTransformOperation { final_transform: true }).await; let final_transform = get_layer_transform(&mut editor, layer).await.unwrap(); + let (width, height) = rectangle_size(&editor, layer); + assert!((width - 200.).abs() < 1., "Panned 2x scale should set rectangle width to 200, got {width}"); + assert!((height - 200.).abs() < 1., "Panned 2x scale should set rectangle height to 200, got {height}"); let scale_x = final_transform.matrix2.x_axis.length() / original_transform.matrix2.x_axis.length(); let scale_y = final_transform.matrix2.y_axis.length() / original_transform.matrix2.y_axis.length(); - - assert!((scale_x - 2.).abs() < 0.1, "Expected scale factor X of 2, got: {scale_x}"); - assert!((scale_y - 2.).abs() < 0.1, "Expected scale factor Y of 2, got: {scale_y}"); + assert!((scale_x - 1.).abs() < 0.05, "Transform scale X should stay 1 after baking, got {scale_x}"); + assert!((scale_y - 1.).abs() < 0.05, "Transform scale Y should stay 1 after baking, got {scale_y}"); } #[tokio::test] @@ -1080,12 +1093,14 @@ mod test_transform_layer { editor.handle_message(TransformLayerMessage::ApplyTransformOperation { final_transform: true }).await; let final_transform = get_layer_transform(&mut editor, layer).await.unwrap(); + let (width, height) = rectangle_size(&editor, layer); + assert!((width - 200.).abs() < 1., "Zoomed 2x scale should set rectangle width to 200, got {width}"); + assert!((height - 200.).abs() < 1., "Zoomed 2x scale should set rectangle height to 200, got {height}"); let scale_x = final_transform.matrix2.x_axis.length() / original_transform.matrix2.x_axis.length(); let scale_y = final_transform.matrix2.y_axis.length() / original_transform.matrix2.y_axis.length(); - - assert!((scale_x - 2.).abs() < 0.1, "Expected scale factor X of 2, got: {scale_x}"); - assert!((scale_y - 2.).abs() < 0.1, "Expected scale factor Y of 2, got: {scale_y}"); + assert!((scale_x - 1.).abs() < 0.05, "Transform scale X should stay 1 after baking, got {scale_x}"); + assert!((scale_y - 1.).abs() < 0.05, "Transform scale Y should stay 1 after baking, got {scale_y}"); } #[tokio::test] @@ -1167,13 +1182,13 @@ mod test_transform_layer { editor.handle_message(TransformLayerMessage::ApplyTransformOperation { final_transform: true }).await; let near_zero_transform = get_layer_transform(&mut editor, layer).await.unwrap(); - // Verify scale is near zero. - let scale_x = near_zero_transform.matrix2.x_axis.length(); - let scale_y = near_zero_transform.matrix2.y_axis.length(); - assert!(scale_x < 0.001, "Scale factor X should be near zero, got: {scale_x}"); - assert!(scale_y < 0.001, "Scale factor Y should be near zero, got: {scale_y}"); - assert!(scale_x > 0., "Scale factor X should not be exactly zero"); - assert!(scale_y > 0., "Scale factor Y should not be exactly zero"); + let (width, height) = rectangle_size(&editor, layer); + assert!(width.abs() < 0.2 && height.abs() < 0.2, "Near-zero scale should shrink the rectangle, got {width}x{height}"); + assert!(width.abs() > 0. && height.abs() > 0., "Near-zero scale should not wipe the rectangle out"); + assert!( + (near_zero_transform.matrix2.x_axis.length() - 1.).abs() < 0.05, + "The tiny size should live on the rectangle, not the Transform scale" + ); editor.handle_message(TransformLayerMessage::BeginScale).await; editor.handle_message(TransformLayerMessage::TypeDigit { digit: 2 }).await; @@ -1213,10 +1228,6 @@ mod test_transform_layer { }) .await; - // Get the group layer (should be the newest layer) - let document = editor.active_document(); - let group_layer = document.metadata().all_layers().next().unwrap(); - // Test 1: Transform single layer editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layers[0].to_node()] }).await; let original_transform = get_layer_transform(&mut editor, layers[0]).await.unwrap(); @@ -1257,14 +1268,35 @@ mod test_transform_layer { "Transform should change for second layer in multi-selection" ); - // Test 3: Transform group + // Test 3: Scaling a group keeps the scale on the group Transform. + // A group has no single rectangle or ellipse size, so the child generator numbers stay put. + let document = editor.active_document(); + let group_layer = document + .metadata() + .all_layers() + .find(|layer| layer.has_children(document.metadata())) + .expect("Grouping two rectangles should produce a folder"); + let child_sizes: Vec<(f64, f64)> = { + let document = editor.active_document(); + group_layer.children(document.metadata()).filter_map(|layer| rectangle_size_if_present(&editor, layer)).collect() + }; + assert_eq!(child_sizes.len(), 2, "The group should contain the two rectangles that were grouped"); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![group_layer.to_node()] }).await; - let original_group_transform = get_layer_transform(&mut editor, group_layer).await.unwrap(); editor.handle_message(TransformLayerMessage::BeginScale).await; editor.handle_message(TransformLayerMessage::TypeDigit { digit: 2 }).await; editor.handle_message(TransformLayerMessage::ApplyTransformOperation { final_transform: true }).await; + let final_group_transform = get_layer_transform(&mut editor, group_layer).await.unwrap(); - assert!(!final_group_transform.abs_diff_eq(original_group_transform, 1e-5), "Transform should change for group"); + let scale_x = final_group_transform.matrix2.x_axis.length(); + let scale_y = final_group_transform.matrix2.y_axis.length(); + assert!((scale_x - 2.).abs() < 0.05, "Group scale should stay on the Transform node, got {scale_x}"); + assert!((scale_y - 2.).abs() < 0.05, "Group scale should stay on the Transform node, got {scale_y}"); + let scaled_child_sizes: Vec<(f64, f64)> = { + let document = editor.active_document(); + group_layer.children(document.metadata()).filter_map(|layer| rectangle_size_if_present(&editor, layer)).collect() + }; + assert_eq!(scaled_child_sizes, child_sizes, "Grouping must not bake the scale into the child rectangles"); // Test 4: Transform layers inside transformed group let child_layer_id = { @@ -1296,4 +1328,87 @@ mod test_transform_layer { let final_child_transform = get_layer_transform(&mut editor, child_layer_id).await.unwrap(); assert!(!final_child_transform.abs_diff_eq(original_child_transform, 1e-5), "Child layer inside transformed group should change"); } + + fn rectangle_size_if_present(editor: &EditorTestUtils, layer: LayerNodeIdentifier) -> Option<(f64, f64)> { + use graph_craft::document::value::TaggedValue; + use graphene_std::vector::generator_nodes::rectangle; + + let document = editor.active_document(); + let node_id = NodeGraphLayer::new(layer, &document.network_interface).upstream_node_id_from_protonode(rectangle::IDENTIFIER)?; + let node = document.network_interface.document_network().nodes.get(&node_id)?; + let number = |input: Option<&graph_craft::document::NodeInput>| match input.and_then(|input| input.as_value()) { + Some(TaggedValue::Number(value)) => Some(*value), + _ => None, + }; + Some((number(node.input(rectangle::WidthInput))?, number(node.input(rectangle::HeightInput))?)) + } + + fn rectangle_size(editor: &EditorTestUtils, layer: LayerNodeIdentifier) -> (f64, f64) { + rectangle_size_if_present(editor, layer).expect("rectangle size") + } + + fn ellipse_radii(editor: &EditorTestUtils, layer: LayerNodeIdentifier) -> (f64, f64) { + use graph_craft::document::value::TaggedValue; + use graphene_std::vector::generator_nodes::ellipse; + + let document = editor.active_document(); + let node_id = NodeGraphLayer::new(layer, &document.network_interface) + .upstream_node_id_from_protonode(ellipse::IDENTIFIER) + .expect("ellipse node"); + let node = document.network_interface.document_network().nodes.get(&node_id).expect("ellipse document node"); + let number = |input: Option<&graph_craft::document::NodeInput>| match input.and_then(|input| input.as_value()) { + Some(TaggedValue::Number(value)) => *value, + other => panic!("expected a number, got {other:?}"), + }; + (number(node.input(ellipse::RadiusXInput)), number(node.input(ellipse::RadiusYInput))) + } + + #[tokio::test] + async fn test_scale_bakes_into_rectangle_and_ellipse_size() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; + let rectangle_layer = editor.active_document().metadata().all_layers().next().unwrap(); + + editor + .handle_message(NodeGraphMessage::SetInput { + input_connector: { + use graphene_std::vector::generator_nodes::rectangle; + let node_id = NodeGraphLayer::new(rectangle_layer, &editor.active_document().network_interface) + .upstream_node_id_from_protonode(rectangle::IDENTIFIER) + .unwrap(); + crate::messages::portfolio::document::utility_types::network_interface::InputConnector::node(node_id, rectangle::WidthInput) + }, + input: graph_craft::document::NodeInput::value(graph_craft::document::value::TaggedValue::Number(40.), false), + }) + .await; + editor.handle_message(TransformLayerMessage::BeginScale).await; + editor.handle_message(TransformLayerMessage::TypeDigit { digit: 2 }).await; + editor.handle_message(TransformLayerMessage::ApplyTransformOperation { final_transform: true }).await; + + let (width, height) = rectangle_size(&editor, rectangle_layer); + let rectangle_transform = get_layer_transform(&mut editor, rectangle_layer).await.unwrap(); + assert!((width - 80.).abs() < 1e-6, "Typed width of 40 scaled by 2 should be 80, got {width}"); + assert!((height - 200.).abs() < 1e-6, "Drawn height of 100 scaled by 2 should be 200, got {height}"); + assert!( + (rectangle_transform.matrix2.x_axis.length() - 1.).abs() < 1e-6, + "Rectangle transform scale should be 1 after the scale is baked" + ); + + editor.drag_tool(ToolType::Ellipse, 0., 0., 80., 40., ModifierKeys::empty()).await; + let ellipse_layer = editor.active_document().metadata().all_layers().next().unwrap(); + let (radius_x, radius_y) = ellipse_radii(&editor, ellipse_layer); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![ellipse_layer.to_node()] }).await; + editor.handle_message(TransformLayerMessage::BeginScale).await; + editor.handle_message(TransformLayerMessage::TypeDigit { digit: 2 }).await; + editor.handle_message(TransformLayerMessage::ApplyTransformOperation { final_transform: true }).await; + let (scaled_radius_x, scaled_radius_y) = ellipse_radii(&editor, ellipse_layer); + let ellipse_transform = get_layer_transform(&mut editor, ellipse_layer).await.unwrap(); + assert!((scaled_radius_x - radius_x * 2.).abs() < 1e-6, "Ellipse radius X should double, from {radius_x} to {scaled_radius_x}"); + assert!((scaled_radius_y - radius_y * 2.).abs() < 1e-6, "Ellipse radius Y should double, from {radius_y} to {scaled_radius_y}"); + assert!( + (ellipse_transform.matrix2.x_axis.length() - 1.).abs() < 1e-6, + "Ellipse transform scale should be 1 after the scale is baked" + ); + } } From 0f3c1f5baa3181e46092482d1e118e5082449087 Mon Sep 17 00:00:00 2001 From: Siddhartha Singh Date: Wed, 7 Oct 2026 16:27:44 +0530 Subject: [PATCH 2/3] Skip baking shape scale after a grab or rotate --- .../transform_layer_message_handler.rs | 61 ++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs b/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs index ad36711cd5f..43bbea7c080 100644 --- a/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs +++ b/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs @@ -87,6 +87,7 @@ pub struct TransformLayerMessageHandler { ptz: PTZ, initial_transform: DAffine2, operation_count: usize, + scaled_in_sequence: bool, was_grabbing: bool, // Pen tool (outgoing handle GRS manipulation) @@ -311,9 +312,11 @@ impl MessageHandler> for TransformLayerMessage::ApplyTransformOperation { final_transform } => { selected.original_transforms.clear(); self.typing.clear(); + let bake_scale = final_transform && self.scaled_in_sequence; if final_transform { self.transform_operation = TransformOperation::None; self.operation_count = 0; + self.scaled_in_sequence = false; } if using_pen_tool { @@ -324,7 +327,8 @@ impl MessageHandler> for selected.responses.add(PenToolMessage::Confirm); } else { update_colinear_handles(&selected_layers, document, responses); - if final_transform { + // A grab or rotate must not fold an existing scale into the generator + if bake_scale { for &layer in &selected_layers { responses.add(GraphOperationMessage::BakeShapeScale { layer }); } @@ -352,7 +356,10 @@ impl MessageHandler> for self.transform_operation = match operation { TransformType::Grab => TransformOperation::Grabbing(Default::default()), TransformType::Rotate => TransformOperation::Rotating(Default::default()), - TransformType::Scale => TransformOperation::Scaling(Default::default()), + TransformType::Scale => { + self.scaled_in_sequence = true; + TransformOperation::Scaling(Default::default()) + } }; self.layer_bounding_box = selected.bounding_box(); let bounding_box = select_tool::create_bounding_box_transform(document); @@ -488,6 +495,7 @@ impl MessageHandler> for TransformLayerMessage::BeginRotate => responses.add_front(TransformLayerMessage::BeginGRS { operation: TransformType::Rotate }), TransformLayerMessage::BeginScale => responses.add_front(TransformLayerMessage::BeginGRS { operation: TransformType::Scale }), TransformLayerMessage::CancelTransformOperation => { + self.scaled_in_sequence = false; if using_path_tool { self.ghost_outline.clear(); self.was_grabbing = false; @@ -1410,4 +1418,53 @@ mod test_transform_layer { "Ellipse transform scale should be 1 after the scale is baked" ); } + + #[tokio::test] + async fn test_grab_does_not_bake_existing_scale() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; + let layer = editor.active_document().metadata().all_layers().next().unwrap(); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await; + editor + .handle_message(GraphOperationMessage::TransformSet { + layer, + transform: DAffine2::from_scale(glam::DVec2::splat(2.)), + transform_in: crate::messages::portfolio::document::graph_operation::utility_types::TransformIn::Local, + skip_rerender: false, + }) + .await; + + let (width, height) = rectangle_size(&editor, layer); + editor.handle_message(TransformLayerMessage::BeginGrab).await; + editor.handle_message(TransformLayerMessage::ApplyTransformOperation { final_transform: true }).await; + + let (width_after_grab, height_after_grab) = rectangle_size(&editor, layer); + let transform = get_layer_transform(&mut editor, layer).await.unwrap(); + assert_eq!(width_after_grab, width, "Moving must not rewrite the rectangle width"); + assert_eq!(height_after_grab, height, "Moving must not rewrite the rectangle height"); + assert!((transform.matrix2.x_axis.length() - 2.).abs() < 1e-6, "An existing scale must stay on the Transform node after a move"); + } + + #[tokio::test] + async fn test_scale_then_grab_still_bakes() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; + let layer = editor.active_document().metadata().all_layers().next().unwrap(); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await; + + editor.handle_message(TransformLayerMessage::BeginScale).await; + editor.handle_message(TransformLayerMessage::TypeDigit { digit: 2 }).await; + editor.handle_message(TransformLayerMessage::BeginGrab).await; + editor.handle_message(TransformLayerMessage::ApplyTransformOperation { final_transform: true }).await; + + let (width, height) = rectangle_size(&editor, layer); + let transform = get_layer_transform(&mut editor, layer).await.unwrap(); + assert!( + (width - 200.).abs() < 1e-6 && (height - 200.).abs() < 1e-6, + "A scale chained into a move should still bake, got {width}x{height}" + ); + assert!((transform.matrix2.x_axis.length() - 1.).abs() < 1e-6, "The chained scale should leave the Transform scale at 1"); + } } From 72aa5bb452c4c8804201da561f9126763d125e70 Mon Sep 17 00:00:00 2001 From: Siddhartha Singh Date: Tue, 13 Oct 2026 13:32:55 +0530 Subject: [PATCH 3/3] Write shape size during a bounds drag and keep a moved point in place --- .../graph_operation_message.rs | 8 +- .../graph_operation_message_handler.rs | 5 + .../document/graph_operation/utility_types.rs | 210 +++++++++++++++++- .../transformation_cage.rs | 5 + .../common_functionality/utility_functions.rs | 16 ++ .../tool/tool_messages/select_tool.rs | 2 + .../messages/tool/tool_messages/shape_tool.rs | 2 + .../transform_layer_message_handler.rs | 129 ++++++++++- .../src/vector/vector_modification.rs | 27 +++ 9 files changed, 400 insertions(+), 4 deletions(-) diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index 440f0052a2a..c6d365c2089 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -1,4 +1,4 @@ -use super::utility_types::TransformIn; +use super::utility_types::{DraggedShapeSize, TransformIn}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::NodeTemplate; use crate::messages::prelude::*; @@ -111,6 +111,12 @@ pub enum GraphOperationMessage { BakeShapeScale { layer: LayerNodeIdentifier, }, + /// Write a rectangle or ellipse size from the size captured at the start of a bounds drag. + /// The gesture scale is whatever is on the Transform right now. The stored size is replaced, not multiplied again. + WriteDraggedShapeSize { + layer: LayerNodeIdentifier, + size: DraggedShapeSize, + }, Vector { layer: LayerNodeIdentifier, modification_type: VectorModificationType, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 330724937ef..f8c42450d77 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -169,6 +169,11 @@ impl MessageHandler> for modify_inputs.bake_scale_into_shape_generator(); } } + GraphOperationMessage::WriteDraggedShapeSize { layer, size } => { + if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { + modify_inputs.write_dragged_shape_size(size); + } + } GraphOperationMessage::Vector { layer, modification_type } => { if layer == LayerNodeIdentifier::ROOT_PARENT { log::error!("Cannot run Vector on ROOT_PARENT"); diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index fbbc0e50fd6..0a8a6c9a5b1 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -6,7 +6,7 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye use crate::messages::portfolio::document::utility_types::network_interface::{self, FlowType, InputConnector, NodeNetworkInterface, OutputConnector}; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils::{ - ReplaceablePaintChain, get_ellipse_id, get_fill_input_node_id, get_rectangle_id, get_upstream_gradient_value_node_id, gradient_chain_target_input, replaceable_paint_chain, + NodeGraphLayer, ReplaceablePaintChain, get_ellipse_id, get_fill_input_node_id, get_rectangle_id, get_upstream_gradient_value_node_id, gradient_chain_target_input, replaceable_paint_chain, }; use glam::{DAffine2, DVec2, IVec2}; use graph_craft::application_io::resource::ResourceId; @@ -36,6 +36,61 @@ fn literal_number(input: Option<&NodeInput>) -> Option { } } +/// Rectangle or ellipse size at the moment a bounds drag starts. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum ShapeSizeBasis { + Rectangle { width: f64, height: f64, corners: Vec }, + Ellipse { radius_x: f64, radius_y: f64 }, +} + +/// Size and path offsets from the start of a bounds drag. +/// Each mouse move writes this size times the current gesture scale. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct DraggedShapeSize { + pub basis: ShapeSizeBasis, + pub path_offsets: Option, +} + +/// Read the literal rectangle or ellipse size, plus any Path-tool offsets, before a bounds drag changes them. +pub fn capture_dragged_shape_size(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { + if layer == LayerNodeIdentifier::ROOT_PARENT { + return None; + } + if layer.children(network_interface.document_metadata()).next().is_some() { + return None; + } + + let basis = if let Some(node_id) = get_rectangle_id(layer, network_interface) { + let node = network_interface.document_network().nodes.get(&node_id)?; + let width = literal_number(node.input(graphene_std::vector::generator_nodes::rectangle::WidthInput))?; + let height = literal_number(node.input(graphene_std::vector::generator_nodes::rectangle::HeightInput))?; + let TaggedValue::BoxCorners(corners) = node.input(graphene_std::vector::generator_nodes::rectangle::CornerRadiusInput).and_then(|input| input.as_value())? else { + return None; + }; + ShapeSizeBasis::Rectangle { + width, + height, + corners: corners.clone(), + } + } else if let Some(node_id) = get_ellipse_id(layer, network_interface) { + let node = network_interface.document_network().nodes.get(&node_id)?; + let radius_x = literal_number(node.input(graphene_std::vector::generator_nodes::ellipse::RadiusXInput))?; + let radius_y = literal_number(node.input(graphene_std::vector::generator_nodes::ellipse::RadiusYInput))?; + ShapeSizeBasis::Ellipse { radius_x, radius_y } + } else { + return None; + }; + + let path_offsets = NodeGraphLayer::new(layer, network_interface) + .find_input(&DefinitionIdentifier::Network("Path".into()), 1) + .and_then(|value| match value { + TaggedValue::VectorModification(modification) if modification.as_ref() != &VectorModification::default() => Some(modification.as_ref().clone()), + _ => None, + }); + + Some(DraggedShapeSize { basis, path_offsets }) +} + // This struct is helpful to prevent passing the same arguments to multiple functions // Should only be used by GraphOperationMessage, since it only affects the document network. pub struct ModifyInputsContext<'a> { @@ -1012,6 +1067,7 @@ impl<'a> ModifyInputsContext<'a> { /// Fold scale from this layer's Transform node into a rectangle or ellipse whose size inputs are plain numbers. /// Position and rotation stay on the Transform node. Connected inputs, skew, and non-uniform scale on a rounded rectangle are left alone. + /// Offsets from a Path edit grow by the same scale, so a moved corner stays where the drag left it. pub fn bake_scale_into_shape_generator(&mut self) { let Some(layer) = self.layer_node else { return }; // A group has no single rectangle or ellipse size. Its scale has to stay on the group Transform. @@ -1052,13 +1108,163 @@ impl<'a> ModifyInputsContext<'a> { return; } + // The sign of the scale stays on the Transform. Path offsets only need the size change. + self.scale_current_path_offsets(DVec2::new(scale.x.abs(), scale.y.abs())); + self.finish_shape_scale(transform_node_id, scale, rotation, transform.translation); + } + + /// During a bounds drag, write the size captured at drag start times the gesture scale. + /// The Transform scale is put back to its sign, so the width shown in the panel is the width being dragged. + pub fn write_dragged_shape_size(&mut self, size: DraggedShapeSize) { + let Some(layer) = self.layer_node else { return }; + if layer.children(self.network_interface.document_metadata()).next().is_some() { + return; + } + let Some(transform_node_id) = self.existing_proto_node_id(graphene_std::transform_nodes::transform::IDENTIFIER, false) else { + return; + }; + + let transform = { + let Some(node) = self.network_interface.document_network().nodes.get(&transform_node_id) else { + return; + }; + let literal_transform = (1..=4).all(|index| node.inputs.get(index).and_then(|input| input.as_value()).is_some()); + if !literal_transform { + return; + } + transform_utils::get_current_transform(&node.inputs) + }; + + let (rotation, scale, skew) = transform.decompose_rotation_scale_skew(); + if !scale.x.is_finite() || !scale.y.is_finite() || skew.abs() > 1e-4 { + return; + } + + let wrote = match &size.basis { + ShapeSizeBasis::Rectangle { width, height, corners } => self.write_rectangle_size(*width, *height, corners, scale), + ShapeSizeBasis::Ellipse { radius_x, radius_y } => self.write_ellipse_size(*radius_x, *radius_y, scale), + }; + if !wrote { + return; + } + + if let Some(path_offsets) = size.path_offsets { + let mut path_offsets = path_offsets; + path_offsets.scale_offsets(DVec2::new(scale.x.abs(), scale.y.abs())); + self.replace_path_offsets(path_offsets); + } + self.finish_shape_scale(transform_node_id, scale, rotation, transform.translation); + } + + fn finish_shape_scale(&mut self, transform_node_id: NodeId, scale: DVec2, rotation: f64, translation: DVec2) { let signed_scale = DVec2::new(if scale.x < 0. { -1. } else { 1. }, if scale.y < 0. { -1. } else { 1. }); - let without_scale = DAffine2::from_scale_angle_translation(signed_scale, rotation, transform.translation); + let without_scale = DAffine2::from_scale_angle_translation(signed_scale, rotation, translation); transform_utils::update_transform(self.network_interface, &transform_node_id, without_scale); self.responses.add(PropertiesPanelMessage::Refresh); self.responses.add(NodeGraphMessage::RunDocumentGraph); } + fn scale_current_path_offsets(&mut self, scale: DVec2) { + let Some(path_node_id) = self.existing_network_node_id("Path", false) else { + return; + }; + let modification = { + let Some(node) = self.network_interface.document_network().nodes.get(&path_node_id) else { + return; + }; + let Some(TaggedValue::VectorModification(modification)) = node.inputs.get(1).and_then(|input| input.as_value()) else { + return; + }; + if modification.as_ref() == &VectorModification::default() { + return; + } + let mut modification = modification.as_ref().clone(); + modification.scale_offsets(scale); + modification + }; + self.replace_path_offsets(modification); + } + + fn replace_path_offsets(&mut self, modification: VectorModification) { + let Some(path_node_id) = self.existing_network_node_id("Path", false) else { + return; + }; + self.network_interface.set_input( + &InputConnector::node_at_index(path_node_id, 1), + NodeInput::value(TaggedValue::VectorModification(Box::new(modification)), false), + &[], + ); + } + + fn write_rectangle_size(&mut self, width: f64, height: f64, corners: &[f64], scale: DVec2) -> bool { + use graphene_std::vector::generator_nodes::rectangle; + + let Some(layer) = self.layer_node else { return false }; + let Some(node_id) = get_rectangle_id(layer, self.network_interface) else { return false }; + let inputs_are_literal = { + let Some(node) = self.network_interface.document_network().nodes.get(&node_id) else { + return false; + }; + literal_number(node.input(rectangle::WidthInput)).is_some() && literal_number(node.input(rectangle::HeightInput)).is_some() + }; + if !inputs_are_literal { + return false; + } + + let uniform = (scale.x.abs() - scale.y.abs()).abs() <= 1e-6 * scale.x.abs().max(scale.y.abs()).max(1.); + if !uniform && corners.iter().any(|radius| radius.abs() > 1e-9) { + return false; + } + + self.network_interface.set_input( + &InputConnector::node(node_id, rectangle::WidthInput), + NodeInput::value(TaggedValue::Number(width * scale.x.abs()), false), + &[], + ); + self.network_interface.set_input( + &InputConnector::node(node_id, rectangle::HeightInput), + NodeInput::value(TaggedValue::Number(height * scale.y.abs()), false), + &[], + ); + if uniform && corners.iter().any(|radius| radius.abs() > 1e-9) { + let scaled_corners = corners.iter().map(|radius| radius * scale.x.abs()).collect::>(); + self.network_interface.set_input( + &InputConnector::node(node_id, rectangle::CornerRadiusInput), + NodeInput::value(TaggedValue::BoxCorners(scaled_corners), false), + &[], + ); + } + true + } + + fn write_ellipse_size(&mut self, radius_x: f64, radius_y: f64, scale: DVec2) -> bool { + use graphene_std::vector::generator_nodes::ellipse; + + let Some(layer) = self.layer_node else { return false }; + let Some(node_id) = get_ellipse_id(layer, self.network_interface) else { return false }; + let inputs_are_literal = { + let Some(node) = self.network_interface.document_network().nodes.get(&node_id) else { + return false; + }; + literal_number(node.input(ellipse::RadiusXInput)).is_some() && literal_number(node.input(ellipse::RadiusYInput)).is_some() + }; + if !inputs_are_literal { + return false; + } + + self.network_interface.set_input( + &InputConnector::node(node_id, ellipse::RadiusXInput), + NodeInput::value(TaggedValue::Number(radius_x * scale.x.abs()), false), + &[], + ); + self.network_interface.set_input( + &InputConnector::node(node_id, ellipse::RadiusYInput), + NodeInput::value(TaggedValue::Number(radius_y * scale.y.abs()), false), + &[], + ); + true + } + fn bake_rectangle_scale(&mut self, node_id: NodeId, scale: DVec2) -> bool { use graphene_std::vector::generator_nodes::rectangle; diff --git a/editor/src/messages/tool/common_functionality/transformation_cage.rs b/editor/src/messages/tool/common_functionality/transformation_cage.rs index ce82f284b63..0f3bdc711d3 100644 --- a/editor/src/messages/tool/common_functionality/transformation_cage.rs +++ b/editor/src/messages/tool/common_functionality/transformation_cage.rs @@ -5,13 +5,16 @@ use crate::consts::{ SKEW_TRIANGLE_SIZE, }; use crate::messages::frontend::utility_types::MouseCursorIcon; +use crate::messages::portfolio::document::graph_operation::utility_types::DraggedShapeSize; 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::transformation::OriginalTransforms; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::snapping::SnapTypeConfiguration; use glam::{DAffine2, DMat2, DVec2}; use graphene_std::renderer::Quad; use graphene_std::renderer::Rect; +use std::collections::HashMap; /// (top, bottom, left, right) pub type EdgeBool = (bool, bool, bool, bool); @@ -379,6 +382,8 @@ pub struct BoundingBoxManager { pub original_bound_transform: DAffine2, pub selected_edges: Option, pub original_transforms: OriginalTransforms, + /// Rectangle or ellipse size at the start of this bounds drag. Empty until the first move. + pub shape_resize_basis: HashMap, pub opposite_pivot: DVec2, pub center_of_transformation: DVec2, } diff --git a/editor/src/messages/tool/common_functionality/utility_functions.rs b/editor/src/messages/tool/common_functionality/utility_functions.rs index 56e1ac695dc..19665f9e24d 100644 --- a/editor/src/messages/tool/common_functionality/utility_functions.rs +++ b/editor/src/messages/tool/common_functionality/utility_functions.rs @@ -1,6 +1,7 @@ use super::snapping::{SnapCandidatePoint, SnapData, SnapManager}; use super::transformation_cage::{BoundingBoxManager, SizeSnapData}; use crate::consts::ROTATE_INCREMENT; +use crate::messages::portfolio::document::graph_operation::utility_types::capture_dragged_shape_size; use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::{NodeNetworkInterface, OutputConnector}; @@ -250,8 +251,23 @@ pub fn resize_bounds( } }); + if bounds.shape_resize_basis.is_empty() { + for &layer in dragging_layers.iter() { + if let Some(captured) = capture_dragged_shape_size(layer, &document.network_interface) { + bounds.shape_resize_basis.insert(layer, captured); + } + } + } + let mut selected = Selected::new(&mut bounds.original_transforms, &mut pivot, dragging_layers, responses, &document.network_interface, None, &tool, None); selected.apply_transformation(bounds.original_bound_transform * transformation * bounds.original_bound_transform.inverse(), None); + drop(selected); + + for &layer in dragging_layers.iter() { + if let Some(size) = bounds.shape_resize_basis.get(&layer).cloned() { + responses.add(GraphOperationMessage::WriteDraggedShapeSize { layer, size }); + } + } } } diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs index 7c30df84e04..cc4c29187ab 100644 --- a/editor/src/messages/tool/tool_messages/select_tool.rs +++ b/editor/src/messages/tool/tool_messages/select_tool.rs @@ -1640,6 +1640,7 @@ impl Fsm for SelectToolFsmState { && let Some(bounds) = &mut tool_data.bounding_box_manager { bounds.original_transforms.clear(); + bounds.shape_resize_basis.clear(); } let selection = tool_data.nested_selection_behavior; @@ -1755,6 +1756,7 @@ impl Fsm for SelectToolFsmState { if let Some(bounds) = &mut tool_data.bounding_box_manager { bounds.original_transforms.clear(); + bounds.shape_resize_basis.clear(); } responses.add(DocumentMessage::AbortTransaction); diff --git a/editor/src/messages/tool/tool_messages/shape_tool.rs b/editor/src/messages/tool/tool_messages/shape_tool.rs index ce69db33591..5898bc787f4 100644 --- a/editor/src/messages/tool/tool_messages/shape_tool.rs +++ b/editor/src/messages/tool/tool_messages/shape_tool.rs @@ -1336,6 +1336,7 @@ impl Fsm for ShapeToolFsmState { if let Some(bounds) = &mut tool_data.bounding_box_manager { bounds.original_transforms.clear(); + bounds.shape_resize_basis.clear(); } tool_data.line_data.dragging_endpoint = None; @@ -1363,6 +1364,7 @@ impl Fsm for ShapeToolFsmState { if let Some(bounds) = &mut tool_data.bounding_box_manager { bounds.original_transforms.clear(); + bounds.shape_resize_basis.clear(); } tool_data.cursor = MouseCursorIcon::Crosshair; diff --git a/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs b/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs index 43bbea7c080..9cf8d50247e 100644 --- a/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs +++ b/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs @@ -773,7 +773,7 @@ fn update_colinear_handles(selected_layers: &[LayerNodeIdentifier], document: &D #[cfg(test)] mod test_transform_layer { use crate::messages::portfolio::document::graph_operation::transform_utils; - use crate::messages::portfolio::document::graph_operation::utility_types::ModifyInputsContext; + use crate::messages::portfolio::document::graph_operation::utility_types::{ModifyInputsContext, capture_dragged_shape_size}; use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; use crate::messages::portfolio::document::utility_types::misc::GroupFolderType; use crate::messages::prelude::Message; @@ -1467,4 +1467,131 @@ mod test_transform_layer { ); assert!((transform.matrix2.x_axis.length() - 1.).abs() < 1e-6, "The chained scale should leave the Transform scale at 1"); } + + fn local_point_positions(editor: &EditorTestUtils, layer: LayerNodeIdentifier) -> Vec { + let document = editor.active_document(); + let vector = document.network_interface.compute_modified_vector(layer).expect("layer vector"); + vector.point_domain.positions().to_vec() + } + + #[tokio::test] + async fn test_dragged_shape_size_does_not_compound() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; + let layer = editor.active_document().metadata().all_layers().next().unwrap(); + let size = capture_dragged_shape_size(layer, &editor.active_document().network_interface).expect("rectangle basis"); + + for scale in [2., 3.] { + editor + .handle_message(GraphOperationMessage::TransformSet { + layer, + transform: DAffine2::from_scale(DVec2::splat(scale)), + transform_in: crate::messages::portfolio::document::graph_operation::utility_types::TransformIn::Local, + skip_rerender: false, + }) + .await; + editor.handle_message(GraphOperationMessage::WriteDraggedShapeSize { layer, size: size.clone() }).await; + } + + let (width, height) = rectangle_size(&editor, layer); + let transform = get_layer_transform(&mut editor, layer).await.unwrap(); + assert!( + (width - 300.).abs() < 1e-6 && (height - 300.).abs() < 1e-6, + "The second move should replace the size, got {width}x{height}" + ); + assert!((transform.matrix2.x_axis.length() - 1.).abs() < 1e-6, "The Transform scale should stay at 1 while the width is dragged"); + } + + #[tokio::test] + async fn test_path_point_edit_keeps_its_place_when_scale_is_baked() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; + let layer = editor.active_document().metadata().all_layers().next().unwrap(); + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await; + + let point = editor.active_document().network_interface.compute_modified_vector(layer).expect("rectangle vector").point_domain.ids()[0]; + editor + .handle_message(GraphOperationMessage::Vector { + layer, + modification_type: VectorModificationType::ApplyPointDelta { point, delta: DVec2::new(25., -10.) }, + }) + .await; + let size = capture_dragged_shape_size(layer, &editor.active_document().network_interface).expect("rectangle basis"); + let before = local_point_positions(&editor, layer); + + editor + .handle_message(GraphOperationMessage::TransformSet { + layer, + transform: DAffine2::from_scale(DVec2::splat(2.)), + transform_in: crate::messages::portfolio::document::graph_operation::utility_types::TransformIn::Local, + skip_rerender: false, + }) + .await; + editor.handle_message(GraphOperationMessage::WriteDraggedShapeSize { layer, size: size.clone() }).await; + editor + .handle_message(GraphOperationMessage::TransformSet { + layer, + transform: DAffine2::from_scale(DVec2::splat(4.)), + transform_in: crate::messages::portfolio::document::graph_operation::utility_types::TransformIn::Local, + skip_rerender: false, + }) + .await; + editor.handle_message(GraphOperationMessage::WriteDraggedShapeSize { layer, size }).await; + + let after = local_point_positions(&editor, layer); + for (before, after) in before.iter().zip(&after) { + assert!(after.distance(*before * 4.) < 1e-4, "A moved corner should follow the drag, expected {:?}, got {after:?}", *before * 4.); + } + let (width, height) = rectangle_size(&editor, layer); + let transform = get_layer_transform(&mut editor, layer).await.unwrap(); + assert!( + (width - 400.).abs() < 1e-6 && (height - 400.).abs() < 1e-6, + "Width should be the drag-start width times 4, got {width}x{height}" + ); + assert!((transform.matrix2.x_axis.length() - 1.).abs() < 1e-6, "The Transform scale should stay at 1"); + } + + #[tokio::test] + async fn test_rounded_rectangle_keeps_non_uniform_scale_on_the_transform() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; + let layer = editor.active_document().metadata().all_layers().next().unwrap(); + let node_id = { + use graphene_std::vector::generator_nodes::rectangle; + crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer::new(layer, &editor.active_document().network_interface) + .upstream_node_id_from_protonode(rectangle::IDENTIFIER) + .unwrap() + }; + editor + .handle_message(NodeGraphMessage::SetInput { + input_connector: crate::messages::portfolio::document::utility_types::network_interface::InputConnector::node( + node_id, + graphene_std::vector::generator_nodes::rectangle::CornerRadiusInput, + ), + input: graph_craft::document::NodeInput::value(graph_craft::document::value::TaggedValue::BoxCorners(vec![5.]), false), + }) + .await; + let size = capture_dragged_shape_size(layer, &editor.active_document().network_interface).expect("rounded rectangle basis"); + editor + .handle_message(GraphOperationMessage::TransformSet { + layer, + transform: DAffine2::from_scale(DVec2::new(2., 3.)), + transform_in: crate::messages::portfolio::document::graph_operation::utility_types::TransformIn::Local, + skip_rerender: false, + }) + .await; + editor.handle_message(GraphOperationMessage::WriteDraggedShapeSize { layer, size }).await; + + let (width, height) = rectangle_size(&editor, layer); + let transform = get_layer_transform(&mut editor, layer).await.unwrap(); + assert!( + (width - 100.).abs() < 1e-6 && (height - 100.).abs() < 1e-6, + "A non-uniform scale on a rounded rectangle should leave the size alone, got {width}x{height}" + ); + assert!((transform.matrix2.x_axis.length() - 2.).abs() < 1e-6, "That scale should stay on the Transform"); + assert!((transform.matrix2.y_axis.length() - 3.).abs() < 1e-6, "The vertical scale should stay on the Transform"); + } } diff --git a/node-graph/libraries/vector-types/src/vector/vector_modification.rs b/node-graph/libraries/vector-types/src/vector/vector_modification.rs index fc7f6f498ad..7dce0b36f7a 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_modification.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_modification.rs @@ -445,6 +445,19 @@ impl VectorModification { } } + /// Multiply stored point offsets and handle offsets by `scale`. + /// Point ids and segment connectivity stay the same. + pub fn scale_offsets(&mut self, scale: DVec2) { + for delta in self.points.delta.values_mut() { + *delta *= scale; + } + for handle in self.segments.handle_primary.values_mut().chain(self.segments.handle_end.values_mut()) { + if let Some(position) = handle { + *position *= scale; + } + } + } + /// Create a new modification that will convert an empty [`Vector`] into the target [`Vector`]. pub fn create_from_vector(vector: &Vector) -> Self { Self { @@ -733,6 +746,20 @@ mod tests { use crate::vector::algorithms::shapes::{ellipse_bezpath, rectangle_bezpath}; use kurbo::{PathSeg, QuadBez}; + #[test] + fn scale_offsets_grows_a_moved_point() { + let vector = Vector::from_bezpath(rectangle_bezpath(DVec2::NEG_ONE, DVec2::ONE)); + let point = vector.point_domain.ids()[0]; + let mut modification = VectorModification::default(); + modification.modify(&VectorModificationType::ApplyPointDelta { point, delta: DVec2::new(2., 4.) }); + modification.scale_offsets(DVec2::new(3., 0.5)); + + let mut scaled = vector.clone(); + modification.apply(&mut scaled); + let expected = vector.point_domain.position_from_id(point).unwrap() + DVec2::new(6., 2.); + assert_eq!(scaled.point_domain.position_from_id(point), Some(expected)); + } + #[test] fn modify_new() { let mut vector = Vector::from_bezpath(ellipse_bezpath(DVec2::ZERO, DVec2::ONE));