Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,21 @@ use glam::{DAffine2, DVec2};
use graph_craft::document::value::TaggedValue;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: PR title enforcement

PR title must use the required new-node format. The rules specify "New node: 'Node Name'" for PRs that add a node, but the current title is a generic sentence instead of the mandated format.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At editor/src/messages/portfolio/document/graph_operation/transform_utils.rs, line 15:

<comment>PR title must use the required new-node format. The rules specify "New node: 'Node Name'" for PRs that add a node, but the current title is a generic sentence instead of the mandated format.</comment>

<file context>
@@ -12,12 +12,12 @@ pub fn update_transform(network_interface: &mut NodeNetworkInterface, node_id: &
 
 	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), &[]);
</file context>

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 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.
Expand Down Expand Up @@ -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 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
Expand Down Expand Up @@ -135,31 +112,32 @@ 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 {
let scale_x = (scale_x as f64) / 5.;
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,
);

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),
"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}",
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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::<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::<ScaleType>() => enum_choice::<ScaleType>().for_socket(default_info).property_row(),
// =====
// OTHER
// =====
Expand Down Expand Up @@ -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 translation = transform.translation;
let rotation = transform.decompose_rotation();
let scale = transform.decompose_scale();
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))
Expand Down Expand Up @@ -608,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,
Expand All @@ -623,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,
Expand All @@ -637,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,
Expand Down
12 changes: 12 additions & 0 deletions editor/src/messages/portfolio/document_migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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
// ==================================
Expand Down
1 change: 1 addition & 0 deletions node-graph/graph-craft/src/document/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions node-graph/interpreted-executor/src/node_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::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]),
Expand Down Expand Up @@ -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::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]),
];
// =============
Expand Down
77 changes: 71 additions & 6 deletions node-graph/libraries/core-types/src/transform.rs
Original file line number Diff line number Diff line change
@@ -1,25 +1,90 @@
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;

fn local_pivot(&self, pivot: DVec2) -> DVec2 {
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) / 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.
///
Expand Down Expand Up @@ -135,7 +200,7 @@ impl Footprint {
}

pub fn scale(&self) -> DVec2 {
self.transform.decompose_scale()
self.transform.scale_magnitudes()
}

pub fn offset(&self) -> DVec2 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 26 additions & 4 deletions node-graph/libraries/vector-types/src/vector/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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 },
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion node-graph/nodes/gstd/src/pixel_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion node-graph/nodes/gstd/src/render_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading