diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml
index 9e40d504b04..b9c2af737b1 100644
--- a/.github/workflows/website.yml
+++ b/.github/workflows/website.yml
@@ -7,15 +7,9 @@ on:
- master
paths:
- website/**
- - frontend/eslint.config.js
- - frontend/eslint-require-data-selectors.js
- - frontend/package-lock.json
pull_request:
paths:
- website/**
- - frontend/eslint.config.js
- - frontend/eslint-require-data-selectors.js
- - frontend/package-lock.json
env:
CARGO_TERM_COLOR: always
INDEX_HTML_HEAD_INCLUSION:
@@ -36,10 +30,6 @@ jobs:
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- cache: npm
- cache-dependency-path: |
- frontend/package-lock.json
- website/package-lock.json
- name: 🕸 Install Zola
uses: taiki-e/install-action@v2
@@ -65,12 +55,9 @@ jobs:
cargo run -p crate-hierarchy-viz -- website/generated
cargo run -p editor-message-tree -- website/generated
- - name: 🔧 Install website and frontend npm dependencies
+ - name: 🔧 Install website npm dependencies
run: |
- # The website is linted by the frontend's ESLint config and the tools it installs
- cd frontend
- npm ci
- cd ../website
+ cd website
npm ci
- name: 📃 Generate node catalog documentation
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..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::*;
@@ -107,6 +107,16 @@ 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,
+ },
+ /// 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 4c867ee2321..bbeb7d12832 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,16 @@ 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::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 68860a640c5..6a51fecaa4a 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,
+ 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;
@@ -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,68 @@ 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,
+ }
+}
+
+/// 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> {
@@ -1003,6 +1066,272 @@ 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.
+ 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;
+ }
+
+ // 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, translation);
+ transform_utils::update_transform(self.network_interface, &transform_node_id, without_scale);
+ self.responses.add(InspectorMessage::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;
+
+ 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 Inspector, 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/common_functionality/transformation_cage.rs b/editor/src/messages/tool/common_functionality/transformation_cage.rs
index c8b97efec44..23bcadb36cc 100644
--- a/editor/src/messages/tool/common_functionality/transformation_cage.rs
+++ b/editor/src/messages/tool/common_functionality/transformation_cage.rs
@@ -5,12 +5,14 @@ use crate::consts::{
MIN_LENGTH_FOR_MIDPOINT_VISIBILITY, MIN_LENGTH_FOR_RESIZE_TO_INCLUDE_INTERIOR, MIN_LENGTH_FOR_SKEW_TRIANGLE_VISIBILITY, RESIZE_HANDLE_SIZE, SKEW_TRIANGLE_OFFSET, 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 glam::{DAffine2, DMat2, DVec2};
use graphene_std::renderer::Quad;
+use std::collections::HashMap;
/// (top, bottom, left, right)
pub type EdgeBool = (bool, bool, bool, bool);
@@ -373,6 +375,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,
/// What a turn of the cage under way snaps by.
diff --git a/editor/src/messages/tool/common_functionality/utility_functions.rs b/editor/src/messages/tool/common_functionality/utility_functions.rs
index 754b3defd1f..1bd4782667d 100644
--- a/editor/src/messages/tool/common_functionality/utility_functions.rs
+++ b/editor/src/messages/tool/common_functionality/utility_functions.rs
@@ -1,5 +1,6 @@
use super::snap_session::{SnapSession, SnapView, anchor_positions};
use super::transformation_cage::{BoundingBoxManager, SizeSnapData};
+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};
@@ -297,8 +298,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 8d5b16e2549..3023b3022ce 100644
--- a/editor/src/messages/tool/tool_messages/select_tool.rs
+++ b/editor/src/messages/tool/tool_messages/select_tool.rs
@@ -1832,6 +1832,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 });
+ }
+ }
+ }
tool_data.pivot_before_drag = None;
let pivot_gizmo = tool_data.pivot_gizmo();
@@ -1846,6 +1853,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;
@@ -1960,6 +1968,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 3bef7104bc6..611486c29c0 100644
--- a/editor/src/messages/tool/tool_messages/shape_tool.rs
+++ b/editor/src/messages/tool/tool_messages/shape_tool.rs
@@ -1386,6 +1386,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();
snap_session.end_gesture();
@@ -1395,6 +1402,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;
@@ -1457,6 +1465,7 @@ fn cancel_drag(tool_data: &mut ShapeToolData, snap_session: &mut SnapSession, re
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 b29847431fe..576a15eeb57 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
@@ -94,6 +94,7 @@ pub struct TransformLayerMessageHandler {
ptz: PTZ,
initial_transform: DAffine2,
operation_count: usize,
+ scaled_in_sequence: bool,
was_grabbing: bool,
/// What the transform under way snaps by, if it began a snapping gesture.
snapping: Option,
@@ -323,9 +324,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 {
@@ -336,6 +339,12 @@ impl MessageHandler> for
selected.responses.add(PenToolMessage::Confirm);
} else {
update_colinear_handles(&selected_layers, document, responses);
+ // 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 });
+ }
+ }
responses.add(DocumentMessage::EndTransaction);
responses.add(ToolMessage::UpdateHints);
responses.add(NodeGraphMessage::RunDocumentGraph);
@@ -360,7 +369,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);
@@ -523,6 +535,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;
@@ -1039,7 +1052,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::portfolio::document::utility_types::transformation::TransformOperation;
@@ -1261,14 +1274,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"
);
}
@@ -1365,9 +1379,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]
@@ -1388,12 +1407,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]
@@ -1414,12 +1435,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]
@@ -1501,13 +1524,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;
@@ -1571,10 +1594,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();
@@ -1615,14 +1634,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 = {
@@ -1655,6 +1695,271 @@ mod test_transform_layer {
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"
+ );
+ }
+
+ #[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");
+ }
+
+ 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.positions().collect()
+ }
+
+ #[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_id_at(0)
+ .expect("first point");
+ 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");
+ }
+
#[tokio::test]
async fn a_grabbed_rectangle_brought_near_a_corner_of_another_lands_on_it_and_follows_the_pointer_again_once_moved_on() {
let mut editor = EditorTestUtils::create();
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 3b37667e406..6b37e39d1b5 100644
--- a/node-graph/libraries/vector-types/src/vector/vector_modification.rs
+++ b/node-graph/libraries/vector-types/src/vector/vector_modification.rs
@@ -878,6 +878,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, paths: &List) -> Self {
let working = WorkingVector::flatten(vector, paths);
@@ -1583,6 +1596,21 @@ mod tests {
}
}
+ #[test]
+ fn scale_offsets_grows_a_moved_point() {
+ let bezpath = rectangle_bezpath(DVec2::NEG_ONE, DVec2::ONE);
+ let (vector, paths) = Vector::from_bezpath(&bezpath);
+ let point = anchor_id(&vector, &paths, 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, mut scaled_paths) = (vector.clone(), paths.clone());
+ modification.apply(&mut scaled, &mut scaled_paths);
+ let expected = anchor_position(&vector, &paths, 0) + DVec2::new(6., 2.);
+ assert_eq!(anchor_position(&scaled, &scaled_paths, 0), expected);
+ }
+
#[test]
fn modify_new() {
let (mut vector, mut paths) = Vector::from_bezpath(&ellipse_bezpath(DVec2::ZERO, DVec2::ONE));