diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index aaa46b9c..06bec125 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -1303,11 +1303,11 @@ fn static_nodes() -> Vec { }, DocumentNodeDefinition { identifier: "Transform", - category: "General", + category: "Math: Transform", node_template: NodeTemplate { document_node: DocumentNode { inputs: vec![ - NodeInput::value(TaggedValue::VectorData(VectorDataTable::default()), true), + NodeInput::value(TaggedValue::DAffine2(DAffine2::default()), true), NodeInput::value(TaggedValue::DVec2(DVec2::ZERO), false), NodeInput::value(TaggedValue::F64(0.), false), NodeInput::value(TaggedValue::DVec2(DVec2::ONE), false), @@ -1317,7 +1317,7 @@ fn static_nodes() -> Vec { exports: vec![NodeInput::node(NodeId(1), 0)], nodes: [ DocumentNode { - inputs: vec![NodeInput::network(concrete!(VectorDataTable), 0)], + inputs: vec![NodeInput::network(generic!(T), 0)], implementation: DocumentNodeImplementation::ProtoNode(memo::monitor::IDENTIFIER), manual_composition: Some(generic!(T)), skip_deduplication: true, @@ -1374,7 +1374,7 @@ fn static_nodes() -> Vec { ..Default::default() }), input_metadata: vec![ - ("Vector Data", "TODO").into(), + ("Value", "TODO").into(), InputMetadata::with_name_description_override( "Translation", "TODO", 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 952c57b1..4c9b8b01 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -21,7 +21,7 @@ use graphene_std::raster::{ }; use graphene_std::raster_types::{CPU, GPU, RasterDataTable}; use graphene_std::text::Font; -use graphene_std::transform::{Footprint, ReferencePoint}; +use graphene_std::transform::{Footprint, ReferencePoint, Transform}; use graphene_std::vector::VectorDataTable; use graphene_std::vector::misc::GridType; use graphene_std::vector::misc::{ArcType, MergeByDistanceAlgorithm}; @@ -176,6 +176,7 @@ pub(crate) fn property_from_type( Some(x) if x == TypeId::of::() => bool_widget(default_info, CheckboxInput::default()).into(), Some(x) if x == TypeId::of::() => text_widget(default_info).into(), Some(x) if x == TypeId::of::() => coordinate_widget(default_info, "X", "Y", "", None, false), + Some(x) if x == TypeId::of::() => transform_widget(default_info, &mut extra_widgets), // ========================== // PRIMITIVE COLLECTION TYPES // ========================== @@ -504,6 +505,126 @@ pub fn footprint_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widg last.clone() } +pub fn transform_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widgets: &mut Vec) -> LayoutGroup { + let ParameterWidgetsInfo { document_node, node_id, index, .. } = parameter_widgets_info; + + let mut location_widgets = start_widgets(parameter_widgets_info); + location_widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder()); + + let mut rotation_widgets = vec![TextLabel::new("").widget_holder()]; + add_blank_assist(&mut rotation_widgets); + rotation_widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder()); + + let mut scale_widgets = vec![TextLabel::new("").widget_holder()]; + add_blank_assist(&mut scale_widgets); + scale_widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder()); + + let Some(document_node) = document_node else { return LayoutGroup::default() }; + let Some(input) = document_node.inputs.get(index) else { + log::warn!("A widget failed to be built because its node's input index is invalid."); + return Vec::new().into(); + }; + + 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(); + + location_widgets.extend_from_slice(&[ + NumberInput::new(Some(translation.x)) + .label("X") + .unit(" px") + .on_update(update_value( + move |x: &NumberInput| { + let mut transform = transform; + transform.translation.x = x.value.unwrap_or(transform.translation.x); + TaggedValue::DAffine2(transform) + }, + node_id, + index, + )) + .on_commit(commit_value) + .widget_holder(), + Separator::new(SeparatorType::Related).widget_holder(), + NumberInput::new(Some(translation.y)) + .label("Y") + .unit(" px") + .on_update(update_value( + move |y: &NumberInput| { + let mut transform = transform; + transform.translation.y = y.value.unwrap_or(transform.translation.y); + TaggedValue::DAffine2(transform) + }, + node_id, + index, + )) + .on_commit(commit_value) + .widget_holder(), + ]); + + rotation_widgets.extend_from_slice(&[NumberInput::new(Some(rotation.to_degrees())) + .unit("°") + .mode(NumberInputMode::Range) + .range_min(Some(-180.)) + .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); + TaggedValue::DAffine2(transform) + }, + node_id, + index, + )) + .on_commit(commit_value) + .widget_holder()]); + + scale_widgets.extend_from_slice(&[ + NumberInput::new(Some(scale.x)) + .label("W") + .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); + TaggedValue::DAffine2(transform) + }, + node_id, + index, + )) + .on_commit(commit_value) + .widget_holder(), + Separator::new(SeparatorType::Related).widget_holder(), + NumberInput::new(Some(scale.y)) + .label("H") + .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); + TaggedValue::DAffine2(transform) + }, + node_id, + index, + )) + .on_commit(commit_value) + .widget_holder(), + ]); + + vec![ + LayoutGroup::Row { widgets: location_widgets }, + LayoutGroup::Row { widgets: rotation_widgets }, + LayoutGroup::Row { widgets: scale_widgets }, + ] + } else { + vec![LayoutGroup::Row { widgets: location_widgets }] + }; + + if let Some((last, rest)) = widgets.split_last() { + *extra_widgets = rest.to_vec(); + last.clone() + } else { + LayoutGroup::default() + } +} + pub fn coordinate_widget(parameter_widgets_info: ParameterWidgetsInfo, x: &str, y: &str, unit: &str, min: Option, is_integer: bool) -> LayoutGroup { let ParameterWidgetsInfo { document_node, node_id, index, .. } = parameter_widgets_info; @@ -1345,9 +1466,9 @@ pub(crate) fn rectangle_properties(node_id: NodeId, context: &mut NodeProperties pub(crate) fn node_no_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { let text = if context.network_interface.is_layer(&node_id, context.selection_network_path) { - "Layer has no properties" + "Layer has no parameters" } else { - "Node has no properties" + "Node has no parameters" }; string_properties(text) } diff --git a/editor/src/messages/portfolio/document/node_graph/utility_types.rs b/editor/src/messages/portfolio/document/node_graph/utility_types.rs index 36961184..3885aefc 100644 --- a/editor/src/messages/portfolio/document/node_graph/utility_types.rs +++ b/editor/src/messages/portfolio/document/node_graph/utility_types.rs @@ -27,7 +27,8 @@ impl FrontendGraphDataType { | TaggedValue::OptionalDVec2(_) | TaggedValue::F64Array4(_) | TaggedValue::VecF64(_) - | TaggedValue::VecDVec2(_) => Self::Number, + | TaggedValue::VecDVec2(_) + | TaggedValue::DAffine2(_) => Self::Number, TaggedValue::GraphicGroup(_) | TaggedValue::GraphicElement(_) => Self::Group, // TODO: Is GraphicElement supposed to be included here? TaggedValue::ArtboardGroup(_) => Self::Artboard, _ => Self::General, diff --git a/node-graph/gcore/src/graphic_element.rs b/node-graph/gcore/src/graphic_element.rs index 4d949dd1..d2382053 100644 --- a/node-graph/gcore/src/graphic_element.rs +++ b/node-graph/gcore/src/graphic_element.rs @@ -100,6 +100,11 @@ impl From> for GraphicGroupTable { Self::new(GraphicElement::RasterDataGPU(raster_data_table)) } } +impl From for GraphicGroupTable { + fn from(_: DAffine2) -> Self { + GraphicGroupTable::default() + } +} /// The possible forms of graphical content held in a Vec by the `elements` field of [`GraphicElement`]. #[derive(Clone, Debug, Hash, PartialEq, DynAny, serde::Serialize, serde::Deserialize)] @@ -118,6 +123,12 @@ impl Default for GraphicElement { } } +impl From for GraphicElement { + fn from(_: DAffine2) -> Self { + GraphicElement::default() + } +} + impl GraphicElement { pub fn as_group(&self) -> Option<&GraphicGroupTable> { match self { @@ -351,6 +362,7 @@ async fn to_element + 'n>( VectorDataTable, RasterDataTable, RasterDataTable, + DAffine2, )] data: Data, ) -> GraphicElement { @@ -463,6 +475,7 @@ async fn to_artboard + 'n>( Context -> VectorDataTable, Context -> RasterDataTable, Context -> RasterDataTable, + Context -> DAffine2, )] contents: impl Node, Output = Data>, label: String, diff --git a/node-graph/gcore/src/instances.rs b/node-graph/gcore/src/instances.rs index 5b5be2a3..ca76745e 100644 --- a/node-graph/gcore/src/instances.rs +++ b/node-graph/gcore/src/instances.rs @@ -1,4 +1,5 @@ use crate::AlphaBlending; +use crate::transform::ApplyTransform; use crate::uuid::NodeId; use dyn_any::StaticType; use glam::DAffine2; @@ -136,6 +137,20 @@ impl Hash for Instances { } } +impl ApplyTransform for Instances { + fn apply_transform(&mut self, modification: &DAffine2) { + for transform in &mut self.transform { + *transform *= *modification; + } + } + + fn left_apply_transform(&mut self, modification: &DAffine2) { + for transform in &mut self.transform { + *transform = *modification * *transform; + } + } +} + impl PartialEq for Instances { fn eq(&self, other: &Self) -> bool { self.instance.len() == other.instance.len() && { self.instance.iter().zip(other.instance.iter()).all(|(a, b)| a == b) } diff --git a/node-graph/gcore/src/transform.rs b/node-graph/gcore/src/transform.rs index dafd3791..4d5f5b5d 100644 --- a/node-graph/gcore/src/transform.rs +++ b/node-graph/gcore/src/transform.rs @@ -6,14 +6,20 @@ use glam::{DAffine2, DMat2, DVec2}; 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((1., 0.).into()).length(), - self.transform().transform_vector2((0., 1.).into()).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. + 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 } } } @@ -141,12 +147,21 @@ impl std::hash::Hash for Footprint { pub trait ApplyTransform { fn apply_transform(&mut self, modification: &DAffine2); + fn left_apply_transform(&mut self, modification: &DAffine2); } impl ApplyTransform for T { fn apply_transform(&mut self, &modification: &DAffine2) { *self.transform_mut() = self.transform() * modification } + fn left_apply_transform(&mut self, &modification: &DAffine2) { + *self.transform_mut() = modification * self.transform() + } } -impl ApplyTransform for () { - fn apply_transform(&mut self, &_modification: &DAffine2) {} +impl ApplyTransform for DVec2 { + fn apply_transform(&mut self, modification: &DAffine2) { + *self = modification.transform_point2(*self); + } + fn left_apply_transform(&mut self, modification: &DAffine2) { + *self = modification.inverse().transform_point2(*self); + } } diff --git a/node-graph/gcore/src/transform_nodes.rs b/node-graph/gcore/src/transform_nodes.rs index 4cde6a74..fc9fd887 100644 --- a/node-graph/gcore/src/transform_nodes.rs +++ b/node-graph/gcore/src/transform_nodes.rs @@ -7,20 +7,22 @@ use core::f64; use glam::{DAffine2, DVec2}; #[node_macro::node(category(""))] -async fn transform( +async fn transform( ctx: impl Ctx + CloneVarArgs + ExtractAll, #[implementations( + Context -> DAffine2, + Context -> DVec2, Context -> VectorDataTable, Context -> GraphicGroupTable, Context -> RasterDataTable, Context -> RasterDataTable, )] - transform_target: impl Node, Output = Instances>, + value: impl Node, Output = T>, translate: DVec2, rotate: f64, scale: DVec2, skew: DVec2, -) -> Instances { +) -> T { let matrix = DAffine2::from_scale_angle_translation(scale, rotate, translate) * DAffine2::from_cols_array(&[1., skew.y, skew.x, 1., 0., 0.]); let footprint = ctx.try_footprint().copied(); @@ -31,11 +33,9 @@ async fn transform( ctx = ctx.with_footprint(footprint); } - let mut transform_target = transform_target.eval(ctx.into_context()).await; + let mut transform_target = value.eval(ctx.into_context()).await; - for data_transform in transform_target.instance_mut_iter() { - *data_transform.transform = matrix * *data_transform.transform; - } + transform_target.left_apply_transform(&matrix); transform_target } @@ -52,6 +52,40 @@ fn replace_transform( data } +#[node_macro::node(category("Math: Transform"), path(graphene_core::vector))] +async fn extract_transform( + _: impl Ctx, + #[implementations( + GraphicGroupTable, + VectorDataTable, + RasterDataTable, + RasterDataTable, + )] + vector_data: Instances, +) -> DAffine2 { + vector_data.instance_ref_iter().next().map(|vector_data| *vector_data.transform).unwrap_or_default() +} + +#[node_macro::node(category("Math: Transform"))] +fn invert_transform(_: impl Ctx, transform: DAffine2) -> DAffine2 { + transform.inverse() +} + +#[node_macro::node(category("Math: Transform"))] +fn decompose_translation(_: impl Ctx, transform: DAffine2) -> DVec2 { + transform.translation +} + +#[node_macro::node(category("Math: Transform"))] +fn decompose_rotation(_: impl Ctx, transform: DAffine2) -> f64 { + transform.decompose_rotation() +} + +#[node_macro::node(category("Math: Transform"))] +fn decompose_scale(_: impl Ctx, transform: DAffine2) -> DVec2 { + transform.decompose_scale() +} + #[node_macro::node(category("Debug"))] async fn boundless_footprint( ctx: impl Ctx + CloneVarArgs + ExtractAll, diff --git a/node-graph/gcore/src/vector/vector_data/modification.rs b/node-graph/gcore/src/vector/vector_data/modification.rs index fd7162a6..a8f0ae97 100644 --- a/node-graph/gcore/src/vector/vector_data/modification.rs +++ b/node-graph/gcore/src/vector/vector_data/modification.rs @@ -418,7 +418,7 @@ impl Hash for VectorModification { } } -/// A node that applies a procedural modification to some [`VectorData`]. +/// Applies a diff modification to a vector path. #[node_macro::node(category(""))] async fn path_modify(_ctx: impl Ctx, mut vector_data: VectorDataTable, modification: Box, node_path: Vec) -> VectorDataTable { if vector_data.is_empty() { @@ -437,6 +437,23 @@ async fn path_modify(_ctx: impl Ctx, mut vector_data: VectorDataTable, modificat vector_data } +/// Applies the vector path's local transformation to its geometry and resets it to the identity. +#[node_macro::node(category("Vector"))] +async fn apply_transform(_ctx: impl Ctx, mut vector_data: VectorDataTable) -> VectorDataTable { + for vector_data_instance in vector_data.instance_mut_iter() { + let vector_data = vector_data_instance.instance; + let transform = *vector_data_instance.transform; + + for (_, point) in vector_data.point_domain.positions_mut() { + *point = transform.transform_point2(*point); + } + + *vector_data_instance.transform = DAffine2::IDENTITY; + } + + vector_data +} + // Do we want to enforce that all serialized/deserialized hashmaps are a vec of tuples? // TODO: Eventually remove this document upgrade code use serde::de::{SeqAccess, Visitor}; diff --git a/node-graph/gcore/src/vector/vector_nodes.rs b/node-graph/gcore/src/vector/vector_nodes.rs index 82c9fab1..93e73293 100644 --- a/node-graph/gcore/src/vector/vector_nodes.rs +++ b/node-graph/gcore/src/vector/vector_nodes.rs @@ -1874,7 +1874,7 @@ fn bevel_algorithm(mut vector_data: VectorData, vector_data_transform: DAffine2, } if segment_domain_length != sorted_segments.len() { - for i in 0..segment_domain_length as usize { + for i in 0..segment_domain_length { if !sorted_segments.contains(&i) { sorted_segments.push(i); } diff --git a/node-graph/gmath-nodes/src/lib.rs b/node-graph/gmath-nodes/src/lib.rs index 34bf434b..f79bc886 100644 --- a/node-graph/gmath-nodes/src/lib.rs +++ b/node-graph/gmath-nodes/src/lib.rs @@ -1,6 +1,7 @@ -use glam::DVec2; +use glam::{DAffine2, DVec2}; use graphene_core::gradient::GradientStops; -use graphene_core::registry::types::{Fraction, Percentage, TextArea}; +use graphene_core::registry::types::{Fraction, Percentage, PixelSize, TextArea}; +use graphene_core::transform::Footprint; use graphene_core::{Color, Ctx, num_traits}; use log::warn; use math_parser::ast; @@ -107,11 +108,11 @@ fn subtract, T>( fn multiply, T>( _: impl Ctx, /// The left-hand side of the multiplication operation. - #[implementations(f64, f32, u32, DVec2, f64, DVec2)] + #[implementations(f64, f32, u32, f64, DVec2, DVec2, DAffine2)] multiplier: U, /// The right-hand side of the multiplication operation. #[default(1.)] - #[implementations(f64, f32, u32, DVec2, DVec2, f64)] + #[implementations(f64, f32, u32, DVec2, f64, DVec2, DAffine2)] multiplicand: T, ) -> >::Output { multiplier * multiplicand @@ -681,6 +682,16 @@ fn string_value(_: impl Ctx, _primary: (), string: TextArea) -> String { string } +/// Constructs a footprint value which may be set to any transformation of a unit square describing a render area, and a render resolution at least 1x1 integer pixels. +#[node_macro::node(category("Value"))] +fn footprint_value(_: impl Ctx, _primary: (), transform: DAffine2, #[default(100., 100.)] resolution: PixelSize) -> Footprint { + Footprint { + transform, + resolution: resolution.max(DVec2::ONE).as_uvec2(), + ..Default::default() + } +} + #[node_macro::node(category("Math: Vector"))] fn dot_product(_: impl Ctx, vector_a: DVec2, vector_b: DVec2) -> f64 { vector_a.dot(vector_b) diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index ddd71abd..1dee5ed6 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -1,5 +1,5 @@ use dyn_any::StaticType; -use glam::{DVec2, IVec2, UVec2}; +use glam::{DAffine2, DVec2, IVec2, UVec2}; use graph_craft::document::value::RenderOutput; use graph_craft::proto::{NodeConstructor, TypeErasedBox}; use graphene_core::raster::color::Color; @@ -52,6 +52,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => String]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => IVec2]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => DVec2]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => DAffine2]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => bool]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => f64]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => u32]), @@ -166,7 +167,7 @@ fn node_registry() -> HashMap