Improve type handling for ints/floats with the #[hard/soft_min/max(...)] node macro parameter attributes (#4041)

This commit is contained in:
Keavon Chambers 2026-04-23 15:49:30 -07:00 committed by GitHub
parent fcf9396a71
commit f42d12da9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 114 additions and 19 deletions

View File

@ -1673,7 +1673,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
// async fn morph(_: impl Ctx, source: Table<Vector>, #[expose] target: Table<Vector>, #[default(0.5)] time: Fraction) -> Table<Vector> { ... }
//
// 4 inputs - even older signature (commit 80b8df8d4298b6669f124b929ce61bfabfc44e41):
// async fn morph(_: impl Ctx, source: Table<Vector>, #[expose] target: Table<Vector>, #[default(0.5)] time: Fraction, #[min(0.)] start_index: IntegerCount) -> Table<Vector> { ... }
// async fn morph(_: impl Ctx, source: Table<Vector>, #[expose] target: Table<Vector>, #[default(0.5)] time: Fraction, start_index: u32) -> Table<Vector> { ... }
//
// v2 signature:
// async fn morph<I: IntoGraphicTable>(_: impl Ctx, #[implementations(Table<Graphic>, Table<Vector>)] content: I, progression: Progression) -> Table<Vector> { ... }

View File

