Make the Transform node use angles in degrees instead of radians for Rotation and Skew (#3160)

* Make the Transform node use degrees not radians

* Migration script

* Migrate skew value input to store degrees

* Add comments

* Fix migrations to account for the old deprecated "Pivot" parameter

* Fix tooling interactions with degrees-based transforms

* Upgrade demo art

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
Adam Gerhant
2025-09-11 00:19:10 +00:00
committed by GitHub
co-authored by Keavon Chambers
parent c51967384f
commit 332088bce1
14 changed files with 143 additions and 38 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -32,10 +32,13 @@ pub fn compute_scale_angle_translation_shear(transform: DAffine2) -> (DVec2, f64
/// 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, angle, translation, shear) = compute_scale_angle_translation_shear(transform); let (scale, rotation, translation, shear) = compute_scale_angle_translation_shear(transform);
let rotation = rotation.to_degrees();
let shear = DVec2::new(shear.x.atan().to_degrees(), shear.y.atan().to_degrees());
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(angle), 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(shear), false), &[]);
} }
@@ -76,14 +79,14 @@ pub fn get_current_transform(inputs: &[NodeInput]) -> DAffine2 {
} else { } else {
DVec2::ZERO DVec2::ZERO
}; };
let rotation = if let Some(&TaggedValue::F64(rotation)) = inputs[2].as_value() { rotation } else { 0. };
let angle = if let Some(&TaggedValue::F64(angle)) = inputs[2].as_value() { angle } 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 shear = if let Some(&TaggedValue::DVec2(shear)) = inputs[4].as_value() { shear } else { DVec2::ZERO };
DAffine2::from_scale_angle_translation(scale, angle, translation) * DAffine2::from_cols_array(&[1., shear.y, shear.x, 1., 0., 0.]) let rotation = rotation.to_radians();
let shear = DVec2::new(shear.x.to_radians().tan(), shear.y.to_radians().tan());
DAffine2::from_scale_angle_translation(scale, rotation, translation) * DAffine2::from_cols_array(&[1., shear.y, shear.x, 1., 0., 0.])
} }
/// Extract the current normalized pivot from the layer /// Extract the current normalized pivot from the layer
@@ -1351,31 +1351,52 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
node_template: NodeTemplate { node_template: NodeTemplate {
document_node: DocumentNode { document_node: DocumentNode {
inputs: vec![ inputs: vec![
// Value
NodeInput::value(TaggedValue::DAffine2(DAffine2::default()), true), NodeInput::value(TaggedValue::DAffine2(DAffine2::default()), true),
// Translation
NodeInput::value(TaggedValue::DVec2(DVec2::ZERO), false), NodeInput::value(TaggedValue::DVec2(DVec2::ZERO), false),
// Rotation
NodeInput::value(TaggedValue::F64(0.), false), NodeInput::value(TaggedValue::F64(0.), false),
// Scale
NodeInput::value(TaggedValue::DVec2(DVec2::ONE), false), NodeInput::value(TaggedValue::DVec2(DVec2::ONE), false),
// Skew
NodeInput::value(TaggedValue::DVec2(DVec2::ZERO), false), NodeInput::value(TaggedValue::DVec2(DVec2::ZERO), false),
// Origin Offset
NodeInput::value(TaggedValue::DVec2(DVec2::ZERO), false),
// Scale Appearance
NodeInput::value(TaggedValue::Bool(true), false),
], ],
implementation: DocumentNodeImplementation::Network(NodeNetwork { implementation: DocumentNodeImplementation::Network(NodeNetwork {
exports: vec![NodeInput::node(NodeId(1), 0)], exports: vec![
// From the Transform node
NodeInput::node(NodeId(1), 0),
],
nodes: [ nodes: [
// Monitor node
DocumentNode { DocumentNode {
inputs: vec![NodeInput::network(generic!(T), 0)], inputs: vec![
// From the Value import
NodeInput::network(generic!(T), 0),
],
implementation: DocumentNodeImplementation::ProtoNode(memo::monitor::IDENTIFIER), implementation: DocumentNodeImplementation::ProtoNode(memo::monitor::IDENTIFIER),
call_argument: generic!(T), call_argument: generic!(T),
skip_deduplication: true, skip_deduplication: true,
..Default::default() ..Default::default()
}, },
// Transform node
DocumentNode { DocumentNode {
inputs: vec![ inputs: vec![
// From the Monitor node
NodeInput::node(NodeId(0), 0), NodeInput::node(NodeId(0), 0),
// From the Translation import
NodeInput::network(concrete!(DVec2), 1), NodeInput::network(concrete!(DVec2), 1),
// From the Rotation import
NodeInput::network(concrete!(f64), 2), NodeInput::network(concrete!(f64), 2),
// From the Scale import
NodeInput::network(concrete!(DVec2), 3), NodeInput::network(concrete!(DVec2), 3),
// From the Skew import
NodeInput::network(concrete!(DVec2), 4), NodeInput::network(concrete!(DVec2), 4),
], ],
call_argument: concrete!(Context),
implementation: DocumentNodeImplementation::ProtoNode(transform_nodes::transform::IDENTIFIER), implementation: DocumentNodeImplementation::ProtoNode(transform_nodes::transform::IDENTIFIER),
..Default::default() ..Default::default()
}, },
@@ -1441,6 +1462,8 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
}), }),
), ),
InputMetadata::with_name_description_override("Skew", "TODO", WidgetOverride::Custom("transform_skew".to_string())), InputMetadata::with_name_description_override("Skew", "TODO", WidgetOverride::Custom("transform_skew".to_string())),
InputMetadata::with_name_description_override("Origin Offset", "TODO", WidgetOverride::Custom("hidden".to_string())),
InputMetadata::with_name_description_override("Scale Appearance", "TODO", WidgetOverride::Custom("hidden".to_string())),
], ],
output_names: vec!["Data".to_string()], output_names: vec!["Data".to_string()],
..Default::default() ..Default::default()
@@ -2190,13 +2213,13 @@ fn static_input_properties() -> InputProperties {
if let Some(&TaggedValue::F64(val)) = input.as_non_exposed_value() { if let Some(&TaggedValue::F64(val)) = input.as_non_exposed_value() {
widgets.extend_from_slice(&[ widgets.extend_from_slice(&[
Separator::new(SeparatorType::Unrelated).widget_holder(), Separator::new(SeparatorType::Unrelated).widget_holder(),
NumberInput::new(Some(val.to_degrees())) NumberInput::new(Some(val))
.unit("°") .unit("°")
.mode(NumberInputMode::Range) .mode(NumberInputMode::Range)
.range_min(Some(-180.)) .range_min(Some(-180.))
.range_max(Some(180.)) .range_max(Some(180.))
.on_update(node_properties::update_value( .on_update(node_properties::update_value(
|number_input: &NumberInput| TaggedValue::F64(number_input.value.unwrap().to_radians()), |number_input: &NumberInput| TaggedValue::F64(number_input.value.unwrap()),
node_id, node_id,
index, index,
)) ))
@@ -2219,29 +2242,28 @@ fn static_input_properties() -> InputProperties {
return Err("Input not found in transform skew input override".to_string()); return Err("Input not found in transform skew input override".to_string());
}; };
if let Some(&TaggedValue::DVec2(val)) = input.as_non_exposed_value() { if let Some(&TaggedValue::DVec2(val)) = input.as_non_exposed_value() {
let to_skew = |input: &NumberInput| input.value.unwrap().to_radians().tan();
widgets.extend_from_slice(&[ widgets.extend_from_slice(&[
Separator::new(SeparatorType::Unrelated).widget_holder(), Separator::new(SeparatorType::Unrelated).widget_holder(),
NumberInput::new(Some(val.x.atan().to_degrees())) NumberInput::new(Some(val.x))
.label("X") .label("X")
.unit("°") .unit("°")
.min(-89.9) .min(-89.9)
.max(89.9) .max(89.9)
.on_update(node_properties::update_value( .on_update(node_properties::update_value(
move |input: &NumberInput| TaggedValue::DVec2(DVec2::new(to_skew(input), val.y)), move |input: &NumberInput| TaggedValue::DVec2(DVec2::new(input.value.unwrap(), val.y)),
node_id, node_id,
index, index,
)) ))
.on_commit(node_properties::commit_value) .on_commit(node_properties::commit_value)
.widget_holder(), .widget_holder(),
Separator::new(SeparatorType::Related).widget_holder(), Separator::new(SeparatorType::Related).widget_holder(),
NumberInput::new(Some(val.y.atan().to_degrees())) NumberInput::new(Some(val.y))
.label("Y") .label("Y")
.unit("°") .unit("°")
.min(-89.9) .min(-89.9)
.max(89.9) .max(89.9)
.on_update(node_properties::update_value( .on_update(node_properties::update_value(
move |input: &NumberInput| TaggedValue::DVec2(DVec2::new(val.x, to_skew(input))), move |input: &NumberInput| TaggedValue::DVec2(DVec2::new(val.x, input.value.unwrap())),
node_id, node_id,
index, index,
)) ))
@@ -1232,6 +1232,17 @@ impl NodeNetworkInterface {
Some(&metadata.transient_metadata) Some(&metadata.transient_metadata)
} }
pub fn set_input_override(&mut self, node_id: &NodeId, index: usize, widget_override: Option<String>, network_path: &[NodeId]) {
let Some(metadata) = self
.node_metadata_mut(node_id, network_path)
.and_then(|node_metadata| node_metadata.persistent_metadata.input_metadata.get_mut(index))
else {
log::error!("Could not get input metadata for {node_id} index {index} in set_input_override");
return;
};
metadata.persistent_metadata.widget_override = widget_override;
}
/// Returns the input name to display in the properties panel. If the name is empty then the type is used. /// Returns the input name to display in the properties panel. If the name is empty then the type is used.
pub fn displayed_input_name_and_description(&mut self, node_id: &NodeId, input_index: usize, network_path: &[NodeId]) -> (String, String) { pub fn displayed_input_name_and_description(&mut self, node_id: &NodeId, input_index: usize, network_path: &[NodeId]) -> (String, String) {
let Some(input_metadata) = self.persistent_input_metadata(node_id, input_index, network_path) else { let Some(input_metadata) = self.persistent_input_metadata(node_id, input_index, network_path) else {
@@ -5,7 +5,7 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions:
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeTemplate, OutputConnector}; use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeTemplate, OutputConnector};
use crate::messages::prelude::DocumentMessageHandler; use crate::messages::prelude::DocumentMessageHandler;
use glam::IVec2; use glam::{DVec2, IVec2};
use graph_craft::document::DocumentNode; use graph_craft::document::DocumentNode;
use graph_craft::document::{DocumentNodeImplementation, NodeInput, value::TaggedValue}; use graph_craft::document::{DocumentNodeImplementation, NodeInput, value::TaggedValue};
use graphene_std::ProtoNodeIdentifier; use graphene_std::ProtoNodeIdentifier;
@@ -16,6 +16,7 @@ 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};
use std::collections::HashMap; use std::collections::HashMap;
use std::f64::consts::PI;
const TEXT_REPLACEMENTS: &[(&str, &str)] = &[ const TEXT_REPLACEMENTS: &[(&str, &str)] = &[
("graphene_core::vector::vector_nodes::SamplePointsNode", "graphene_core::vector::SamplePolylineNode"), ("graphene_core::vector::vector_nodes::SamplePointsNode", "graphene_core::vector::SamplePolylineNode"),
@@ -1051,14 +1052,80 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
document.network_interface.add_import(TaggedValue::U32(0), false, 1, "Loop Level", "TODO", &node_path); document.network_interface.add_import(TaggedValue::U32(0), false, 1, "Loop Level", "TODO", &node_path);
} }
// Add context features to nodes that don't have them (fine-grained context caching migration) // Migrate the Transform node to use degrees instead of radians
if node.context_features == graphene_std::ContextDependencies::default() { if reference == "Transform" && node.inputs.get(6).is_none() {
if let Some(reference) = document.network_interface.reference(node_id, network_path).cloned().flatten() { // Migrate rotation from radians to degrees
if let Some(node_definition) = resolve_document_node_type(&reference) { match node.inputs.get(2)? {
let context_features = node_definition.node_template.document_node.context_features; NodeInput::Value { tagged_value, exposed } => {
document.network_interface.set_context_features(node_id, network_path, context_features); // Read the existing Properties panel number value, which used to be in radians
let TaggedValue::F64(radians) = *tagged_value.clone().into_inner() else { return None };
// Convert the radians to degrees and set it back as the new input value
let degrees = NodeInput::value(TaggedValue::F64(radians.to_degrees()), *exposed);
document.network_interface.set_input(&InputConnector::node(*node_id, 2), degrees, network_path);
} }
NodeInput::Node { .. } => {
// Construct a new Multiply node for converting from degrees to radians
let Some(multiply_node) = resolve_document_node_type("Multiply") else {
log::error!("Could not get multiply node from definition when upgrading transform");
return None;
};
let mut multiply_template = multiply_node.default_node_template();
multiply_template.document_node.inputs[1] = NodeInput::value(TaggedValue::F64(180. / PI), false);
// Decide on the placement position of the new Multiply node
let multiply_node_id = NodeId::new();
let Some(transform_position) = document.network_interface.position_from_downstream_node(node_id, network_path) else {
log::error!("Could not get positon for transform node {node_id}");
return None;
};
let multiply_position = transform_position + IVec2::new(-7, 1);
// Insert the new Multiply node into the network directly before it's used
document.network_interface.insert_node(multiply_node_id, multiply_template, network_path);
document.network_interface.shift_absolute_node_position(&multiply_node_id, multiply_position, network_path);
document.network_interface.insert_node_between(&multiply_node_id, &InputConnector::node(*node_id, 2), 0, network_path);
}
_ => {}
};
// Migrate skew from radians to degrees
if let NodeInput::Value { tagged_value, exposed } = node.inputs.get(4)? {
// Read the existing Properties panel number value, which used to be in radians
let TaggedValue::DVec2(old_value) = *tagged_value.clone().into_inner() else { return None };
// The previous value stored the tangent of the displayed degrees. Now it stores the degrees, so take the arctan of it and convert to degrees.
let new_value = DVec2::new(old_value.x.atan().to_degrees(), old_value.y.atan().to_degrees());
let new_input = NodeInput::value(TaggedValue::DVec2(new_value), *exposed);
document.network_interface.set_input(&InputConnector::node(*node_id, 4), new_input, network_path);
} }
// Remove the possible existence of the old "Pivot" hidden value input that was removed in #2730
let nested_transform_network = [network_path, &[*node_id]].concat();
if node.inputs.get(5).is_some() {
document.network_interface.remove_import(5, &nested_transform_network);
}
// Add the Origin Offset parameter as a hidden input, which will be given actual functionality in the future but is currently used as a marker to detect not-yet-upgraded Transform nodes
document
.network_interface
.add_import(TaggedValue::DVec2(DVec2::ZERO), false, 5, "Origin Offset", "", &nested_transform_network);
document.network_interface.set_input_override(node_id, 5, Some("hidden".to_string()), network_path); // Hide it while we're not yet using it
// Add the Scale Appearance parameter as a hidden input, which will be given actual functionality in the future but is currently used as a marker to detect not-yet-upgraded Transform nodes
document
.network_interface
.add_import(TaggedValue::Bool(true), false, 6, "Scale Appearance", "", &nested_transform_network);
document.network_interface.set_input_override(node_id, 6, Some("hidden".to_string()), network_path); // Hide it while we're not yet using it
}
// Add context features to nodes that don't have them (fine-grained context caching migration)
if node.context_features == graphene_std::ContextDependencies::default()
&& let Some(reference) = document.network_interface.reference(node_id, network_path).cloned().flatten()
&& let Some(node_definition) = resolve_document_node_type(&reference)
{
let context_features = node_definition.node_template.document_node.context_features;
document.network_interface.set_context_features(node_id, network_path, context_features);
} }
// ================================== // ==================================
@@ -246,11 +246,11 @@ pub fn new_custom(id: NodeId, nodes: Vec<(NodeId, NodeTemplate)>, parent: LayerN
LayerNodeIdentifier::new_unchecked(id) LayerNodeIdentifier::new_unchecked(id)
} }
/// Locate the origin of the transform node /// Locate the origin of the "Transform" node.
pub fn get_origin(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<DVec2> { pub fn get_origin(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<DVec2> {
use graphene_std::transform_nodes::transform::TranslateInput; use graphene_std::transform_nodes::transform::*;
if let TaggedValue::DVec2(origin) = NodeGraphLayer::new(layer, network_interface).find_input("Transform", TranslateInput::INDEX)? { if let TaggedValue::DVec2(origin) = NodeGraphLayer::new(layer, network_interface).find_input("Transform", TranslationInput::INDEX)? {
Some(*origin) Some(*origin)
} else { } else {
None None
+5 -3
View File
@@ -22,12 +22,14 @@ async fn transform<T: ApplyTransform + 'n + 'static>(
Context -> Table<GradientStops>, Context -> Table<GradientStops>,
)] )]
value: impl Node<Context<'static>, Output = T>, value: impl Node<Context<'static>, Output = T>,
translate: DVec2, translation: DVec2,
rotate: f64, rotation: f64,
scale: DVec2, scale: DVec2,
skew: DVec2, skew: DVec2,
) -> T { ) -> T {
let matrix = DAffine2::from_scale_angle_translation(scale, rotate, translate) * DAffine2::from_cols_array(&[1., skew.y, skew.x, 1., 0., 0.]); let trs = DAffine2::from_scale_angle_translation(scale, rotation.to_radians(), translation);
let skew = DAffine2::from_cols_array(&[1., skew.y.to_radians().tan(), skew.x.to_radians().tan(), 1., 0., 0.]);
let matrix = trs * skew;
let footprint = ctx.try_footprint().copied(); let footprint = ctx.try_footprint().copied();