mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 22:28:10 +08:00
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:
@@ -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,15 +23,66 @@ pub trait Transform {
|
||||
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 {
|
||||
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())
|
||||
}
|
||||
|
||||
/// Requires that the transform does not contain any skew.
|
||||
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);
|
||||
if rotation == -0. { 0. } else { rotation }
|
||||
/// 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
|
||||
@@ -135,7 +200,7 @@ impl Footprint {
|
||||
}
|
||||
|
||||
pub fn scale(&self) -> DVec2 {
|
||||
self.transform.decompose_scale()
|
||||
self.transform.scale_magnitudes()
|
||||
}
|
||||
|
||||
pub fn offset(&self) -> DVec2 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,7 +483,7 @@ impl<Upstream> BoundingBox for Vector<Upstream> {
|
||||
// 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);
|
||||
|
||||
Reference in New Issue
Block a user