Add the first field-based nodes: 'Instance on Points', 'Instance Position', 'Instance Index', as well as 'Grid' (#2574)

* Basic fields

* Add 'Extract XY' and 'Split Vector2' nodes

* Add 'Instance Index' node

* Fix test again

* Improve grid generator to support rectangular as well

* Avoid crashing

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
James Lindsay
2025-04-16 12:58:59 +01:00
committed by GitHub
parent 184c009f17
commit 435a6daf25
12 changed files with 515 additions and 26 deletions

View File

@@ -328,6 +328,15 @@ impl OwnedContextImpl {
self.animation_time = Some(animation_time);
self
}
pub fn with_vararg(mut self, value: Box<dyn Any + Send + Sync>) -> 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<Arc<Self>> {
Some(Arc::new(self))
}

View File

@@ -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<U: num_traits::float::Float>(_: 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<U: num_traits::int::PrimInt>(_: 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<U: num_traits::float::Float>(_: impl Ctx, #[implementations(f64, f32)] value: U) -> U {
@@ -343,10 +350,7 @@ fn clamp<T: core::cmp::PartialOrd>(
fn equals<U: core::cmp::PartialEq<T>, 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<U: core::cmp::PartialEq<T>, T>(
fn not_equals<U: core::cmp::PartialEq<T>, 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<T: Into<DVec2>>(_: 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)]

View File

@@ -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::<DVec2>()) {
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: Clone>(T);
impl<'i, I: Ctx, T: 'i + Clone + Send> Node<'i, I> for FutureWrapperNode<T> {
type Output = Pin<Box<dyn core::future::Future<Output = T> + '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);
}
}
}

View File

@@ -1 +1,2 @@
mod instance;
mod merge_by_distance;

View File

@@ -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<T: AsU64>(
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<T: GridSpacing>(
_: 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<usize>| {
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<usize>| {
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()
);
}
}

View File

@@ -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,
}

View File

@@ -202,6 +202,7 @@ tagged_value! {
VecU64(Vec<u64>),
NodePath(Vec<NodeId>),
VecDVec2(Vec<DVec2>),
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),