@ -23,8 +23,6 @@ pub mod types {
pub type Progression = f64;
/// Signed integer that's actually a float because we don't handle type conversions very well yet
pub type SignedInteger = f64;
/// Unsigned integer
pub type IntegerCount = u32;
/// Unsigned integer to be used for random seeds
pub type SeedValue = u32;
/// DVec2 with px unit

View File

@ -1,7 +1,7 @@
use convert_case::{Case, Casing};
use indoc::{formatdoc, indoc};
use proc_macro2::TokenStream as TokenStream2;
use quote::{ToTokens, format_ident};
use quote::{ToTokens, format_ident, quote};
use syn::parse::{Parse, ParseStream, Parser};
use syn::punctuated::Punctuated;
use syn::spanned::Spanned;
@ -123,6 +123,60 @@ pub enum ParsedFieldType {
Node(NodeParsedField),
}
/// A numeric bound value accepted by attributes like `#[soft_min]`, `#[hard_min]`, `#[soft_max]`, and `#[hard_max]`.
/// Accepts both integer literals (e.g. `1`, `-1`) and float literals (e.g. `1.`, `-500.`).
#[derive(Clone, Debug)]
pub struct NumberBound {
is_negative: bool,
literal: NumberBoundLiteral,
}
#[derive(Clone, Debug)]
enum NumberBoundLiteral {
Float(LitFloat),
Int(LitInt),
}
impl NumberBound {
pub fn to_f64(&self) -> f64 {
let magnitude = match &self.literal {
NumberBoundLiteral::Float(lit) => lit.base10_parse::<f64>().unwrap_or_default(),
NumberBoundLiteral::Int(lit) => lit.base10_parse::<u64>().unwrap_or_default() as f64,
};
if self.is_negative { -magnitude } else { magnitude }
}
}
impl Parse for NumberBound {
fn parse(input: ParseStream) -> syn::Result<Self> {
let is_negative = input.peek(syn::Token![-]);
if is_negative {
let _: syn::Token![-] = input.parse()?;
}
let literal = if input.peek(LitFloat) {
NumberBoundLiteral::Float(input.parse()?)
} else if input.peek(LitInt) {
NumberBoundLiteral::Int(input.parse()?)
} else {
return Err(input.error("expected a numeric literal (integer or float)"));
};
Ok(NumberBound { is_negative, literal })
}
}
impl ToTokens for NumberBound {
fn to_tokens(&self, stream: &mut TokenStream2) {
match (&self.literal, self.is_negative) {
(NumberBoundLiteral::Float(lit), false) => lit.to_tokens(stream),
(NumberBoundLiteral::Float(lit), true) => stream.extend(quote!(-#lit)),
(NumberBoundLiteral::Int(lit), false) => stream.extend(quote!(#lit as f64)),
(NumberBoundLiteral::Int(lit), true) => stream.extend(quote!(-(#lit as f64))),
}
}
}
/// a param of any kind, either a concrete type or a generic type with a set of possible types specified via
/// `#[implementation(type)]`
#[derive(Clone, Debug)]
@ -130,10 +184,10 @@ pub struct RegularParsedField {
pub ty: Type,
pub exposed: bool,
pub value_source: ParsedValueSource,
pub number_soft_min: Option<LitFloat>,
pub number_soft_max: Option<LitFloat>,
pub number_hard_min: Option<LitFloat>,
pub number_hard_max: Option<LitFloat>,
pub number_soft_min: Option<NumberBound>,
pub number_soft_max: Option<NumberBound>,
pub number_hard_min: Option<NumberBound>,
pub number_hard_max: Option<NumberBound>,
pub number_mode_range: Option<ExprTuple>,
pub implementations: Punctuated<Type, Comma>,
pub gpu_image: bool,
@ -722,6 +776,29 @@ fn parse_field(pat_ident: PatIdent, ty: Type, attrs: &[Attribute]) -> syn::Resul
.map(|attr| parse_implementations(attr, ident))
.transpose()?
.unwrap_or_default();
// Error if a float literal is given for a bound attribute on an integer-typed field
if is_integer_type(&ty) {
let bound_attrs = [
(&number_soft_min, "soft_min"),
(&number_hard_min, "hard_min"),
(&number_soft_max, "soft_max"),
(&number_hard_max, "hard_max"),
];
for (bound, attr_name) in bound_attrs {
if let Some(NumberBound {
literal: NumberBoundLiteral::Float(_),
..
}) = bound
{
return Err(Error::new_spanned(
&pat_ident,
format!("Attribute `#[{attr_name}]` on `{ident}` has a float literal, but `{ident}` is an integer type. Use an integer literal without a decimal point."),
));
}
}
}
Ok(ParsedField {
pat_ident,
ty: ParsedFieldType::Regular(RegularParsedField {
@ -769,6 +846,15 @@ fn parse_node_type(ty: &Type) -> (bool, Option<Type>, Option<Type>) {
(false, None, None)
}
fn is_integer_type(ty: &Type) -> bool {
let Type::Path(type_path) = ty else { return false };
let Some(segment) = type_path.path.segments.last() else { return false };
matches!(
segment.ident.to_string().as_str(),
"u8" | "u16" | "u32" | "u64" | "u128" | "usize" | "i8" | "i16" | "i32" | "i64" | "i128" | "isize"
)
}
fn parse_output(output: &ReturnType) -> syn::Result<Type> {
match output {
ReturnType::Default => Ok(syn::parse_quote!(())),

View File

@ -34,8 +34,8 @@ fn validate_min_max(parsed: &ParsedNodeFn) {
} = field
{
if let (Some(soft_min), Some(hard_min)) = (number_soft_min, number_hard_min) {
let soft_min_value: f64 = soft_min.base10_parse().unwrap_or_default();
let hard_min_value: f64 = hard_min.base10_parse().unwrap_or_default();
let soft_min_value: f64 = soft_min.to_f64();
let hard_min_value: f64 = hard_min.to_f64();
if soft_min_value == hard_min_value {
emit_error!(
pat_ident.span(),
@ -56,8 +56,8 @@ fn validate_min_max(parsed: &ParsedNodeFn) {
}
if let (Some(soft_max), Some(hard_max)) = (number_soft_max, number_hard_max) {
let soft_max_value: f64 = soft_max.base10_parse().unwrap_or_default();
let hard_max_value: f64 = hard_max.base10_parse().unwrap_or_default();
let soft_max_value: f64 = soft_max.to_f64();
let hard_max_value: f64 = hard_max.to_f64();
if soft_max_value == hard_max_value {
emit_error!(
pat_ident.span(),

View File

@ -962,7 +962,7 @@ fn posterize<T: Adjust<Color>>(
#[gpu_image]
mut input: T,
#[default(4)]
#[hard_min(2.)]
#[hard_min(2)]
levels: u32,
) -> T {
input.adjust(|color| {

View File

@ -1,11 +1,16 @@
use core_types::color::Color;
use core_types::context::Ctx;
use core_types::registry::types::IntegerCount;
use core_types::table::{Table, TableRow};
use raster_types::{CPU, Raster};
#[node_macro::node(category("Color"))]
async fn image_color_palette(_: impl Ctx, image: Table<Raster<CPU>>, #[default(4)] count: IntegerCount) -> Table<Color> {
async fn image_color_palette(
_: impl Ctx,
image: Table<Raster<CPU>>,
#[default(4)]
#[hard_min(1)]
count: u32,
) -> Table<Color> {
const GRID: f32 = 3.;
let bins = GRID * GRID * GRID;

View File

@ -1,6 +1,6 @@
use crate::gcore::Context;
use core::f64::consts::TAU;
use core_types::registry::types::{Angle, IntegerCount, PixelSize};
use core_types::registry::types::{Angle, PixelSize};
use core_types::table::{Table, TableRowRef};
use core_types::{CloneVarArgs, Color, Ctx, ExtractAll, InjectVarArgs, OwnedContextImpl};
use glam::{DAffine2, DVec2};
@ -19,7 +19,9 @@ async fn repeat<T: Into<Graphic> + Default + Send + Clone + 'static>(
Context -> Table<GradientStops>,
)]
instance: impl Node<'n, Context<'static>, Output = Table<T>>,
#[default(1)] count: u64,
#[default(1)]
#[hard_min(1)]
count: u32,
reverse: bool,
) -> Table<T> {
// Someday this node can have the option to generate infinitely instead of a fixed count (basically `std::iter::repeat`).
@ -57,7 +59,9 @@ pub async fn repeat_array<T: Into<Graphic> + Default + Send + Clone + 'static>(
// TODO: When using a custom Properties panel layout in document_node_definitions.rs and this default is set, the widget weirdly doesn't show up in the Properties panel. Investigation is needed.
direction: PixelSize,
angle: Angle,
#[default(5)] count: IntegerCount,
#[default(5)]
#[hard_min(1)]
count: u32,
) -> Table<T> {
let angle = angle.to_radians();
let count = count.max(1);
@ -102,7 +106,9 @@ async fn repeat_radial<T: Into<Graphic> + Default + Send + Clone + 'static>(
#[unit(" px")]
#[default(5)]
radius: f64,
#[default(5)] count: IntegerCount,
#[default(5)]
#[hard_min(1)]
count: u32,
) -> Table<T> {
let count = count.max(1);