Refactor transform decomposition API with skew support, add 'Decompose Skew' node, and fix stroke transform interpolation (#3973)

* Refactor transform decomposition API with skew support, add Decompose Skew node, and fix stroke transform interpolation

* Fix bug in master with skew changing Area node calculated value

* Code review simplification

* More code review fixes

* Rename cases where "shear" terminology was used in place of "skew"
This commit is contained in:
Keavon Chambers 2026-03-28 20:47:32 -07:00 committed by GitHub
parent e2a142333f
commit a3ea6ab0af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 160 additions and 70 deletions

View File

@ -3,44 +3,21 @@ use glam::{DAffine2, DVec2};
use graph_craft::document::value::TaggedValue; use graph_craft::document::value::TaggedValue;
use graph_craft::document::{NodeId, NodeInput}; use graph_craft::document::{NodeId, NodeInput};
use graphene_std::subpath::Subpath; use graphene_std::subpath::Subpath;
use graphene_std::transform::Transform;
use graphene_std::vector::PointId; 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 /// 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) { 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 rotation = rotation.to_degrees();
let shear = DVec2::new(shear.x.atan().to_degrees(), shear.y.atan().to_degrees()); 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, 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, 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, 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. // TODO: This should be extracted from the graph at the location of the transform node.
@ -81,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 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 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 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 /// Extract the current normalized pivot from the layer
@ -135,8 +112,8 @@ mod tests {
/// ``` /// ```
#[test] #[test]
fn derive_transform() { fn derive_transform() {
for shear_x in -10..=10 { for skew_x in -10..=10 {
let shear_x = (shear_x as f64) / 2.; let skew_x = (skew_x as f64) / 2.;
for angle in (0..=360).step_by(15) { for angle in (0..=360).step_by(15) {
let angle = (angle as f64).to_radians(); let angle = (angle as f64).to_radians();
for scale_x in 1..10 { for scale_x in 1..10 {
@ -144,22 +121,23 @@ mod tests {
for scale_y in 1..10 { for scale_y in 1..10 {
let scale_y = (scale_y as f64) / 5.; 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 scale = DVec2::new(scale_x, scale_y);
let translate = DVec2::new(5666., 644.); let translate = DVec2::new(5666., 644.);
let original_transform = DAffine2::from_cols( 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() - scale.y * angle.sin() * skew.y, scale.x * angle.sin() + scale.y * angle.cos() * skew.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() * skew.x - scale.y * angle.sin(), scale.x * angle.sin() * skew.x + scale.y * angle.cos()),
translate, translate,
); );
let (new_scale, new_angle, new_translation, new_shear) = compute_scale_angle_translation_shear(original_transform); let (new_angle, new_scale, new_skew) = original_transform.decompose_rotation_scale_skew();
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_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!( assert!(
new_transform.abs_diff_eq(original_transform, 1e-10), 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}",
); );
} }
} }

View File

@ -23,7 +23,7 @@ use graphene_std::raster::{
}; };
use graphene_std::table::{Table, TableRow}; use graphene_std::table::{Table, TableRow};
use graphene_std::text::{Font, TextAlign}; 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::QRCodeErrorCorrectionLevel;
use graphene_std::vector::misc::BooleanOperation; use graphene_std::vector::misc::BooleanOperation;
use graphene_std::vector::misc::{ArcType, CentroidType, ExtrudeJoiningAlgorithm, GridType, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType}; 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::<CentroidType>() => enum_choice::<CentroidType>().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::<CentroidType>() => enum_choice::<CentroidType>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<LuminanceCalculation>() => enum_choice::<LuminanceCalculation>().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::<LuminanceCalculation>() => enum_choice::<LuminanceCalculation>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<QRCodeErrorCorrectionLevel>() => enum_choice::<QRCodeErrorCorrectionLevel>().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::<QRCodeErrorCorrectionLevel>() => enum_choice::<QRCodeErrorCorrectionLevel>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<ScaleType>() => enum_choice::<ScaleType>().for_socket(default_info).property_row(),
// ===== // =====
// OTHER // OTHER
// ===== // =====
@ -566,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 widgets = if let Some(&TaggedValue::DAffine2(transform)) = input.as_non_exposed_value() {
let translation = transform.translation; let translation = transform.translation;
let rotation = transform.decompose_rotation(); let (rotation, scale, skew) = transform.decompose_rotation_scale_skew();
let scale = transform.decompose_scale(); let skew_matrix = DAffine2::from_cols_array(&[1., 0., skew, 1., 0., 0.]);
location_widgets.extend_from_slice(&[ location_widgets.extend_from_slice(&[
NumberInput::new(Some(translation.x)) NumberInput::new(Some(translation.x))
@ -608,7 +609,7 @@ pub fn transform_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widg
.range_max(Some(180.)) .range_max(Some(180.))
.on_update(update_value( .on_update(update_value(
move |r: &NumberInput| { 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) TaggedValue::DAffine2(transform)
}, },
node_id, node_id,
@ -623,7 +624,7 @@ pub fn transform_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widg
.unit("x") .unit("x")
.on_update(update_value( .on_update(update_value(
move |w: &NumberInput| { 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) TaggedValue::DAffine2(transform)
}, },
node_id, node_id,
@ -637,7 +638,7 @@ pub fn transform_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widg
.unit("x") .unit("x")
.on_update(update_value( .on_update(update_value(
move |h: &NumberInput| { 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) TaggedValue::DAffine2(transform)
}, },
node_id, node_id,

View File

@ -12,6 +12,7 @@ use graphene_std::ProtoNodeIdentifier;
use graphene_std::subpath::Subpath; use graphene_std::subpath::Subpath;
use graphene_std::table::Table; use graphene_std::table::Table;
use graphene_std::text::{TextAlign, TypesettingConfig}; use graphene_std::text::{TextAlign, TypesettingConfig};
use graphene_std::transform::ScaleType;
use graphene_std::uuid::NodeId; use graphene_std::uuid::NodeId;
use graphene_std::vector::Vector; use graphene_std::vector::Vector;
use graphene_std::vector::style::{PaintOrder, StrokeAlign}; 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); 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 // PUT ALL MIGRATIONS ABOVE THIS LINE
// ================================== // ==================================

View File

@ -272,6 +272,7 @@ tagged_value! {
CentroidType(vector::misc::CentroidType), CentroidType(vector::misc::CentroidType),
BooleanOperation(vector::misc::BooleanOperation), BooleanOperation(vector::misc::BooleanOperation),
TextAlign(text_nodes::TextAlign), TextAlign(text_nodes::TextAlign),
ScaleType(core_types::transform::ScaleType),
} }
impl TaggedValue { impl TaggedValue {

View File

@ -134,6 +134,7 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::transform::ReferencePoint]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, 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::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::text::TextAlign]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::transform::ScaleType]),
// Context nullification // Context nullification
#[cfg(feature = "gpu")] #[cfg(feature = "gpu")]
async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => &WasmEditorApi, Context => graphene_std::ContextFeatures]), 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<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
async_node!(graphene_core::memo::MemoNode<_, _>, 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::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::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::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]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => RenderIntermediate]),
]; ];
// ============= // =============

View File

@ -1,7 +1,21 @@
use crate::math::bbox::AxisAlignedBbox; use crate::math::bbox::AxisAlignedBbox;
use core::f64; use core::f64;
use dyn_any::DynAny;
use glam::{DAffine2, DMat2, DVec2, UVec2}; 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 { pub trait Transform {
fn transform(&self) -> DAffine2; fn transform(&self) -> DAffine2;
@ -9,15 +23,66 @@ pub trait Transform {
pivot pivot
} }
/// 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) / 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)
}
/// 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 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 { 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()) DVec2::new(self.transform().transform_vector2(DVec2::X).length(), self.transform().transform_vector2(DVec2::Y).length())
} }
/// Requires that the transform does not contain any skew. /// Returns the horizontal skew (shear) coefficient from the TRS+Skew decomposition.
fn decompose_rotation(&self) -> f64 { /// This is the raw matrix coefficient. To convert to degrees: `skew.atan().to_degrees()`.
let rotation_matrix = (self.transform() * DAffine2::from_scale(self.decompose_scale().recip())).matrix2; fn decompose_skew(&self) -> f64 {
let rotation = -rotation_matrix.mul_vec2(DVec2::X).angle_to(DVec2::X); self.decompose_rotation_scale_skew().2
if rotation == -0. { 0. } else { rotation }
} }
/// Detects if the transform contains skew by checking if the transformation matrix /// Detects if the transform contains skew by checking if the transformation matrix
@ -135,7 +200,7 @@ impl Footprint {
} }
pub fn scale(&self) -> DVec2 { pub fn scale(&self) -> DVec2 {
self.transform.decompose_scale() self.transform.scale_magnitudes()
} }
pub fn offset(&self) -> DVec2 { pub fn offset(&self) -> DVec2 {

View File

@ -179,7 +179,7 @@ impl ClickTarget {
// Decompose transform into rotation, scale, translation for caching strategy // Decompose transform into rotation, scale, translation for caching strategy
let rotation = transform.decompose_rotation(); let rotation = transform.decompose_rotation();
let scale = transform.decompose_scale(); let scale = transform.scale_magnitudes();
let translation = transform.translation; let translation = transform.translation;
// Generate fingerprint for cache lookup // Generate fingerprint for cache lookup

View File

@ -4,8 +4,10 @@ pub use crate::gradient::*;
use core_types::Color; use core_types::Color;
use core_types::color::Alpha; use core_types::color::Alpha;
use core_types::table::Table; use core_types::table::Table;
use core_types::transform::Transform;
use dyn_any::DynAny; use dyn_any::DynAny;
use glam::DAffine2; use glam::DAffine2;
use std::f64::consts::{PI, TAU};
/// Describes the fill of a layer. /// Describes the fill of a layer.
/// ///
@ -364,10 +366,30 @@ impl Stroke {
join: if time < 0.5 { self.join } else { other.join }, 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, 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 }, align: if time < 0.5 { self.align } else { other.align },
transform: DAffine2::from_mat2_translation( transform: {
time * self.transform.matrix2 + (1. - time) * other.transform.matrix2, // Decompose into scale/rotation/skew and interpolate each component separately.
self.transform.translation * time + other.transform.translation * (1. - time), // 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 }, paint_order: if time < 0.5 { self.paint_order } else { other.paint_order },
} }
} }

View File

@ -483,7 +483,7 @@ impl<Upstream> BoundingBox for Vector<Upstream> {
// Include stroke by adding offset based on stroke width // Include stroke by adding offset based on stroke width
let stroke_width = self.style.stroke().map(|s| s.weight()).unwrap_or_default(); 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 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 // 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); let offset = DVec2::splat(stroke_width * scale.x.max(scale.y) * miter_limit);

View File

@ -22,7 +22,7 @@ pub async fn pixel_preview<'a: 'n>(
let physical_scale = render_params.scale; let physical_scale = render_params.scale;
let footprint = *ctx.footprint(); 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. { 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(); let context = OwnedContextImpl::from(ctx).into_context();

View File

@ -393,7 +393,7 @@ pub async fn render_output_cache<'a: 'n>(
} }
let device_scale = render_params.scale; let device_scale = render_params.scale;
let zoom = footprint.decompose_scale().x; let zoom = footprint.scale_magnitudes().x;
let rotation = footprint.decompose_rotation(); let rotation = footprint.decompose_rotation();
let viewport_origin_offset = footprint.transform.translation; let viewport_origin_offset = footprint.transform.translation;

View File

@ -108,7 +108,7 @@ async fn create_context<'a: 'n>(
render_output_type, render_output_type,
footprint: Footprint::default(), footprint: Footprint::default(),
scale: render_config.scale, scale: render_config.scale,
viewport_zoom: footprint.decompose_scale().x, viewport_zoom: footprint.scale_magnitudes().x,
..Default::default() ..Default::default()
}; };

View File

@ -203,7 +203,7 @@ pub fn mask(
.into_iter() .into_iter()
.filter_map(|mut row| { .filter_map(|mut row| {
let image_size = DVec2::new(row.element.width as f64, row.element.height as f64); 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 { if mask_size == DVec2::ZERO {
return None; return None;

View File

@ -1,7 +1,7 @@
use core::f64; use core::f64;
use core_types::color::Color; use core_types::color::Color;
use core_types::table::Table; 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 core_types::{CloneVarArgs, Context, Ctx, ExtractAll, InjectFootprint, ModifyFootprint, OwnedContextImpl};
use glam::{DAffine2, DMat2, DVec2}; use glam::{DAffine2, DMat2, DVec2};
use graphic_types::Graphic; use graphic_types::Graphic;
@ -77,7 +77,7 @@ fn reset_transform<T>(
row.transform.matrix2 = DMat2::IDENTITY; row.transform.matrix2 = DMat2::IDENTITY;
} }
(true, false) => { (true, false) => {
let scale = row.transform.decompose_scale(); let scale = row.transform.scale_magnitudes();
row.transform.matrix2 = DMat2::from_diagonal(scale); row.transform.matrix2 = DMat2::from_diagonal(scale);
} }
(false, true) => { (false, true) => {
@ -143,15 +143,24 @@ fn decompose_translation(_: impl Ctx, transform: DAffine2) -> DVec2 {
} }
/// Extracts the rotation component (in degrees) from the input transform. /// 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"))] #[node_macro::node(category("Math: Transform"))]
fn decompose_rotation(_: impl Ctx, transform: DAffine2) -> f64 { fn decompose_rotation(_: impl Ctx, transform: DAffine2) -> f64 {
transform.decompose_rotation().to_degrees() transform.decompose_rotation().to_degrees()
} }
/// Extracts the scale component from the input transform. /// 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"))] #[node_macro::node(category("Math: Transform"))]
fn decompose_scale(_: impl Ctx, transform: DAffine2) -> DVec2 { fn decompose_scale(_: impl Ctx, transform: DAffine2, scale_type: ScaleType) -> DVec2 {
transform.decompose_scale() match scale_type {
ScaleType::Magnitude => transform.scale_magnitudes(),
ScaleType::Pure => transform.decompose_scale(),
}
}
/// 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()
} }

View File

@ -4,7 +4,7 @@ use core::hash::{Hash, Hasher};
use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLength, Progression, SeedValue}; use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLength, Progression, SeedValue};
use core_types::table::{Table, TableRow, TableRowMut}; 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 core_types::{CloneVarArgs, Color, Context, Ctx, ExtractAll, OwnedContextImpl};
use glam::{DAffine2, DVec2}; use glam::{DAffine2, DVec2};
use graphic_types::Vector; use graphic_types::Vector;
@ -2534,8 +2534,8 @@ async fn area(ctx: impl Ctx + CloneVarArgs + ExtractAll, content: impl Node<Cont
vector vector
.iter() .iter()
.map(|row| { .map(|row| {
let scale = row.transform.decompose_scale(); let area_scale = row.transform.matrix2.determinant().abs();
row.element.stroke_bezpath_iter().map(|subpath| subpath.area() * scale.x * scale.y).sum::<f64>() row.element.stroke_bezpath_iter().map(|subpath| subpath.area() * area_scale).sum::<f64>()
}) })
.sum() .sum()
} }