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 7d9a40baa..dd49e4ec7 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 @@ -20,6 +20,7 @@ use graphene_core::text::{Font, TypesettingConfig}; use graphene_core::transform::Footprint; use graphene_core::vector::VectorDataTable; use graphene_core::*; +use graphene_std::ops::XY; use std::collections::{HashMap, HashSet, VecDeque}; #[cfg(feature = "gpu")] use wgpu_executor::{Bindgroup, CommandBuffer, PipelineLayout, ShaderHandle, ShaderInputFrame, WgpuShaderInput}; @@ -900,6 +901,75 @@ fn static_nodes() -> Vec { description: Cow::Borrowed("TODO"), properties: None, }, + DocumentNodeDefinition { + identifier: "Split Vector2", + category: "Math: Vector", + node_template: NodeTemplate { + document_node: DocumentNode { + implementation: DocumentNodeImplementation::Network(NodeNetwork { + exports: vec![NodeInput::node(NodeId(0), 0), NodeInput::node(NodeId(1), 0)], + nodes: [ + DocumentNode { + inputs: vec![NodeInput::network(concrete!(ImageFrameTable), 0), NodeInput::value(TaggedValue::XY(XY::X), false)], + implementation: DocumentNodeImplementation::ProtoNode(ProtoNodeIdentifier::new("graphene_core::ops::ExtractXyNode")), + manual_composition: Some(generic!(T)), + ..Default::default() + }, + DocumentNode { + inputs: vec![NodeInput::network(concrete!(ImageFrameTable), 0), NodeInput::value(TaggedValue::XY(XY::Y), false)], + implementation: DocumentNodeImplementation::ProtoNode(ProtoNodeIdentifier::new("graphene_core::ops::ExtractXyNode")), + manual_composition: Some(generic!(T)), + ..Default::default() + }, + ] + .into_iter() + .enumerate() + .map(|(id, node)| (NodeId(id as u64), node)) + .collect(), + + ..Default::default() + }), + inputs: vec![NodeInput::value(TaggedValue::ImageFrame(ImageFrameTable::one_empty_image()), true)], + ..Default::default() + }, + persistent_node_metadata: DocumentNodePersistentMetadata { + input_properties: vec!["Vector2".into()], + output_names: vec!["X".to_string(), "Y".to_string()], + has_primary_output: false, + network_metadata: Some(NodeNetworkMetadata { + persistent_metadata: NodeNetworkPersistentMetadata { + node_metadata: [ + DocumentNodeMetadata { + persistent_metadata: DocumentNodePersistentMetadata { + display_name: "Extract XY".to_string(), + node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, 0)), + ..Default::default() + }, + ..Default::default() + }, + DocumentNodeMetadata { + persistent_metadata: DocumentNodePersistentMetadata { + display_name: "Extract XY".to_string(), + node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, 2)), + ..Default::default() + }, + ..Default::default() + }, + ] + .into_iter() + .enumerate() + .map(|(id, node)| (NodeId(id as u64), node)) + .collect(), + ..Default::default() + }, + ..Default::default() + }), + ..Default::default() + }, + }, + description: Cow::Borrowed("TODO"), + properties: None, + }, DocumentNodeDefinition { identifier: "Brush", category: "Raster", @@ -2889,6 +2959,7 @@ fn static_node_properties() -> NodeProperties { map.insert("exposure_properties".to_string(), Box::new(node_properties::exposure_properties)); map.insert("math_properties".to_string(), Box::new(node_properties::math_properties)); map.insert("rectangle_properties".to_string(), Box::new(node_properties::rectangle_properties)); + map.insert("grid_properties".to_string(), Box::new(node_properties::grid_properties)); map.insert( "identity_properties".to_string(), Box::new(|_node_id, _context| node_properties::string_properties("The identity node simply passes its data through.")), 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 648af38db..84b55ff58 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -21,9 +21,10 @@ use graphene_core::vector::misc::CentroidType; use graphene_core::vector::style::{GradientType, LineCap, LineJoin}; use graphene_std::animation::RealTimeMode; use graphene_std::application_io::TextureFrameTable; +use graphene_std::ops::XY; use graphene_std::transform::Footprint; use graphene_std::vector::VectorDataTable; -use graphene_std::vector::misc::BooleanOperation; +use graphene_std::vector::misc::{BooleanOperation, GridType}; use graphene_std::vector::style::{Fill, FillChoice, FillType, GradientStops}; use graphene_std::{GraphicGroupTable, RasterFrame}; @@ -168,6 +169,7 @@ pub(crate) fn property_from_type( Some(x) if x == TypeId::of::() => real_time_mode(document_node, node_id, index, name, true), Some(x) if x == TypeId::of::() => color_channel(document_node, node_id, index, name, true), Some(x) if x == TypeId::of::() => rgba_channel(document_node, node_id, index, name, true), + Some(x) if x == TypeId::of::() => xy_components(document_node, node_id, index, name, true), Some(x) if x == TypeId::of::() => noise_type(document_node, node_id, index, name, true), Some(x) if x == TypeId::of::() => fractal_type(document_node, node_id, index, name, true, false), Some(x) if x == TypeId::of::() => cellular_distance_function(document_node, node_id, index, name, true, false), @@ -185,6 +187,7 @@ pub(crate) fn property_from_type( .widget_holder(), ] .into(), + Some(x) if x == TypeId::of::() => grid_type_widget(document_node, node_id, index, name, true), Some(x) if x == TypeId::of::() => line_cap_widget(document_node, node_id, index, name, true), Some(x) if x == TypeId::of::() => line_join_widget(document_node, node_id, index, name, true), Some(x) if x == TypeId::of::() => vec![ @@ -567,6 +570,28 @@ pub fn vec2_widget( .widget_holder(), ]); } + Some(&TaggedValue::F64(value)) => { + widgets.extend_from_slice(&[ + Separator::new(SeparatorType::Unrelated).widget_holder(), + NumberInput::new(Some(value)) + .label(x) + .unit(unit) + .min(min.unwrap_or(-((1_u64 << f64::MANTISSA_DIGITS) as f64))) + .max((1_u64 << f64::MANTISSA_DIGITS) as f64) + .on_update(update_value(move |input: &NumberInput| TaggedValue::DVec2(DVec2::new(input.value.unwrap(), value)), node_id, index)) + .on_commit(commit_value) + .widget_holder(), + Separator::new(SeparatorType::Related).widget_holder(), + NumberInput::new(Some(value)) + .label(y) + .unit(unit) + .min(min.unwrap_or(-((1_u64 << f64::MANTISSA_DIGITS) as f64))) + .max((1_u64 << f64::MANTISSA_DIGITS) as f64) + .on_update(update_value(move |input: &NumberInput| TaggedValue::DVec2(DVec2::new(value, input.value.unwrap())), node_id, index)) + .on_commit(commit_value) + .widget_holder(), + ]); + } _ => {} } @@ -745,6 +770,15 @@ pub fn number_widget(document_node: &DocumentNode, node_id: NodeId, index: usize .widget_holder(), ]); } + Some(&TaggedValue::DVec2(dvec2)) => widgets.extend_from_slice(&[ + Separator::new(SeparatorType::Unrelated).widget_holder(), + number_props + // We use an arbitrary `y` instead of an arbitrary `x` here because the "Grid" node's "Spacing" value's height should be used from rectangular mode when transferred to "Y Spacing" in isometric mode + .value(Some(dvec2.y)) + .on_update(update_value(move |x: &NumberInput| TaggedValue::F64(x.value.unwrap()), node_id, index)) + .on_commit(commit_value) + .widget_holder(), + ]), _ => {} } @@ -840,6 +874,33 @@ pub fn rgba_channel(document_node: &DocumentNode, node_id: NodeId, index: usize, LayoutGroup::Row { widgets }.with_tooltip("Color Channel") } +pub fn xy_components(document_node: &DocumentNode, node_id: NodeId, index: usize, name: &str, blank_assist: bool) -> LayoutGroup { + let mut widgets = start_widgets(document_node, node_id, index, name, FrontendGraphDataType::General, blank_assist); + 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 LayoutGroup::Row { widgets: vec![] }; + }; + if let Some(&TaggedValue::XY(mode)) = input.as_non_exposed_value() { + let calculation_modes = [XY::X, XY::Y]; + let mut entries = Vec::with_capacity(calculation_modes.len()); + for method in calculation_modes { + entries.push( + MenuListEntry::new(format!("{method:?}")) + .label(method.to_string()) + .on_update(update_value(move |_| TaggedValue::XY(method), node_id, index)) + .on_commit(commit_value), + ); + } + let entries = vec![entries]; + + widgets.extend_from_slice(&[ + Separator::new(SeparatorType::Unrelated).widget_holder(), + DropdownInput::new(entries).selected_index(Some(mode as u32)).widget_holder(), + ]); + } + LayoutGroup::Row { widgets }.with_tooltip("X or Y Component of Vector2") +} + // TODO: Generalize this instead of using a separate function per dropdown menu enum pub fn noise_type(document_node: &DocumentNode, node_id: NodeId, index: usize, name: &str, blank_assist: bool) -> LayoutGroup { let mut widgets = start_widgets(document_node, node_id, index, name, FrontendGraphDataType::General, blank_assist); @@ -1064,6 +1125,31 @@ pub fn boolean_operation_radio_buttons(document_node: &DocumentNode, node_id: No LayoutGroup::Row { widgets } } +pub fn grid_type_widget(document_node: &DocumentNode, node_id: NodeId, index: usize, name: &str, blank_assist: bool) -> LayoutGroup { + let mut widgets = start_widgets(document_node, node_id, index, name, FrontendGraphDataType::General, blank_assist); + 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 LayoutGroup::Row { widgets: vec![] }; + }; + if let Some(&TaggedValue::GridType(grid_type)) = input.as_non_exposed_value() { + let entries = [("Rectangular", GridType::Rectangular), ("Isometric", GridType::Isometric)] + .into_iter() + .map(|(name, val)| { + RadioEntryData::new(format!("{val:?}")) + .label(name) + .on_update(update_value(move |_| TaggedValue::GridType(val), node_id, index)) + .on_commit(commit_value) + }) + .collect(); + + widgets.extend_from_slice(&[ + Separator::new(SeparatorType::Unrelated).widget_holder(), + RadioInput::new(entries).selected_index(Some(grid_type as u32)).widget_holder(), + ]); + } + LayoutGroup::Row { widgets } +} + pub fn line_cap_widget(document_node: &DocumentNode, node_id: NodeId, index: usize, name: &str, blank_assist: bool) -> LayoutGroup { let mut widgets = start_widgets(document_node, node_id, index, name, FrontendGraphDataType::General, blank_assist); let Some(input) = document_node.inputs.get(index) else { @@ -1484,6 +1570,52 @@ pub(crate) fn _gpu_map_properties(document_node: &DocumentNode, node_id: NodeId, vec![LayoutGroup::Row { widgets: map }] } +pub(crate) fn grid_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { + let grid_type_index = 1; + let spacing_index = 2; + let angles_index = 3; + let rows_index = 4; + let columns_index = 5; + + let document_node = match get_document_node(node_id, context) { + Ok(document_node) => document_node, + Err(err) => { + log::error!("Could not get document node in exposure_properties: {err}"); + return Vec::new(); + } + }; + let grid_type = grid_type_widget(document_node, node_id, grid_type_index, "Grid Type", true); + + let mut widgets = vec![grid_type]; + + let Some(grid_type_input) = document_node.inputs.get(grid_type_index) else { + log::warn!("A widget failed to be built because its node's input index is invalid."); + return vec![]; + }; + if let Some(&TaggedValue::GridType(grid_type)) = grid_type_input.as_non_exposed_value() { + match grid_type { + GridType::Rectangular => { + let spacing = vec2_widget(document_node, node_id, spacing_index, "Spacing", "W", "H", " px", Some(0.), add_blank_assist); + widgets.push(spacing); + } + GridType::Isometric => { + let spacing = LayoutGroup::Row { + widgets: number_widget(document_node, node_id, spacing_index, "Spacing", NumberInput::default().label("H").min(0.).unit(" px"), true), + }; + let angles = vec2_widget(document_node, node_id, angles_index, "Angles", "", "", "°", None, add_blank_assist); + widgets.extend([spacing, angles]); + } + } + } + + let rows = number_widget(document_node, node_id, rows_index, "Rows", NumberInput::default().min(1.), true); + let columns = number_widget(document_node, node_id, columns_index, "Columns", NumberInput::default().min(1.), true); + + widgets.extend([LayoutGroup::Row { widgets: rows }, LayoutGroup::Row { widgets: columns }]); + + widgets +} + pub(crate) fn exposure_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { let document_node = match get_document_node(node_id, context) { Ok(document_node) => document_node, diff --git a/editor/src/messages/portfolio/document/overlays/grid_overlays.rs b/editor/src/messages/portfolio/document/overlays/grid_overlays.rs index 87d4e07ed..56ed6c2d7 100644 --- a/editor/src/messages/portfolio/document/overlays/grid_overlays.rs +++ b/editor/src/messages/portfolio/document/overlays/grid_overlays.rs @@ -176,7 +176,7 @@ fn grid_overlay_isometric_dot(document: &DocumentMessageHandler, overlay_context pub fn grid_overlay(document: &DocumentMessageHandler, overlay_context: &mut OverlayContext) { match document.snapping_state.grid.grid_type { - GridType::Rectangle { spacing } => { + GridType::Rectangular { spacing } => { if document.snapping_state.grid.dot_display { grid_overlay_rectangular_dot(document, overlay_context, spacing) } else { @@ -238,14 +238,14 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec { RadioInput::new(vec![ RadioEntryData::new("rectangular") .label("Rectangular") - .on_update(update_val(grid, |grid, _| grid.grid_type = GridType::RECTANGLE)), + .on_update(update_val(grid, |grid, _| grid.grid_type = GridType::RECTANGULAR)), RadioEntryData::new("isometric") .label("Isometric") .on_update(update_val(grid, |grid, _| grid.grid_type = GridType::ISOMETRIC)), ]) .min_width(200) .selected_index(Some(match grid.grid_type { - GridType::Rectangle { .. } => 0, + GridType::Rectangular { .. } => 0, GridType::Isometric { .. } => 1, })) .widget_holder(), @@ -291,7 +291,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec { }); match grid.grid_type { - GridType::Rectangle { spacing } => widgets.push(LayoutGroup::Row { + GridType::Rectangular { spacing } => widgets.push(LayoutGroup::Row { widgets: vec![ TextLabel::new("Spacing").table_align(true).widget_holder(), Separator::new(SeparatorType::Unrelated).widget_holder(), @@ -300,7 +300,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec { .unit(" px") .min(0.) .min_width(98) - .on_update(update_origin(grid, |grid| grid.grid_type.rect_spacing().map(|spacing| &mut spacing.x))) + .on_update(update_origin(grid, |grid| grid.grid_type.rectangular_spacing().map(|spacing| &mut spacing.x))) .widget_holder(), Separator::new(SeparatorType::Related).widget_holder(), NumberInput::new(Some(spacing.y)) @@ -308,7 +308,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec { .unit(" px") .min(0.) .min_width(98) - .on_update(update_origin(grid, |grid| grid.grid_type.rect_spacing().map(|spacing| &mut spacing.y))) + .on_update(update_origin(grid, |grid| grid.grid_type.rectangular_spacing().map(|spacing| &mut spacing.y))) .widget_holder(), ], }), diff --git a/editor/src/messages/portfolio/document/utility_types/misc.rs b/editor/src/messages/portfolio/document/utility_types/misc.rs index 3b7821f64..17612dcdb 100644 --- a/editor/src/messages/portfolio/document/utility_types/misc.rs +++ b/editor/src/messages/portfolio/document/utility_types/misc.rs @@ -160,26 +160,33 @@ impl Default for PathSnapping { #[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize, PartialEq)] pub enum GridType { - Rectangle { spacing: DVec2 }, - Isometric { y_axis_spacing: f64, angle_a: f64, angle_b: f64 }, + #[serde(alias = "Rectangle")] + Rectangular { + spacing: DVec2, + }, + Isometric { + y_axis_spacing: f64, + angle_a: f64, + angle_b: f64, + }, } impl Default for GridType { fn default() -> Self { - Self::RECTANGLE + Self::RECTANGULAR } } impl GridType { - pub const RECTANGLE: Self = GridType::Rectangle { spacing: DVec2::ONE }; + pub const RECTANGULAR: Self = GridType::Rectangular { spacing: DVec2::ONE }; pub const ISOMETRIC: Self = GridType::Isometric { y_axis_spacing: 1., angle_a: 30., angle_b: 30., }; - pub fn rect_spacing(&mut self) -> Option<&mut DVec2> { + pub fn rectangular_spacing(&mut self) -> Option<&mut DVec2> { match self { - Self::Rectangle { spacing } => Some(spacing), + Self::Rectangular { spacing } => Some(spacing), _ => None, } } diff --git a/editor/src/messages/tool/common_functionality/snapping/grid_snapper.rs b/editor/src/messages/tool/common_functionality/snapping/grid_snapper.rs index 00751884f..299d9c145 100644 --- a/editor/src/messages/tool/common_functionality/snapping/grid_snapper.rs +++ b/editor/src/messages/tool/common_functionality/snapping/grid_snapper.rs @@ -34,6 +34,7 @@ impl GridSnapper { } lines } + // Isometric grid has 6 lines around a point, 2 y axis, 2 on the angle a, and 2 on the angle b. fn get_snap_lines_isometric(&self, document_point: DVec2, snap_data: &mut SnapData, y_axis_spacing: f64, angle_a: f64, angle_b: f64) -> Vec { let document = snap_data.document; @@ -86,9 +87,10 @@ impl GridSnapper { lines } + fn get_snap_lines(&self, document_point: DVec2, snap_data: &mut SnapData) -> Vec { match snap_data.document.snapping_state.grid.grid_type { - GridType::Rectangle { spacing } => self.get_snap_lines_rectangular(document_point, snap_data, spacing), + GridType::Rectangular { spacing } => self.get_snap_lines_rectangular(document_point, snap_data, spacing), GridType::Isometric { y_axis_spacing, angle_a, angle_b } => self.get_snap_lines_isometric(document_point, snap_data, y_axis_spacing, angle_a, angle_b), } } diff --git a/node-graph/gcore/src/context.rs b/node-graph/gcore/src/context.rs index 10d2f19cc..6eedf1ec4 100644 --- a/node-graph/gcore/src/context.rs +++ b/node-graph/gcore/src/context.rs @@ -328,6 +328,15 @@ impl OwnedContextImpl { self.animation_time = Some(animation_time); self } + pub fn with_vararg(mut self, value: Box) -> Self { + assert!(self.varargs.is_none_or(|value| value.is_empty())); + self.varargs = Some(Arc::new([value])); + self + } + pub fn with_index(mut self, index: usize) -> Self { + self.index = Some(index); + self + } pub fn into_context(self) -> Option> { Some(Arc::new(self)) } diff --git a/node-graph/gcore/src/ops.rs b/node-graph/gcore/src/ops.rs index 768950418..8d95d7f8e 100644 --- a/node-graph/gcore/src/ops.rs +++ b/node-graph/gcore/src/ops.rs @@ -6,7 +6,8 @@ use crate::vector::style::GradientStops; use crate::{Color, Node}; use core::marker::PhantomData; use core::ops::{Add, Div, Mul, Rem, Sub}; -use glam::DVec2; +use dyn_any::DynAny; +use glam::{DVec2, IVec2, UVec2}; use math_parser::ast; use math_parser::context::{EvalContext, NothingMap, ValueProvider}; use math_parser::value::{Number, Value}; @@ -284,6 +285,12 @@ fn to_u64(_: impl Ctx, #[implementations(f64, f32)] value.to_u64().unwrap() } +/// Convert an integer to a decimal number of the type f64, which may be the required type for certain node inputs. This will be removed in the future when automatic type conversion is implemented. +#[node_macro::node(name("To f64"), category("Math: Numeric"))] +fn to_f64(_: impl Ctx, #[implementations(u32, u64)] value: U) -> f64 { + value.to_f64().unwrap() +} + /// The rounding function (round) maps an input value to its nearest whole number. Halfway values are rounded away from zero. #[node_macro::node(category("Math: Numeric"))] fn round(_: impl Ctx, #[implementations(f64, f32)] value: U) -> U { @@ -343,10 +350,7 @@ fn clamp( fn equals, T>( _: impl Ctx, #[implementations(f64, &f64, f32, &f32, u32, &u32, DVec2, &DVec2, &str)] value: T, - #[implementations(f64, &f64, f32, &f32, u32, &u32, DVec2, &DVec2, &str)] - #[min(100.)] - #[max(200.)] - other_value: U, + #[implementations(f64, &f64, f32, &f32, u32, &u32, DVec2, &DVec2, &str)] other_value: U, ) -> bool { other_value == value } @@ -356,10 +360,7 @@ fn equals, T>( fn not_equals, T>( _: impl Ctx, #[implementations(f64, &f64, f32, &f32, u32, &u32, DVec2, &DVec2, &str)] value: T, - #[implementations(f64, &f64, f32, &f32, u32, &u32, DVec2, &DVec2, &str)] - #[min(100.)] - #[max(200.)] - other_value: U, + #[implementations(f64, &f64, f32, &f32, u32, &u32, DVec2, &DVec2, &str)] other_value: U, ) -> bool { other_value != value } @@ -491,6 +492,32 @@ fn dot_product(_: impl Ctx, vector_a: DVec2, vector_b: DVec2) -> f64 { vector_a.dot(vector_b) } +/// Obtain the X or Y component of a vector2. +#[node_macro::node(name("Extract XY"), category("Math: Vector"))] +fn extract_xy>(_: impl Ctx, #[implementations(DVec2, IVec2, UVec2)] vector: T, axis: XY) -> f64 { + match axis { + XY::X => vector.into().x, + XY::Y => vector.into().y, + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "std", derive(specta::Type))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, DynAny)] +pub enum XY { + #[default] + X, + Y, +} +impl core::fmt::Display for XY { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + XY::X => write!(f, "X"), + XY::Y => write!(f, "Y"), + } + } +} + // TODO: Rename to "Passthrough" /// Passes-through the input value without changing it. This is useful for rerouting wires for organization purposes. #[node_macro::node(skip_impl)] diff --git a/node-graph/gcore/src/vector/algorithms/instance.rs b/node-graph/gcore/src/vector/algorithms/instance.rs new file mode 100644 index 000000000..89a08d561 --- /dev/null +++ b/node-graph/gcore/src/vector/algorithms/instance.rs @@ -0,0 +1,98 @@ +use crate::instances::Instance; +use crate::vector::{VectorData, VectorDataTable}; +use crate::{CloneVarArgs, Context, Ctx, ExtractAll, ExtractIndex, ExtractVarArgs, OwnedContextImpl}; +use glam::{DAffine2, DVec2}; + +#[node_macro::node(name("Instance on Points"), category("Vector: Shape"), path(graphene_core::vector))] +async fn instance_on_points( + ctx: impl ExtractAll + CloneVarArgs + Ctx, + points: VectorDataTable, + #[implementations(Context -> VectorDataTable)] instance_node: impl Node<'n, Context<'static>, Output = VectorDataTable>, +) -> VectorDataTable { + let mut result = VectorDataTable::empty(); + + for Instance { instance: points, transform, .. } in points.instances() { + for (index, &point) in points.point_domain.positions().iter().enumerate() { + let transformed_point = transform.transform_point2(point); + + let new_ctx = OwnedContextImpl::from(ctx.clone()).with_index(index).with_vararg(Box::new(transformed_point)); + let instanced = instance_node.eval(new_ctx.into_context()).await; + + for instanced in instanced.instances() { + let instanced = result.push_instance(instanced); + *instanced.transform *= DAffine2::from_translation(transformed_point); + } + } + } + + // TODO: Remove once we support empty tables, currently this is here to avoid crashing + if result.is_empty() { + return VectorDataTable::new(VectorData::empty()); + } + + result +} + +#[node_macro::node(category("Attributes"), path(graphene_core::vector))] +async fn instance_position(ctx: impl Ctx + ExtractVarArgs) -> DVec2 { + match ctx.vararg(0).map(|dynamic| dynamic.downcast_ref::()) { + Ok(Some(position)) => return *position, + Ok(_) => warn!("Extracted value of incorrect type"), + Err(e) => warn!("Cannot extract position vararg: {e:?}"), + } + Default::default() +} + +#[node_macro::node(category("Attributes"), path(graphene_core::vector))] +async fn instance_index(ctx: impl Ctx + ExtractIndex) -> f64 { + match ctx.try_index() { + Some(index) => return index as f64, + None => warn!("Extracted value of incorrect type"), + } + 0. +} + +#[cfg(test)] +mod test { + use super::*; + use crate::Node; + use crate::ops::ExtractXyNode; + use crate::vector::VectorData; + use bezier_rs::Subpath; + use glam::DVec2; + use std::pin::Pin; + + #[derive(Clone)] + pub struct FutureWrapperNode(T); + + impl<'i, I: Ctx, T: 'i + Clone + Send> Node<'i, I> for FutureWrapperNode { + type Output = Pin + 'i + Send>>; + fn eval(&'i self, _input: I) -> Self::Output { + let value = self.0.clone(); + Box::pin(async move { value }) + } + } + + #[tokio::test] + async fn instance_on_points_test() { + let owned = OwnedContextImpl::default().into_context(); + let rect = crate::vector::generator_nodes::RectangleNode::new( + FutureWrapperNode(()), + ExtractXyNode::new(InstancePositionNode {}, FutureWrapperNode(crate::ops::XY::Y)), + FutureWrapperNode(2_f64), + FutureWrapperNode(false), + FutureWrapperNode(0_f64), + FutureWrapperNode(false), + ); + + let positions = [DVec2::new(40., 20.), DVec2::ONE, DVec2::new(-42., 9.), DVec2::new(10., 345.)]; + let points = VectorDataTable::new(VectorData::from_subpath(Subpath::from_anchors_linear(positions, false))); + let repeated = super::instance_on_points(owned, points, &rect).await; + assert_eq!(repeated.len(), positions.len()); + for (position, instanced) in positions.into_iter().zip(repeated.instances()) { + let bounds = instanced.instance.bounding_box_with_transform(*instanced.transform).unwrap(); + assert!(position.abs_diff_eq((bounds[0] + bounds[1]) / 2., 1e-10)); + assert_eq!((bounds[1] - bounds[0]).x, position.y); + } + } +} diff --git a/node-graph/gcore/src/vector/algorithms/mod.rs b/node-graph/gcore/src/vector/algorithms/mod.rs index cf00e538f..2cd7217c0 100644 --- a/node-graph/gcore/src/vector/algorithms/mod.rs +++ b/node-graph/gcore/src/vector/algorithms/mod.rs @@ -1 +1,2 @@ +mod instance; mod merge_by_distance; diff --git a/node-graph/gcore/src/vector/generator_nodes.rs b/node-graph/gcore/src/vector/generator_nodes.rs index ad09e31d0..a80433ded 100644 --- a/node-graph/gcore/src/vector/generator_nodes.rs +++ b/node-graph/gcore/src/vector/generator_nodes.rs @@ -1,10 +1,10 @@ +use super::misc::{AsU64, GridType}; +use super::{PointId, SegmentId, StrokeId}; use crate::Ctx; use crate::vector::{HandleId, VectorData, VectorDataTable}; use bezier_rs::Subpath; use glam::DVec2; -use super::misc::AsU64; - trait CornerRadius { fn generate(self, size: DVec2, clamped: bool) -> VectorDataTable; } @@ -36,7 +36,14 @@ impl CornerRadius for [f64; 4] { } #[node_macro::node(category("Vector: Shape"))] -fn circle(_: impl Ctx, _primary: (), #[default(50.)] radius: f64) -> VectorDataTable { +fn circle( + _: impl Ctx, + _primary: (), + #[default(50.)] + #[min(0.)] + radius: f64, +) -> VectorDataTable { + let radius = radius.max(0.); VectorDataTable::new(VectorData::from_subpath(Subpath::new_ellipse(DVec2::splat(-radius), DVec2::splat(radius)))) } @@ -108,3 +115,129 @@ fn star( fn line(_: impl Ctx, _primary: (), #[default((0., -50.))] start: DVec2, #[default((0., 50.))] end: DVec2) -> VectorDataTable { VectorDataTable::new(VectorData::from_subpath(Subpath::new_line(start, end))) } + +trait GridSpacing { + fn as_dvec2(&self) -> DVec2; +} +impl GridSpacing for f64 { + fn as_dvec2(&self) -> DVec2 { + DVec2::splat(*self) + } +} +impl GridSpacing for DVec2 { + fn as_dvec2(&self) -> DVec2 { + *self + } +} + +#[node_macro::node(category("Vector: Shape"), properties("grid_properties"))] +fn grid( + _: impl Ctx, + _primary: (), + grid_type: GridType, + #[min(0.)] + #[default(10)] + #[implementations(f64, DVec2)] + spacing: T, + #[default(30., 30.)] angles: DVec2, + #[default(10)] rows: u32, + #[default(10)] columns: u32, +) -> VectorDataTable { + let (x_spacing, y_spacing) = spacing.as_dvec2().into(); + let (angle_a, angle_b) = angles.into(); + + let mut vector_data = VectorData::empty(); + let mut segment_id = SegmentId::ZERO; + let mut point_id = PointId::ZERO; + + match grid_type { + GridType::Rectangular => { + // Create rectangular grid points and connect them with line segments + for y in 0..rows { + for x in 0..columns { + // Add current point to the grid + let current_index = vector_data.point_domain.ids().len(); + vector_data.point_domain.push(point_id.next_id(), DVec2::new(x_spacing * x as f64, y_spacing * y as f64)); + + // Helper function to connect points with line segments + let mut push_segment = |to_index: Option| { + if let Some(other_index) = to_index { + vector_data + .segment_domain + .push(segment_id.next_id(), other_index, current_index, bezier_rs::BezierHandles::Linear, StrokeId::ZERO); + } + }; + + // Connect to the point to the left (horizontal connection) + push_segment((x > 0).then(|| current_index - 1)); + + // Connect to the point above (vertical connection) + push_segment(current_index.checked_sub(columns as usize)); + } + } + } + GridType::Isometric => { + // Calculate isometric grid spacing based on angles + let tan_a = angle_a.to_radians().tan(); + let tan_b = angle_b.to_radians().tan(); + let spacing = DVec2::new(y_spacing / (tan_a + tan_b), y_spacing); + + // Create isometric grid points and connect them with line segments + for y in 0..rows { + for x in 0..columns { + // Add current point to the grid with offset for odd columns + let current_index = vector_data.point_domain.ids().len(); + vector_data + .point_domain + .push(point_id.next_id(), DVec2::new(spacing.x * x as f64, spacing.y * (y as f64 - (x % 2) as f64 * 0.5))); + + // Helper function to connect points with line segments + let mut push_segment = |to_index: Option| { + if let Some(other_index) = to_index { + vector_data + .segment_domain + .push(segment_id.next_id(), other_index, current_index, bezier_rs::BezierHandles::Linear, StrokeId::ZERO); + } + }; + + // Connect to the point to the left + push_segment((x > 0).then(|| current_index - 1)); + + // Connect to the point directly above + push_segment(current_index.checked_sub(columns as usize)); + + // Additional diagonal connections for odd columns (creates hexagonal pattern) + if x % 2 == 1 { + // Connect to the point diagonally up-right (if not at right edge) + push_segment(current_index.checked_sub(columns as usize - 1).filter(|_| x + 1 < columns)); + + // Connect to the point diagonally up-left + push_segment(current_index.checked_sub(columns as usize + 1)); + } + } + } + } + } + + VectorDataTable::new(vector_data) +} + +#[test] +fn isometric_grid_test() { + // Doesn't crash with weird angles + grid((), (), GridType::Isometric, 0., (0., 0.).into(), 5, 5); + grid((), (), GridType::Isometric, 90., (90., 90.).into(), 5, 5); + + // Works properly + let grid = grid((), (), GridType::Isometric, 10., (30., 30.).into(), 5, 5); + assert_eq!(grid.one_instance().instance.point_domain.ids().len(), 5 * 5); + assert_eq!(grid.one_instance().instance.segment_bezier_iter().count(), 4 * 5 + 4 * 9); + for (_, bezier, _, _) in grid.one_instance().instance.segment_bezier_iter() { + assert_eq!(bezier.handles, bezier_rs::BezierHandles::Linear); + assert!( + ((bezier.start - bezier.end).length() - 10.).abs() < 1e-5, + "Length of {} should be 10", + (bezier.start - bezier.end).length() + ); + } +} diff --git a/node-graph/gcore/src/vector/misc.rs b/node-graph/gcore/src/vector/misc.rs index 476963b4b..a63988a06 100644 --- a/node-graph/gcore/src/vector/misc.rs +++ b/node-graph/gcore/src/vector/misc.rs @@ -85,3 +85,10 @@ impl AsI64 for f64 { *self as i64 } } + +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, specta::Type)] +pub enum GridType { + #[default] + Rectangular, + Isometric, +} diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index dc9617a73..7dfa76718 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -202,6 +202,7 @@ tagged_value! { VecU64(Vec), NodePath(Vec), VecDVec2(Vec), + XY(graphene_core::ops::XY), RedGreenBlue(graphene_core::raster::RedGreenBlue), RealTimeMode(graphene_core::animation::RealTimeMode), RedGreenBlueAlpha(graphene_core::raster::RedGreenBlueAlpha), @@ -212,6 +213,7 @@ tagged_value! { DomainWarpType(graphene_core::raster::DomainWarpType), RelativeAbsolute(graphene_core::raster::RelativeAbsolute), SelectiveColorChoice(graphene_core::raster::SelectiveColorChoice), + GridType(graphene_core::vector::misc::GridType), LineCap(graphene_core::vector::style::LineCap), LineJoin(graphene_core::vector::style::LineJoin), FillType(graphene_core::vector::style::FillType),