From 38df04de40b93e61eb4d09d666947a003b05b77d Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Thu, 26 Mar 2026 05:09:21 -0700 Subject: [PATCH 1/5] Refactor transform decomposition API with skew support, add Decompose Skew node, and fix stroke transform interpolation --- .../graph_operation/transform_utils.rs | 36 ++------- .../document/node_graph/node_properties.rs | 5 +- .../messages/portfolio/document_migration.rs | 12 +++ node-graph/graph-craft/src/document/value.rs | 1 + .../interpreted-executor/src/node_registry.rs | 2 + .../libraries/core-types/src/transform.rs | 77 +++++++++++++++++-- .../vector-types/src/vector/click_target.rs | 2 +- .../vector-types/src/vector/style.rs | 30 +++++++- .../vector-types/src/vector/vector_types.rs | 2 +- node-graph/nodes/gstd/src/pixel_preview.rs | 2 +- node-graph/nodes/gstd/src/render_cache.rs | 2 +- node-graph/nodes/gstd/src/render_node.rs | 2 +- node-graph/nodes/raster/src/std_nodes.rs | 2 +- .../nodes/transform/src/transform_nodes.rs | 21 +++-- node-graph/nodes/vector/src/vector_nodes.rs | 2 +- 15 files changed, 144 insertions(+), 54 deletions(-) diff --git a/editor/src/messages/portfolio/document/graph_operation/transform_utils.rs b/editor/src/messages/portfolio/document/graph_operation/transform_utils.rs index db4e110e77..8d5bcabfd7 100644 --- a/editor/src/messages/portfolio/document/graph_operation/transform_utils.rs +++ b/editor/src/messages/portfolio/document/graph_operation/transform_utils.rs @@ -3,39 +3,16 @@ use glam::{DAffine2, DVec2}; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; use graphene_std::subpath::Subpath; +use graphene_std::transform::Transform; use graphene_std::vector::PointId; -/// Convert an affine transform into the tuple `(scale, angle, translation, shear)` assuming `shear.y = 0`. -pub fn compute_scale_angle_translation_shear(transform: DAffine2) -> (DVec2, f64, DVec2, DVec2) { - let x_axis = transform.matrix2.x_axis; - let y_axis = transform.matrix2.y_axis; - - // Assuming there is no vertical shear - let angle = x_axis.y.atan2(x_axis.x); - let (sin, cos) = angle.sin_cos(); - let scale_x = if cos.abs() > 1e-10 { x_axis.x / cos } else { x_axis.y / sin }; - - let mut shear_x = (sin * y_axis.y + cos * y_axis.x) / (sin * sin * scale_x + cos * cos * scale_x); - if !shear_x.is_finite() { - shear_x = 0.; - } - let scale_y = if cos.abs() > 1e-10 { - (y_axis.y - scale_x * sin * shear_x) / cos - } else { - (scale_x * cos * shear_x - y_axis.x) / sin - }; - let translation = transform.translation; - let scale = DVec2::new(scale_x, scale_y); - let shear = DVec2::new(shear_x, 0.); - (scale, angle, translation, shear) -} - /// Update the inputs of the transform node to match a new transform pub fn update_transform(network_interface: &mut NodeNetworkInterface, node_id: &NodeId, transform: DAffine2) { - let (scale, rotation, translation, shear) = compute_scale_angle_translation_shear(transform); + let (rotation, scale, skew) = transform.decompose_rotation_scale_skew(); + let translation = transform.translation; let rotation = rotation.to_degrees(); - let shear = DVec2::new(shear.x.atan().to_degrees(), shear.y.atan().to_degrees()); + let shear = DVec2::new(skew.atan().to_degrees(), 0.); network_interface.set_input(&InputConnector::node(*node_id, 1), NodeInput::value(TaggedValue::DVec2(translation), false), &[]); network_interface.set_input(&InputConnector::node(*node_id, 2), NodeInput::value(TaggedValue::F64(rotation), false), &[]); @@ -154,8 +131,9 @@ mod tests { translate, ); - let (new_scale, new_angle, new_translation, new_shear) = compute_scale_angle_translation_shear(original_transform); - let new_transform = DAffine2::from_scale_angle_translation(new_scale, new_angle, new_translation) * DAffine2::from_cols_array(&[1., new_shear.y, new_shear.x, 1., 0., 0.]); + let (new_angle, new_scale, new_skew) = original_transform.decompose_rotation_scale_skew(); + let new_translation = original_transform.translation; + let new_transform = DAffine2::from_scale_angle_translation(new_scale, new_angle, new_translation) * DAffine2::from_cols_array(&[1., 0., new_skew, 1., 0., 0.]); assert!( new_transform.abs_diff_eq(original_transform, 1e-10), diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 5df11baa3e..36c3a71c71 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -23,7 +23,7 @@ use graphene_std::raster::{ }; use graphene_std::table::{Table, TableRow}; use graphene_std::text::{Font, TextAlign}; -use graphene_std::transform::{Footprint, ReferencePoint, Transform}; +use graphene_std::transform::{Footprint, ReferencePoint, ScaleType, Transform}; use graphene_std::vector::QRCodeErrorCorrectionLevel; use graphene_std::vector::misc::BooleanOperation; use graphene_std::vector::misc::{ArcType, CentroidType, ExtrudeJoiningAlgorithm, GridType, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType}; @@ -265,6 +265,7 @@ pub(crate) fn property_from_type( Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), + Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), // ===== // OTHER // ===== @@ -567,7 +568,7 @@ pub fn transform_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widg let widgets = if let Some(&TaggedValue::DAffine2(transform)) = input.as_non_exposed_value() { let translation = transform.translation; let rotation = transform.decompose_rotation(); - let scale = transform.decompose_scale(); + let scale = transform.scale_magnitudes(); location_widgets.extend_from_slice(&[ NumberInput::new(Some(translation.x)) diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index bddc2197ab..c82892adee 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -12,6 +12,7 @@ use graphene_std::ProtoNodeIdentifier; use graphene_std::subpath::Subpath; use graphene_std::table::Table; use graphene_std::text::{TextAlign, TypesettingConfig}; +use graphene_std::transform::ScaleType; use graphene_std::uuid::NodeId; use graphene_std::vector::Vector; use graphene_std::vector::style::{PaintOrder, StrokeAlign}; @@ -1848,6 +1849,17 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], document.network_interface.set_context_features(node_id, network_path, context_features); } + // Add the "Scale Type" parameter to the "Decompose Scale" node + if reference == DefinitionIdentifier::ProtoNode(graphene_std::transform_nodes::decompose_scale::IDENTIFIER) && inputs_count == 1 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + + document.network_interface.set_input(&InputConnector::node(*node_id, 0), old_inputs[0].clone(), network_path); + document + .network_interface + .set_input(&InputConnector::node(*node_id, 1), NodeInput::value(TaggedValue::ScaleType(ScaleType::Magnitude), false), network_path); + } + // ================================== // PUT ALL MIGRATIONS ABOVE THIS LINE // ================================== diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 7126db8a66..5ea1d42e72 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -272,6 +272,7 @@ tagged_value! { CentroidType(vector::misc::CentroidType), BooleanOperation(vector::misc::BooleanOperation), TextAlign(text_nodes::TextAlign), + ScaleType(core_types::transform::ScaleType), } impl TaggedValue { diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 74b2733afa..4f0d6716a7 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -134,6 +134,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => graphene_std::transform::ReferencePoint]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::vector::misc::CentroidType]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::text::TextAlign]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::transform::ScaleType]), // Context nullification #[cfg(feature = "gpu")] async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => &WasmEditorApi, Context => graphene_std::ContextFeatures]), @@ -227,6 +228,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => graphene_std::vector::misc::CentroidType]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::vector::misc::BooleanOperation]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::text::TextAlign]), + async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::transform::ScaleType]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => RenderIntermediate]), ]; // ============= diff --git a/node-graph/libraries/core-types/src/transform.rs b/node-graph/libraries/core-types/src/transform.rs index ed408959b9..4b1da0276e 100644 --- a/node-graph/libraries/core-types/src/transform.rs +++ b/node-graph/libraries/core-types/src/transform.rs @@ -1,7 +1,21 @@ use crate::math::bbox::AxisAlignedBbox; use core::f64; +use dyn_any::DynAny; use glam::{DAffine2, DMat2, DVec2, UVec2}; +/// Controls whether the Decompose Scale node returns axis-length magnitudes or pure scale factors. +#[repr(C)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, node_macro::ChoiceType)] +#[widget(Radio)] +pub enum ScaleType { + /// The visual length of each axis (always positive, includes any skew contribution). + #[default] + Magnitude, + /// The isolated scale factors with rotation and skew stripped away (can be negative for flipped axes). + Pure, +} + pub trait Transform { fn transform(&self) -> DAffine2; @@ -9,17 +23,68 @@ pub trait Transform { pivot } - fn decompose_scale(&self) -> DVec2 { - DVec2::new(self.transform().transform_vector2(DVec2::X).length(), self.transform().transform_vector2(DVec2::Y).length()) + /// Decomposes the full transform into `(rotation, signed_scale, skew)` using a TRS+Skew factorization. + /// + /// - `rotation`: angle in radians + /// - `signed_scale`: the algebraic scale factors (can be negative for reflections, excludes skew) + /// - `skew`: the horizontal shear coefficient (the raw matrix value, not an angle) + /// + /// The original transform can be reconstructed as: + /// ``` + /// DAffine2::from_scale_angle_translation(scale, rotation, translation) * DAffine2::from_cols_array(&[1., 0., skew, 1., 0., 0.]) + /// ``` + #[inline(always)] + fn decompose_rotation_scale_skew(&self) -> (f64, DVec2, f64) { + let t = self.transform(); + let x_axis = t.matrix2.x_axis; + let y_axis = t.matrix2.y_axis; + + let angle = x_axis.y.atan2(x_axis.x); + let (sin, cos) = angle.sin_cos(); + + let scale_x = if cos.abs() > 1e-10 { x_axis.x / cos } else { x_axis.y / sin }; + + let mut skew = (sin * y_axis.y + cos * y_axis.x) / (sin * sin * scale_x + cos * cos * scale_x); + if !skew.is_finite() { + skew = 0.; + } + + let scale_y = if cos.abs() > 1e-10 { + (y_axis.y - scale_x * sin * skew) / cos + } else { + (scale_x * cos * skew - y_axis.x) / sin + }; + + (angle, DVec2::new(scale_x, scale_y), skew) } - /// Requires that the transform does not contain any skew. + /// Extracts the rotation angle (in radians) from the transform. + /// This is the angle of the x-axis and is correct regardless of skew, negative scale, or non-uniform scale. fn decompose_rotation(&self) -> f64 { - let rotation_matrix = (self.transform() * DAffine2::from_scale(self.decompose_scale().recip())).matrix2; - let rotation = -rotation_matrix.mul_vec2(DVec2::X).angle_to(DVec2::X); + let x_axis = self.transform().matrix2.x_axis; + let rotation = x_axis.y.atan2(x_axis.x); if rotation == -0. { 0. } else { rotation } } + /// Returns the signed scale components from the TRS+Skew decomposition. + /// Unlike [`Self::scale_magnitudes`] which returns positive axis-length magnitudes, + /// this returns the algebraic scale factors which can be negative for reflections and exclude skew. + fn decompose_scale(&self) -> DVec2 { + self.decompose_rotation_scale_skew().1 + } + + /// Returns the unsigned scale as the lengths of each axis (always positive, includes skew contribution). + /// Use this for magnitude-based queries like stroke width scaling, zoom level, or bounding box inflation. + fn scale_magnitudes(&self) -> DVec2 { + DVec2::new(self.transform().transform_vector2(DVec2::X).length(), self.transform().transform_vector2(DVec2::Y).length()) + } + + /// Returns the horizontal skew (shear) coefficient from the TRS+Skew decomposition. + /// This is the raw matrix coefficient. To convert to degrees: `skew.atan().to_degrees()`. + fn decompose_skew(&self) -> f64 { + self.decompose_rotation_scale_skew().2 + } + /// Detects if the transform contains skew by checking if the transformation matrix /// deviates from a pure rotation + uniform scale + translation. /// @@ -135,7 +200,7 @@ impl Footprint { } pub fn scale(&self) -> DVec2 { - self.transform.decompose_scale() + self.transform.scale_magnitudes() } pub fn offset(&self) -> DVec2 { diff --git a/node-graph/libraries/vector-types/src/vector/click_target.rs b/node-graph/libraries/vector-types/src/vector/click_target.rs index 9bc1a7c50f..ce9eeb16f4 100644 --- a/node-graph/libraries/vector-types/src/vector/click_target.rs +++ b/node-graph/libraries/vector-types/src/vector/click_target.rs @@ -179,7 +179,7 @@ impl ClickTarget { // Decompose transform into rotation, scale, translation for caching strategy let rotation = transform.decompose_rotation(); - let scale = transform.decompose_scale(); + let scale = transform.scale_magnitudes(); let translation = transform.translation; // Generate fingerprint for cache lookup diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index 8a9298e910..0828c4e6f2 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -4,8 +4,10 @@ pub use crate::gradient::*; use core_types::Color; use core_types::color::Alpha; use core_types::table::Table; +use core_types::transform::Transform; use dyn_any::DynAny; use glam::DAffine2; +use std::f64::consts::{PI, TAU}; /// Describes the fill of a layer. /// @@ -364,10 +366,30 @@ impl Stroke { join: if time < 0.5 { self.join } else { other.join }, join_miter_limit: self.join_miter_limit + (other.join_miter_limit - self.join_miter_limit) * time, align: if time < 0.5 { self.align } else { other.align }, - transform: DAffine2::from_mat2_translation( - time * self.transform.matrix2 + (1. - time) * other.transform.matrix2, - self.transform.translation * time + other.transform.translation * (1. - time), - ), + transform: { + // Decompose into scale/rotation/skew and interpolate each component separately. + // We do this instead of linear matrix interpolation because that passes through a zero matrix + // (and thus a division by 0 when rendering) when transforms have opposing rotations (e.g. 0° vs 180°). + + let (s_angle, s_scale, s_skew) = self.transform.decompose_rotation_scale_skew(); + let (t_angle, t_scale, t_skew) = other.transform.decompose_rotation_scale_skew(); + + let lerp = |a: f64, b: f64| a + (b - a) * time; + let lerped_translation = self.transform.translation * (1. - time) + other.transform.translation * time; + + // Shortest-arc rotation interpolation + let mut rotation_diff = t_angle - s_angle; + if rotation_diff > PI { + rotation_diff -= TAU; + } else if rotation_diff < -PI { + rotation_diff += TAU; + } + let lerped_angle = s_angle + rotation_diff * time; + + let trs = DAffine2::from_scale_angle_translation(s_scale.lerp(t_scale, time), lerped_angle, lerped_translation); + let skew = DAffine2::from_cols_array(&[1., 0., lerp(s_skew, t_skew), 1., 0., 0.]); + trs * skew + }, paint_order: if time < 0.5 { self.paint_order } else { other.paint_order }, } } diff --git a/node-graph/libraries/vector-types/src/vector/vector_types.rs b/node-graph/libraries/vector-types/src/vector/vector_types.rs index e504bb7199..e9b8bbb670 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_types.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_types.rs @@ -483,7 +483,7 @@ impl BoundingBox for Vector { // Include stroke by adding offset based on stroke width let stroke_width = self.style.stroke().map(|s| s.weight()).unwrap_or_default(); let miter_limit = self.style.stroke().map(|s| s.join_miter_limit).unwrap_or(1.); - let scale = transform.decompose_scale(); + let scale = transform.scale_magnitudes(); // Use the full line width to account for different styles of stroke caps let offset = DVec2::splat(stroke_width * scale.x.max(scale.y) * miter_limit); diff --git a/node-graph/nodes/gstd/src/pixel_preview.rs b/node-graph/nodes/gstd/src/pixel_preview.rs index 5628477586..5a56417ec9 100644 --- a/node-graph/nodes/gstd/src/pixel_preview.rs +++ b/node-graph/nodes/gstd/src/pixel_preview.rs @@ -22,7 +22,7 @@ pub async fn pixel_preview<'a: 'n>( let physical_scale = render_params.scale; let footprint = *ctx.footprint(); - let viewport_zoom = footprint.decompose_scale().x * physical_scale; + let viewport_zoom = footprint.scale_magnitudes().x * physical_scale; if render_params.render_mode != RenderMode::PixelPreview || !matches!(render_params.render_output_type, RenderOutputTypeRequest::Vello) || viewport_zoom <= 1. { let context = OwnedContextImpl::from(ctx).into_context(); diff --git a/node-graph/nodes/gstd/src/render_cache.rs b/node-graph/nodes/gstd/src/render_cache.rs index 1244c8a8be..72ba23ca83 100644 --- a/node-graph/nodes/gstd/src/render_cache.rs +++ b/node-graph/nodes/gstd/src/render_cache.rs @@ -393,7 +393,7 @@ pub async fn render_output_cache<'a: 'n>( } let device_scale = render_params.scale; - let zoom = footprint.decompose_scale().x; + let zoom = footprint.scale_magnitudes().x; let rotation = footprint.decompose_rotation(); let viewport_origin_offset = footprint.transform.translation; diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index 4575cf2f04..d60eb10bcf 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -108,7 +108,7 @@ async fn create_context<'a: 'n>( render_output_type, footprint: Footprint::default(), scale: render_config.scale, - viewport_zoom: footprint.decompose_scale().x, + viewport_zoom: footprint.scale_magnitudes().x, ..Default::default() }; diff --git a/node-graph/nodes/raster/src/std_nodes.rs b/node-graph/nodes/raster/src/std_nodes.rs index 8b8f9e1767..ebba6268de 100644 --- a/node-graph/nodes/raster/src/std_nodes.rs +++ b/node-graph/nodes/raster/src/std_nodes.rs @@ -203,7 +203,7 @@ pub fn mask( .into_iter() .filter_map(|mut row| { let image_size = DVec2::new(row.element.width as f64, row.element.height as f64); - let mask_size = stencil.transform.decompose_scale(); + let mask_size = stencil.transform.scale_magnitudes(); if mask_size == DVec2::ZERO { return None; diff --git a/node-graph/nodes/transform/src/transform_nodes.rs b/node-graph/nodes/transform/src/transform_nodes.rs index 9cfeab5e16..db49aea2d4 100644 --- a/node-graph/nodes/transform/src/transform_nodes.rs +++ b/node-graph/nodes/transform/src/transform_nodes.rs @@ -1,7 +1,7 @@ use core::f64; use core_types::color::Color; use core_types::table::Table; -use core_types::transform::{ApplyTransform, Transform}; +use core_types::transform::{ApplyTransform, ScaleType, Transform}; use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, InjectFootprint, ModifyFootprint, OwnedContextImpl}; use glam::{DAffine2, DMat2, DVec2}; use graphic_types::Graphic; @@ -77,7 +77,7 @@ fn reset_transform( row.transform.matrix2 = DMat2::IDENTITY; } (true, false) => { - let scale = row.transform.decompose_scale(); + let scale = row.transform.scale_magnitudes(); row.transform.matrix2 = DMat2::from_diagonal(scale); } (false, true) => { @@ -143,15 +143,24 @@ fn decompose_translation(_: impl Ctx, transform: DAffine2) -> DVec2 { } /// Extracts the rotation component (in degrees) from the input transform. -/// This, together with the "Decompose Scale" node, also may jointly represent any shear component in the original transform. #[node_macro::node(category("Math: Transform"))] fn decompose_rotation(_: impl Ctx, transform: DAffine2) -> f64 { transform.decompose_rotation().to_degrees() } /// Extracts the scale component from the input transform. -/// This, together with the "Decompose Rotation" node, also may jointly represent any shear component in the original transform. +/// **Magnitude** returns the visual length of each axis (always positive, includes any skew contribution). +/// **Pure** returns the isolated scale factors with rotation and skew stripped away (can be negative for flipped axes). #[node_macro::node(category("Math: Transform"))] -fn decompose_scale(_: impl Ctx, transform: DAffine2) -> DVec2 { - transform.decompose_scale() +fn decompose_scale(_: impl Ctx, transform: DAffine2, scale_type: ScaleType) -> DVec2 { + match scale_type { + ScaleType::Magnitude => transform.scale_magnitudes(), + ScaleType::Pure => transform.decompose_scale(), + } +} + +/// Extracts the skew coefficient (in degrees) from the input transform. +#[node_macro::node(category("Math: Transform"))] +fn decompose_skew(_: impl Ctx, transform: DAffine2) -> f64 { + transform.decompose_skew().atan().to_degrees() } diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 8c37b10c7a..24a4c0f7e4 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -2534,7 +2534,7 @@ async fn area(ctx: impl Ctx + CloneVarArgs + ExtractAll, content: impl Node() }) .sum() From 3f2e67d6bebf0f65a69e85e0260cadbb60683b76 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 28 Mar 2026 20:10:51 -0700 Subject: [PATCH 2/5] Fix bug in master with skew changing Area node calculated value --- node-graph/nodes/vector/src/vector_nodes.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 24a4c0f7e4..f33d1a17fc 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -4,7 +4,7 @@ use core::hash::{Hash, Hasher}; use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLength, Progression, SeedValue}; use core_types::table::{Table, TableRow, TableRowMut}; -use core_types::transform::{Footprint, Transform}; +use core_types::transform::Footprint; use core_types::{CloneVarArgs, Color, Context, Ctx, ExtractAll, OwnedContextImpl}; use glam::{DAffine2, DVec2}; use graphic_types::Vector; @@ -2534,8 +2534,8 @@ async fn area(ctx: impl Ctx + CloneVarArgs + ExtractAll, content: impl Node() + let area_scale = row.transform.matrix2.determinant().abs(); + row.element.stroke_bezpath_iter().map(|subpath| subpath.area() * area_scale).sum::() }) .sum() } From 131ff8ce8bc24272ab43342b7554dee06c94f94b Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 28 Mar 2026 20:11:04 -0700 Subject: [PATCH 3/5] Code review simplification --- node-graph/libraries/core-types/src/transform.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node-graph/libraries/core-types/src/transform.rs b/node-graph/libraries/core-types/src/transform.rs index 4b1da0276e..f92965ab97 100644 --- a/node-graph/libraries/core-types/src/transform.rs +++ b/node-graph/libraries/core-types/src/transform.rs @@ -44,7 +44,7 @@ pub trait Transform { let scale_x = if cos.abs() > 1e-10 { x_axis.x / cos } else { x_axis.y / sin }; - let mut skew = (sin * y_axis.y + cos * y_axis.x) / (sin * sin * scale_x + cos * cos * scale_x); + let mut skew = (sin * y_axis.y + cos * y_axis.x) / scale_x; if !skew.is_finite() { skew = 0.; } From 55626a2929d239ee5bc585ca0c0c8bd74e84f859 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 28 Mar 2026 20:27:45 -0700 Subject: [PATCH 4/5] More code review fixes --- .../portfolio/document/node_graph/node_properties.rs | 10 +++++----- node-graph/nodes/transform/src/transform_nodes.rs | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 36c3a71c71..8f428a7286 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -567,8 +567,8 @@ pub fn transform_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widg let widgets = if let Some(&TaggedValue::DAffine2(transform)) = input.as_non_exposed_value() { let translation = transform.translation; - let rotation = transform.decompose_rotation(); - let scale = transform.scale_magnitudes(); + let (rotation, scale, skew) = transform.decompose_rotation_scale_skew(); + let skew_matrix = DAffine2::from_cols_array(&[1., 0., skew, 1., 0., 0.]); location_widgets.extend_from_slice(&[ NumberInput::new(Some(translation.x)) @@ -609,7 +609,7 @@ pub fn transform_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widg .range_max(Some(180.)) .on_update(update_value( move |r: &NumberInput| { - let transform = DAffine2::from_scale_angle_translation(scale, r.value.map(|r| r.to_radians()).unwrap_or(rotation), translation); + let transform = DAffine2::from_scale_angle_translation(scale, r.value.map(|r| r.to_radians()).unwrap_or(rotation), translation) * skew_matrix; TaggedValue::DAffine2(transform) }, node_id, @@ -624,7 +624,7 @@ pub fn transform_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widg .unit("x") .on_update(update_value( move |w: &NumberInput| { - let transform = DAffine2::from_scale_angle_translation(DVec2::new(w.value.unwrap_or(scale.x), scale.y), rotation, translation); + let transform = DAffine2::from_scale_angle_translation(DVec2::new(w.value.unwrap_or(scale.x), scale.y), rotation, translation) * skew_matrix; TaggedValue::DAffine2(transform) }, node_id, @@ -638,7 +638,7 @@ pub fn transform_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widg .unit("x") .on_update(update_value( move |h: &NumberInput| { - let transform = DAffine2::from_scale_angle_translation(DVec2::new(scale.x, h.value.unwrap_or(scale.y)), rotation, translation); + let transform = DAffine2::from_scale_angle_translation(DVec2::new(scale.x, h.value.unwrap_or(scale.y)), rotation, translation) * skew_matrix; TaggedValue::DAffine2(transform) }, node_id, diff --git a/node-graph/nodes/transform/src/transform_nodes.rs b/node-graph/nodes/transform/src/transform_nodes.rs index db49aea2d4..93de7153fb 100644 --- a/node-graph/nodes/transform/src/transform_nodes.rs +++ b/node-graph/nodes/transform/src/transform_nodes.rs @@ -159,7 +159,7 @@ fn decompose_scale(_: impl Ctx, transform: DAffine2, scale_type: ScaleType) -> D } } -/// Extracts the skew coefficient (in degrees) from the input transform. +/// Extracts the skew angle (in degrees) from the input transform. #[node_macro::node(category("Math: Transform"))] fn decompose_skew(_: impl Ctx, transform: DAffine2) -> f64 { transform.decompose_skew().atan().to_degrees() From 2b23e19ca544c27cac55cb29f9278826503f5ff9 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 28 Mar 2026 20:45:27 -0700 Subject: [PATCH 5/5] Rename cases where "shear" terminology was used in place of "skew" --- .../graph_operation/transform_utils.rs | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/editor/src/messages/portfolio/document/graph_operation/transform_utils.rs b/editor/src/messages/portfolio/document/graph_operation/transform_utils.rs index 8d5bcabfd7..e132402e71 100644 --- a/editor/src/messages/portfolio/document/graph_operation/transform_utils.rs +++ b/editor/src/messages/portfolio/document/graph_operation/transform_utils.rs @@ -12,12 +12,12 @@ pub fn update_transform(network_interface: &mut NodeNetworkInterface, node_id: & let translation = transform.translation; let rotation = rotation.to_degrees(); - let shear = DVec2::new(skew.atan().to_degrees(), 0.); + let skew = DVec2::new(skew.atan().to_degrees(), 0.); network_interface.set_input(&InputConnector::node(*node_id, 1), NodeInput::value(TaggedValue::DVec2(translation), false), &[]); network_interface.set_input(&InputConnector::node(*node_id, 2), NodeInput::value(TaggedValue::F64(rotation), false), &[]); network_interface.set_input(&InputConnector::node(*node_id, 3), NodeInput::value(TaggedValue::DVec2(scale), false), &[]); - network_interface.set_input(&InputConnector::node(*node_id, 4), NodeInput::value(TaggedValue::DVec2(shear), false), &[]); + network_interface.set_input(&InputConnector::node(*node_id, 4), NodeInput::value(TaggedValue::DVec2(skew), false), &[]); } // TODO: This should be extracted from the graph at the location of the transform node. @@ -58,12 +58,12 @@ pub fn get_current_transform(inputs: &[NodeInput]) -> DAffine2 { }; let rotation = if let Some(&TaggedValue::F64(rotation)) = inputs[2].as_value() { rotation } else { 0. }; let scale = if let Some(&TaggedValue::DVec2(scale)) = inputs[3].as_value() { scale } else { DVec2::ONE }; - let shear = if let Some(&TaggedValue::DVec2(shear)) = inputs[4].as_value() { shear } else { DVec2::ZERO }; + let skew = if let Some(&TaggedValue::DVec2(skew)) = inputs[4].as_value() { skew } else { DVec2::ZERO }; let rotation = rotation.to_radians(); - let shear = DVec2::new(shear.x.to_radians().tan(), shear.y.to_radians().tan()); + let skew = DVec2::new(skew.x.to_radians().tan(), skew.y.to_radians().tan()); - DAffine2::from_scale_angle_translation(scale, rotation, translation) * DAffine2::from_cols_array(&[1., shear.y, shear.x, 1., 0., 0.]) + DAffine2::from_scale_angle_translation(scale, rotation, translation) * DAffine2::from_cols_array(&[1., skew.y, skew.x, 1., 0., 0.]) } /// Extract the current normalized pivot from the layer @@ -112,8 +112,8 @@ mod tests { /// ``` #[test] fn derive_transform() { - for shear_x in -10..=10 { - let shear_x = (shear_x as f64) / 2.; + for skew_x in -10..=10 { + let skew_x = (skew_x as f64) / 2.; for angle in (0..=360).step_by(15) { let angle = (angle as f64).to_radians(); for scale_x in 1..10 { @@ -121,13 +121,13 @@ mod tests { for scale_y in 1..10 { let scale_y = (scale_y as f64) / 5.; - let shear = DVec2::new(shear_x, 0.); + let skew = DVec2::new(skew_x, 0.); let scale = DVec2::new(scale_x, scale_y); let translate = DVec2::new(5666., 644.); let original_transform = DAffine2::from_cols( - DVec2::new(scale.x * angle.cos() - scale.y * angle.sin() * shear.y, scale.x * angle.sin() + scale.y * angle.cos() * shear.y), - DVec2::new(scale.x * angle.cos() * shear.x - scale.y * angle.sin(), scale.x * angle.sin() * shear.x + scale.y * angle.cos()), + DVec2::new(scale.x * angle.cos() - scale.y * angle.sin() * skew.y, scale.x * angle.sin() + scale.y * angle.cos() * skew.y), + DVec2::new(scale.x * angle.cos() * skew.x - scale.y * angle.sin(), scale.x * angle.sin() * skew.x + scale.y * angle.cos()), translate, ); @@ -137,7 +137,7 @@ mod tests { assert!( new_transform.abs_diff_eq(original_transform, 1e-10), - "original_transform {original_transform} new_transform {new_transform} / scale {scale} new_scale {new_scale} / angle {angle} new_angle {new_angle} / shear {shear} / new_shear {new_shear}", + "original_transform {original_transform} new_transform {new_transform} / scale {scale} new_scale {new_scale} / angle {angle} new_angle {new_angle} / skew {skew} / new_skew {new_skew}", ); } }