From 8b9aac976ecebf1c5679e880401281fd9b2c29a5 Mon Sep 17 00:00:00 2001 From: Timon Date: Fri, 23 Oct 2026 19:37:03 +0000 Subject: [PATCH 1/8] Fix crashes and report errors in math nodes --- Cargo.lock | 2 +- node-graph/nodes/math/Cargo.toml | 2 +- node-graph/nodes/math/src/lib.rs | 791 +++++++++++++++++++++++-------- 3 files changed, 607 insertions(+), 188 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 714057d9830..44f6b20bc0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3611,10 +3611,10 @@ dependencies = [ "glam 0.33.10", "graphene-core", "graphic-types", - "log", "math-parser", "node-macro", "rand", + "thiserror 2.0.18", "vector-types", ] diff --git a/node-graph/nodes/math/Cargo.toml b/node-graph/nodes/math/Cargo.toml index d7f41cc21be..0526269720e 100644 --- a/node-graph/nodes/math/Cargo.toml +++ b/node-graph/nodes/math/Cargo.toml @@ -26,4 +26,4 @@ dyn-any = { workspace = true } glam = { workspace = true } rand = { workspace = true } math-parser = { workspace = true } -log = { workspace = true } +thiserror = { workspace = true } diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 60d2b326820..593f7f9d93c 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -1,4 +1,3 @@ -use core_types::FallibleVec2Operations; use core_types::context::{CloneVarArgs, ExtractAll}; use core_types::list::{Bundle, Item, List}; use core_types::transform::Footprint; @@ -7,63 +6,88 @@ use core_types::{Context, Interrupt}; use glam::{DAffine2, DMat2, DVec2}; use graphic_types::raster_types::{CPU, GPU, Raster}; use graphic_types::{Artboard, Graphic, Vector}; -use log::warn; use math_parser::ast; use math_parser::context::{EvalContext, NothingMap, ValueProvider}; +use math_parser::executer::EvalError; use math_parser::lexer::Constant; use math_parser::matrix::{Affine2, Linear2, Matrix}; use math_parser::object::Object; +use math_parser::parser::{MessagePart, ParseError}; use math_parser::reducer::classify_reducer; use math_parser::value::{Value, Vector2, Vector3}; use rand::{Rng, SeedableRng}; -use std::ops::{Add, Mul, Rem, Sub}; use std::sync::{Arc, Mutex, PoisonError}; use vector_types::Gradient; -/// A parsed source and its tree, which an invalid source lacks. -type ParsedSource = Option<(String, Option>)>; +/// Why the "Math f(x)" or "Math f(…)" node's expression gives no result. +#[derive(Debug, thiserror::Error)] +pub enum MathExpressionError { + #[error("The expression is empty")] + Empty, + #[error("The expression is invalid: {0}")] + Invalid(String), + #[error(transparent)] + Evaluation(#[from] EvalError), + #[error("The result {result} can't be read as {output_type}")] + OutputType { result: String, output_type: String }, + #[error("The input can't be read as {0}")] + InputType(String), + #[error("`{reducer}` gives no result for an empty list")] + EmptyReduction { reducer: String }, + #[error("`{reducer}` gives no result across these items")] + NoReduction { reducer: String }, +} + +/// A parse error's messages without the byte ranges they point at, which only mean something beside the source. +fn parse_error_message(error: &ParseError) -> String { + let message = |parts: &[MessagePart]| { + parts + .iter() + .map(|part| match part { + MessagePart::Text(text) => text.clone(), + MessagePart::Code(code) => format!("`{code}`"), + }) + .collect::() + }; + error.messages().iter().map(|error| message(error.parts())).collect::>().join("; ") +} + +/// A parsed source and its tree, or the message of why it doesn't parse. +type ParsedSource = Option<(String, Result, String>)>; /// The last expression a node parsed, reused while its source stays the same, so a list of thousands of items or a run of -/// frames parses once. An invalid source is remembered too, so it is logged once rather than per item. +/// frames parses once. An invalid source is remembered too, so it is parsed once rather than per item. #[derive(Debug, Clone, Default)] pub struct ParseCache(Arc>); impl ParseCache { - /// The parse tree of `source`, or `None` for an invalid math expression. - fn parse(&self, source: &str) -> Option> { + /// The parse tree of `source`, or why it isn't a valid math expression. + fn parse(&self, source: &str) -> Result, MathExpressionError> { + if source.trim().is_empty() { + return Err(MathExpressionError::Empty); + } + // A lock poisoned by a panic elsewhere still guards a usable cache let mut cached = self.0.lock().unwrap_or_else(PoisonError::into_inner); if let Some((cached_source, tree)) = cached.as_ref() && cached_source == source { - return tree.clone(); + return tree.clone().map_err(MathExpressionError::Invalid); } - let tree = match ast::Node::try_parse_from_str(source) { - Ok(tree) => Some(Arc::new(tree)), - Err(error) => { - warn!("Invalid math expression: `{source}`\n{error}"); - None - } - }; + let tree = ast::Node::try_parse_from_str(source).map(Arc::new).map_err(|error| parse_error_message(&error)); *cached = Some((source.to_string(), tree.clone())); - tree + tree.map_err(MathExpressionError::Invalid) } } -/// Evaluates a parsed expression with the given variable bindings, logging and returning `None` on failure. -fn evaluate_expression(expression: &ast::Node, provider: impl ValueProvider) -> Option { - match expression.eval(&EvalContext::new(provider, NothingMap)) { - Ok(object) => Some(object), - Err(error) => { - warn!("Expression evaluation error: {error:?}"); - None - } - } +/// Evaluates a parsed expression with the given variable bindings. +fn evaluate_expression(expression: &ast::Node, provider: impl ValueProvider) -> Result { + Ok(expression.eval(&EvalContext::new(provider, NothingMap))?) } /// Converts a node item type to and from the objects, values or matrices, the expression evaluator runs on. -trait ExpressionValue: Copy + Default { +trait ExpressionValue: Copy + Default + 'static { fn into_object(self) -> Object; /// Reads an evaluated result as this type, seeing only the parts this type has, or `None` when it does not fit, like 0.5 read as a Bool or a matrix read as a Number. fn from_object(object: &Object) -> Option; @@ -87,7 +111,7 @@ impl Items { } /// A node item type that is a value in the expression language, so a list of them binds positionally. -trait ExpressionItem: Copy + Default { +trait ExpressionItem: Copy + Default + 'static { /// Binds this value into the expression language, exactly for an integer. fn into_value(self) -> Value; fn from_value(value: &Value) -> Option; @@ -119,17 +143,12 @@ impl ExpressionItem for f64 { } } -/// Reads an expression's result into the node's output type, warning and falling back to the type's default when it does not fit. -fn output(result: Option) -> T { - result - .and_then(|object| { - let output = T::from_object(&object); - if output.is_none() { - warn!("The expression's result {object} does not fit the output type"); - } - output - }) - .unwrap_or_default() +/// Reads an expression's result as the node's output type. +fn output(result: &Object) -> Result { + T::from_object(result).ok_or_else(|| MathExpressionError::OutputType { + result: result.to_string(), + output_type: core_types::concrete!(T).to_string(), + }) } impl ExpressionItem for i64 { @@ -194,11 +213,11 @@ impl ExpressionValue for DAffine2 { } /// Supplies the value of `x`, or the matrix of `X`, for the "Math f(x)" node's expression. -struct SingleVariableMathContext { - x: Object, +struct SingleVariableMathContext<'a> { + x: &'a Object, } -impl ValueProvider for SingleVariableMathContext { +impl ValueProvider for SingleVariableMathContext<'_> { fn get_value(&self, name: &str) -> Option { // Bound by exact spelling, per the language's rule that a binding shadows the builtin of exactly its spelling (name == "x").then(|| self.x.as_value().copied()).flatten() @@ -233,7 +252,7 @@ fn math_fx( #[name("Output Type")] output_type: Item, #[data] parsed: ParseCache, -) -> Item { +) -> Result, (Item, MathExpressionError)> { // The output type input is a type witness: its type selects the implementation, and its value is never read let _ = output_type; @@ -243,9 +262,19 @@ fn math_fx( // The default `x` passes any input through, including a Transform, which an expression names `X` let source = if x.as_matrix().is_some() && fx.element().trim() == "x" { "X" } else { fx.element() }; - let result = output(parsed.parse(source).and_then(|expression| evaluate_expression(&expression, SingleVariableMathContext { x }))); + let result = parsed + .parse(source) + .and_then(|expression| evaluate_expression(&expression, SingleVariableMathContext { x: &x })) + .and_then(|result| output(&result)); - Item::from_parts(result, attributes) + match result { + Ok(result) => Ok(Item::from_parts(result, attributes)), + // The input passes through as the default `x` reads it, unless the output type can't hold it + Err(error) => match U::from_object(&x) { + Some(input) => Err((Item::from_parts(input, attributes), error)), + None => Err((Item::from_parts(U::default(), attributes), MathExpressionError::InputType(core_types::concrete!(U).to_string()))), + }, + } } /// Binds the items of the "Math f(…)" node's list to the positional variables `a`, `b`, `c`, and so on, or `A`, `B`, `C` for matrices. @@ -308,7 +337,7 @@ fn math_f( #[name("Output Type")] output_type: Item, #[data] parsed: ParseCache, -) -> Item { +) -> Result, MathExpressionError> { // The output type input is a type witness: its type selects the implementation, and its value is never read let _ = output_type; @@ -324,19 +353,143 @@ fn math_f( Items::Matrices(matrices) => reducer.evaluate_matrices(matrices), }; let Some(result) = result else { - warn!("The `{expression}` reducer cannot be applied to {} items", bindings.items.len()); - return Item::new_from_element(U::default()); + let name = expression.trim(); + return match bindings.items.len() { + // Like the dedicated nodes, the minimum, maximum and mean of no values are 0 + 0 if matches!(bindings.items, Items::Values(_)) && matches!(name.trim_start_matches('\\'), "min" | "max" | "mean") => Ok(Item::new_from_element(U::default())), + 0 => Err(MathExpressionError::EmptyReduction { reducer: name.to_string() }), + _ => Err(MathExpressionError::NoReduction { reducer: name.to_string() }), + }; }; - return Item::new_from_element(output(Some(result))); + return Ok(Item::new_from_element(output(&result)?)); + } + + let tree = parsed.parse(expression)?; + let result = evaluate_expression(&tree, bindings)?; + Ok(Item::new_from_element(output(&result)?)) +} + +/// An Integer result too large or too small for the Integer type to hold. +#[derive(Debug, thiserror::Error)] +pub enum IntegerOverflowError { + #[error("The result is outside the Integer range")] + OutOfRange, +} + +pub trait TryAdd { + type Output; + fn try_add(self, addend: Rhs) -> Result; +} +impl TryAdd for f64 { + type Output = f64; + fn try_add(self, addend: f64) -> Result { + Ok(self + addend) + } +} +impl TryAdd for i64 { + type Output = i64; + fn try_add(self, addend: i64) -> Result { + self.checked_add(addend).ok_or(IntegerOverflowError::OutOfRange) + } +} +impl TryAdd for DVec2 { + type Output = DVec2; + fn try_add(self, addend: DVec2) -> Result { + Ok(self + addend) + } +} +impl TryAdd for DVec2 { + type Output = DVec2; + fn try_add(self, addend: f64) -> Result { + Ok(self + addend) + } +} +impl TryAdd for f64 { + type Output = DVec2; + fn try_add(self, addend: DVec2) -> Result { + Ok(self + addend) + } +} + +pub trait TrySub { + type Output; + fn try_sub(self, subtrahend: Rhs) -> Result; +} +impl TrySub for f64 { + type Output = f64; + fn try_sub(self, subtrahend: f64) -> Result { + Ok(self - subtrahend) + } +} +impl TrySub for i64 { + type Output = i64; + fn try_sub(self, subtrahend: i64) -> Result { + self.checked_sub(subtrahend).ok_or(IntegerOverflowError::OutOfRange) + } +} +impl TrySub for DVec2 { + type Output = DVec2; + fn try_sub(self, subtrahend: DVec2) -> Result { + Ok(self - subtrahend) + } +} +impl TrySub for DVec2 { + type Output = DVec2; + fn try_sub(self, subtrahend: f64) -> Result { + Ok(self - subtrahend) + } +} +impl TrySub for f64 { + type Output = DVec2; + fn try_sub(self, subtrahend: DVec2) -> Result { + Ok(self - subtrahend) } +} - let result = output(parsed.parse(expression).and_then(|expression| evaluate_expression(&expression, bindings))); - Item::new_from_element(result) +pub trait TryMul { + type Output; + fn try_mul(self, multiplicand: Rhs) -> Result; +} +impl TryMul for f64 { + type Output = f64; + fn try_mul(self, multiplicand: f64) -> Result { + Ok(self * multiplicand) + } +} +impl TryMul for i64 { + type Output = i64; + fn try_mul(self, multiplicand: i64) -> Result { + self.checked_mul(multiplicand).ok_or(IntegerOverflowError::OutOfRange) + } +} +impl TryMul for DVec2 { + type Output = DVec2; + fn try_mul(self, multiplicand: DVec2) -> Result { + Ok(self * multiplicand) + } +} +impl TryMul for DVec2 { + type Output = DVec2; + fn try_mul(self, multiplicand: f64) -> Result { + Ok(self * multiplicand) + } +} +impl TryMul for f64 { + type Output = DVec2; + fn try_mul(self, multiplicand: DVec2) -> Result { + Ok(self * multiplicand) + } +} +impl TryMul for DAffine2 { + type Output = DAffine2; + fn try_mul(self, multiplicand: DAffine2) -> Result { + Ok(self * multiplicand) + } } /// The addition operation (`+`) calculates the sum of two scalar numbers or vec2s. #[node_macro::node(category("Math: Arithmetic"), icon("NodeAdd"))] -fn add, B>( +fn add, B>( _: impl Ctx, /// The left-hand side of the addition operation. #[implementations(f64, i64, DVec2, f64, DVec2)] @@ -344,15 +497,15 @@ fn add, B>( /// The right-hand side of the addition operation. #[implementations(f64, i64, DVec2, DVec2, f64)] addend: Item, -) -> Item<>::Output> { +) -> Result>::Output>, IntegerOverflowError> { let (augend, attributes) = augend.into_parts(); - Item::from_parts(augend + addend.into_element(), attributes) + Ok(Item::from_parts(augend.try_add(addend.into_element())?, attributes)) } /// The subtraction operation (`-`) calculates the difference between two scalar numbers or vec2s. #[node_macro::node(category("Math: Arithmetic"), icon("NodeSubtract"))] -fn subtract, B>( +fn subtract, B>( _: impl Ctx, /// The left-hand side of the subtraction operation. #[implementations(f64, i64, DVec2, f64, DVec2)] @@ -360,15 +513,15 @@ fn subtract, B>( /// The right-hand side of the subtraction operation. #[implementations(f64, i64, DVec2, DVec2, f64)] subtrahend: Item, -) -> Item<>::Output> { +) -> Result>::Output>, IntegerOverflowError> { let (minuend, attributes) = minuend.into_parts(); - Item::from_parts(minuend - subtrahend.into_element(), attributes) + Ok(Item::from_parts(minuend.try_sub(subtrahend.into_element())?, attributes)) } /// The multiplication operation (`×`) calculates the product of two scalar numbers, vec2s, or transforms. #[node_macro::node(category("Math: Arithmetic"), icon("NodeMultiply"))] -fn multiply, B>( +fn multiply, B>( _: impl Ctx, /// The left-hand side of the multiplication operation. #[implementations(f64, i64, DVec2, f64, DVec2, DAffine2)] @@ -377,44 +530,47 @@ fn multiply, B>( #[default(1.)] #[implementations(f64, i64, DVec2, DVec2, f64, DAffine2)] multiplicand: Item, -) -> Item<>::Output> { +) -> Result>::Output>, IntegerOverflowError> { let (multiplier, attributes) = multiplier.into_parts(); - Item::from_parts(multiplier * multiplicand.into_element(), attributes) + Ok(Item::from_parts(multiplier.try_mul(multiplicand.into_element())?, attributes)) } pub trait SafeDivide { type Output; - fn safe_divide(self, denominator: Rhs) -> Self::Output; + fn safe_divide(self, denominator: Rhs) -> Result; } impl SafeDivide for f64 { type Output = f64; - fn safe_divide(self, denominator: f64) -> f64 { - if denominator == 0. { 0. } else { self / denominator } + fn safe_divide(self, denominator: f64) -> Result { + Ok(if denominator == 0. { 0. } else { self / denominator }) } } impl SafeDivide for i64 { type Output = i64; - fn safe_divide(self, denominator: i64) -> i64 { - self.checked_div(denominator).unwrap_or(0) + fn safe_divide(self, denominator: i64) -> Result { + if denominator == 0 { + return Ok(0); + } + self.checked_div(denominator).ok_or(IntegerOverflowError::OutOfRange) } } impl SafeDivide for DVec2 { type Output = DVec2; - fn safe_divide(self, denominator: DVec2) -> DVec2 { - DVec2::new(self.x.safe_divide(denominator.x), self.y.safe_divide(denominator.y)) + fn safe_divide(self, denominator: DVec2) -> Result { + Ok(DVec2::new(self.x.safe_divide(denominator.x)?, self.y.safe_divide(denominator.y)?)) } } impl SafeDivide for DVec2 { type Output = DVec2; - fn safe_divide(self, denominator: f64) -> DVec2 { - DVec2::new(self.x.safe_divide(denominator), self.y.safe_divide(denominator)) + fn safe_divide(self, denominator: f64) -> Result { + Ok(DVec2::new(self.x.safe_divide(denominator)?, self.y.safe_divide(denominator)?)) } } impl SafeDivide for f64 { type Output = DVec2; - fn safe_divide(self, denominator: DVec2) -> DVec2 { - DVec2::new(self.safe_divide(denominator.x), self.safe_divide(denominator.y)) + fn safe_divide(self, denominator: DVec2) -> Result { + Ok(DVec2::new(self.safe_divide(denominator.x)?, self.safe_divide(denominator.y)?)) } } @@ -431,10 +587,10 @@ fn divide, B>( #[default(1.)] #[implementations(f64, i64, DVec2, f64, DVec2)] denominator: Item, -) -> Item<>::Output> { +) -> Result>::Output>, IntegerOverflowError> { let (numerator, attributes) = numerator.into_parts(); - Item::from_parts(numerator.safe_divide(denominator.into_element()), attributes) + Ok(Item::from_parts(numerator.safe_divide(denominator.into_element())?, attributes)) } trait Componentwise { @@ -466,11 +622,72 @@ fn reciprocal( Item::from_parts(value.componentwise(|value| if value == 0. { 0. } else { 1. / value }), attributes) } +/// Why a modulo has no result. +#[derive(Debug, thiserror::Error)] +pub enum ModuloError { + #[error("The modulus can't be 0")] + ZeroModulus, +} + +pub trait Modulo { + type Output; + fn modulo(self, modulus: Rhs, always_positive: bool) -> Result; +} + +fn scalar_modulo(numerator: f64, modulus: f64, always_positive: bool) -> Result { + if modulus == 0. { + return Err(ModuloError::ZeroModulus); + } + Ok(if always_positive { (numerator % modulus + modulus) % modulus } else { numerator % modulus }) +} +impl Modulo for f64 { + type Output = f64; + fn modulo(self, modulus: f64, always_positive: bool) -> Result { + scalar_modulo(self, modulus, always_positive) + } +} +impl Modulo for i64 { + type Output = i64; + fn modulo(self, modulus: i64, always_positive: bool) -> Result { + if modulus == 0 { + return Err(ModuloError::ZeroModulus); + } + + // Only `i64::MIN % -1` wraps, to its true remainder of 0 + let remainder = self.wrapping_rem(modulus); + + // Adding the modulus only to a remainder of the opposite sign gives it the modulus's sign without overflowing + Ok(if always_positive && remainder != 0 && (remainder < 0) != (modulus < 0) { + remainder + modulus + } else { + remainder + }) + } +} +impl Modulo for DVec2 { + type Output = DVec2; + fn modulo(self, modulus: DVec2, always_positive: bool) -> Result { + Ok(DVec2::new(scalar_modulo(self.x, modulus.x, always_positive)?, scalar_modulo(self.y, modulus.y, always_positive)?)) + } +} +impl Modulo for DVec2 { + type Output = DVec2; + fn modulo(self, modulus: f64, always_positive: bool) -> Result { + Ok(DVec2::new(scalar_modulo(self.x, modulus, always_positive)?, scalar_modulo(self.y, modulus, always_positive)?)) + } +} +impl Modulo for f64 { + type Output = DVec2; + fn modulo(self, modulus: DVec2, always_positive: bool) -> Result { + Ok(DVec2::new(scalar_modulo(self, modulus.x, always_positive)?, scalar_modulo(self, modulus.y, always_positive)?)) + } +} + /// The modulo operation (`%`) calculates the remainder from the division of two scalar numbers or vec2s. /// -/// The sign of the result shares the sign of the numerator unless *Always Positive* is enabled. +/// The sign of the result shares the sign of the numerator, or of the modulus if *Always Positive* is enabled. #[node_macro::node(category("Math: Arithmetic"), icon("NodeModulo"))] -fn modulo>>, B: Copy>( +fn modulo, B>( _: impl Ctx, /// The left-hand side of the modulo operation. #[implementations(f64, i64, DVec2, DVec2, f64)] @@ -479,59 +696,59 @@ fn modulo>>, B: Copy #[default(2.)] #[implementations(f64, i64, DVec2, f64, DVec2)] modulus: Item, - /// Ensures the result is always positive, even if the numerator is negative. + /// Ensures the result is positive for a positive modulus, even if the numerator is negative. #[default(true)] always_positive: Item, -) -> Item<>::Output> { +) -> Result>::Output>, ModuloError> { let (numerator, attributes) = numerator.into_parts(); - let (modulus, always_positive) = (*modulus.element(), *always_positive.element()); - let result = if always_positive { (numerator % modulus + modulus) % modulus } else { numerator % modulus }; - Item::from_parts(result, attributes) + Ok(Item::from_parts(numerator.modulo(modulus.into_element(), *always_positive.element())?, attributes)) } pub trait Exponent { type Output; - fn power(self, power: Rhs) -> Self::Output; + fn power(self, power: Rhs) -> Result; } impl Exponent for f64 { type Output = f64; - fn power(self, power: f64) -> f64 { - self.powf(power) + fn power(self, power: f64) -> Result { + Ok(self.powf(power)) } } impl Exponent for i64 { type Output = i64; - // Only ±1 stay representable once the exponent is negative or too large, so everything else falls to 0 - fn power(self, power: i64) -> i64 { + fn power(self, power: i64) -> Result { if let Ok(power) = u32::try_from(power) { - return self.checked_pow(power).unwrap_or(0); + return self.checked_pow(power).ok_or(IntegerOverflowError::OutOfRange); } + // A negative power truncates toward 0, which only ±1 escape, and a power beyond 32 bits overflows for any other base but 0 match self { - 1 => 1, - -1 if power % 2 == 0 => 1, - -1 => -1, - _ => 0, + 1 => Ok(1), + -1 if power % 2 == 0 => Ok(1), + -1 => Ok(-1), + 0 => Ok(0), + _ if power < 0 => Ok(0), + _ => Err(IntegerOverflowError::OutOfRange), } } } impl Exponent for DVec2 { type Output = DVec2; - fn power(self, power: DVec2) -> DVec2 { - DVec2::new(self.x.powf(power.x), self.y.powf(power.y)) + fn power(self, power: DVec2) -> Result { + Ok(DVec2::new(self.x.powf(power.x), self.y.powf(power.y))) } } impl Exponent for DVec2 { type Output = DVec2; - fn power(self, power: f64) -> DVec2 { - DVec2::new(self.x.powf(power), self.y.powf(power)) + fn power(self, power: f64) -> Result { + Ok(DVec2::new(self.x.powf(power), self.y.powf(power))) } } impl Exponent for f64 { type Output = DVec2; - fn power(self, power: DVec2) -> DVec2 { - DVec2::new(self.powf(power.x), self.powf(power.y)) + fn power(self, power: DVec2) -> Result { + Ok(DVec2::new(self.powf(power.x), self.powf(power.y))) } } @@ -548,10 +765,10 @@ fn exponent, B>( #[implementations(f64, i64, DVec2, f64, DVec2)] #[default(2.)] power: Item, -) -> Item<>::Output> { +) -> Result>::Output>, IntegerOverflowError> { let (base, attributes) = base.into_parts(); - Item::from_parts(base.power(power.into_element()), attributes) + Ok(Item::from_parts(base.power(power.into_element())?, attributes)) } fn scalar_nth_root(radicand: f64, degree: f64) -> f64 { @@ -561,6 +778,8 @@ fn scalar_nth_root(radicand: f64, degree: f64) -> f64 { radicand.cbrt() } else if degree <= 0. { 0. + } else if radicand < 0. && degree % 2. == 1. { + -(-radicand).powf(1. / degree) } else { radicand.powf(1. / degree) } @@ -806,6 +1025,13 @@ impl TangentInverse for DVec2 { } } +/// Why a remap has no result. +#[derive(Debug, thiserror::Error)] +pub enum RemapError { + #[error("Input Min and Input Max can't be equal")] + FlatInputRange, +} + /// Linearly maps an input value from one range to another. The ranges may be reversed. /// /// For example, 0.5 in the input range `[0, 1]` would map to 0 in the output range `[-180, 180]`. @@ -826,17 +1052,15 @@ fn remap( output_max: Item, /// Whether to constrain the result within the output range instead of extrapolating beyond its bounds. clamped: Item, -) -> Item { +) -> Result, RemapError> { let (value, attributes) = value.into_parts(); let (input_min, input_max, output_min, output_max) = (*input_min.element(), *input_max.element(), *output_min.element(), *output_max.element()); - let input_range = input_max - input_min; - - // Handle division by zero - if input_range.abs() < f64::EPSILON { - return Item::from_parts(output_min, attributes); + if input_min == input_max { + return Err(RemapError::FlatInputRange); } + let input_range = input_max - input_min; let normalized = (value - input_min) / input_range; let output_range = output_max - output_min; @@ -846,14 +1070,17 @@ fn remap( // Handle both normal and inverted ranges, since we want to allow the user to use this node to also reverse a range. if output_min <= output_max { result.clamp(output_min, output_max) - } else { + } else if output_max < output_min { result.clamp(output_max, output_min) + } else { + // A NaN bound gives no range to clamp within, and has already made the result NaN + result } } else { result }; - Item::from_parts(result, attributes) + Ok(Item::from_parts(result, attributes)) } trait Lerp { @@ -991,22 +1218,22 @@ fn ceiling( Item::from_parts(value.componentwise(f64::ceil), attributes) } -trait AbsoluteValue { - fn abs(self) -> Self; +trait AbsoluteValue: Sized { + fn abs(self) -> Result; } impl AbsoluteValue for DVec2 { - fn abs(self) -> Self { - DVec2::new(self.x.abs(), self.y.abs()) + fn abs(self) -> Result { + Ok(DVec2::new(self.x.abs(), self.y.abs())) } } impl AbsoluteValue for f64 { - fn abs(self) -> Self { - self.abs() + fn abs(self) -> Result { + Ok(self.abs()) } } impl AbsoluteValue for i64 { - fn abs(self) -> Self { - self.abs() + fn abs(self) -> Result { + self.checked_abs().ok_or(IntegerOverflowError::OutOfRange) } } @@ -1019,10 +1246,10 @@ fn absolute_value( /// The number to be made positive. #[implementations(f64, i64, DVec2)] value: Item, -) -> Item { +) -> Result, IntegerOverflowError> { let (value, attributes) = value.into_parts(); - Item::from_parts(value.abs(), attributes) + Ok(Item::from_parts(value.abs()?, attributes)) } /// The sign function (`sign`) reports whether an input value is positive (1), negative (-1), or zero (0). @@ -1037,15 +1264,7 @@ fn sign( ) -> Item { let (value, attributes) = value.into_parts(); - let result = value.componentwise(|value| { - if value > 0. { - 1. - } else if value < 0. { - -1. - } else { - 0. - } - }); + let result = value.componentwise(|value| if value == 0. { 0. } else { value.signum() }); Item::from_parts(result, attributes) } @@ -1570,11 +1789,23 @@ fn hsla_to_color( Item::new_from_element(Color::from_hsla(hue, saturation, lightness, alpha)) } -/// Constructs a color value from a CSS color string. Accepts hex (`#RRGGBB`, `#RRGGBBAA`, plus bare and shorthand variants), CSS named colors (like `red`), and functional notations (`rgb(...)`, `hsl(...)`, etc.). Invalid inputs produce a transparent color. +/// A string that isn't a CSS color. +#[derive(Debug, thiserror::Error)] +pub enum StringToColorError { + #[error("`{0}` isn't a valid color")] + InvalidColor(String), +} + +/// Constructs a color value from a CSS color string. Accepts hex (`#RRGGBB`, `#RRGGBBAA`, plus bare and shorthand variants), CSS named colors (like `red`), and functional notations (`rgb(...)`, `hsl(...)`, etc.). An empty string produces a transparent color. #[node_macro::node(category("Color"), icon("NodeStringToColor"), name("String to Color"))] -fn string_to_color(_: impl Ctx, string: Item) -> Item { - let color = core_types::misc::parse_css_color(string.element()).unwrap_or_default(); - Item::new_from_element(color) +fn string_to_color(_: impl Ctx, string: Item) -> Result, StringToColorError> { + let string = string.into_element(); + if string.trim().is_empty() { + return Ok(Item::new_from_element(Color::default())); + } + + let color = core_types::misc::parse_css_color(&string).ok_or(StringToColorError::InvalidColor(string))?; + Ok(Item::new_from_element(color)) } /// Constructs a gradient value which may be set to any sequence of color stops to represent the transition between colors. @@ -1777,6 +2008,11 @@ fn vec2_to_numbers(_: impl Ctx, #[name("Vec2")] vec2: Item) -> Vec2Compon } } +/// Divides a vec2 by its largest absolute component, so normalizing it can't overflow or underflow; a zero or non-finite vec2 gets a NaN component. +fn scaled_to_unit_box(vec2: DVec2) -> DVec2 { + vec2 / vec2.abs().max_element() +} + /// The dot product operation (`·`) calculates the degree of similarity of a vec2 pair based on their angles and lengths. /// /// Calculated as `‖a‖‖b‖cos(θ)`, it represents the product of their lengths (`‖a‖‖b‖`) scaled by the alignment of their directions (`cos(θ)`). @@ -1797,7 +2033,7 @@ fn dot_product( let other_value = *other_value.element(); let result = if *normalize.element() { - value.normalize_or_zero().dot(other_value.normalize_or_zero()) + scaled_to_unit_box(value).normalize_or_zero().dot(scaled_to_unit_box(other_value).normalize_or_zero()) } else { value.dot(other_value) }; @@ -1843,10 +2079,8 @@ fn angle_between( return Item::from_parts(0., attributes); } - let angle = direction_from.try_angle_to(direction_to).unwrap_or_else(|| { - warn!("Angle to could not be computed"); - 0. - }); + let (from, to) = (scaled_to_unit_box(direction_from), scaled_to_unit_box(direction_to)); + let angle = from.perp_dot(to).atan2(from.dot(to)); let result = if *radians.element() { angle } else { angle.to_degrees() }; Item::from_parts(result, attributes) } @@ -1918,14 +2152,14 @@ fn distance( fn normalize(_: impl Ctx, vec2: Item) -> Item { let (vec2, attributes) = vec2.into_parts(); - Item::from_parts(vec2.normalize_or_zero(), attributes) + Item::from_parts(scaled_to_unit_box(vec2).normalize_or_zero(), attributes) } #[cfg(test)] mod test { use super::*; - use core_types::Node; use core_types::generic::FnNode; + use core_types::{Node, concrete}; #[test] pub fn dot_product_function() { @@ -2051,7 +2285,7 @@ mod test { let vec2 = |x, y| Item::new_from_element(DVec2::new(x, y)); assert_eq!(super::min((), Item::new_from_element(0_f64), vec2(-5., 5.)).into_element(), DVec2::new(-5., 0.)); assert_eq!(super::max((), Item::new_from_element(0_f64), vec2(-5., 5.)).into_element(), DVec2::new(0., 5.)); - assert_eq!(exponent((), Item::new_from_element(2_f64), vec2(2., 3.)).into_element(), DVec2::new(4., 8.)); + assert_eq!(exponent((), Item::new_from_element(2_f64), vec2(2., 3.)).unwrap().into_element(), DVec2::new(4., 8.)); assert_eq!(root((), Item::new_from_element(64_f64), vec2(2., 3.)).into_element(), DVec2::new(8., 4.)); assert_eq!(logarithm((), Item::new_from_element(8_f64), vec2(2., 10.)).into_element(), DVec2::new(3., 8_f64.log10())); assert_eq!(clamp((), Item::new_from_element(5_f64), vec2(0., 6.), vec2(1., 10.)).into_element(), DVec2::new(1., 6.)); @@ -2074,6 +2308,7 @@ mod test { Item::new_from_element(expression.to_string()), Item::new_from_element(0_i64), ) + .unwrap() .into_element() }; assert_eq!(integer(3, "x * 2"), 6); @@ -2083,7 +2318,9 @@ mod test { let items: List = [1_i64, 2, 4].into_iter().map(Item::new_from_element).collect(); assert_eq!( - math_f((), &ParseCache::default(), items, Item::new_from_element("mean".to_string()), Item::new_from_element(0_i64)).into_element(), + math_f((), &ParseCache::default(), items, Item::new_from_element("mean".to_string()), Item::new_from_element(0_i64)) + .unwrap() + .into_element(), 2, "7/3 rounds to 2" ); @@ -2100,6 +2337,7 @@ mod test { Item::new_from_element("x * 2".to_string()), Item::new_from_element(0_i64) ) + .unwrap() .into_element(), 5 ); @@ -2111,6 +2349,7 @@ mod test { Item::new_from_element("x > 2".to_string()), Item::new_from_element(false) ) + .unwrap() .into_element() ); assert_eq!( @@ -2121,13 +2360,16 @@ mod test { Item::new_from_element("x / 2".to_string()), Item::new_from_element(0.) ) + .unwrap() .into_element(), 0.5 ); let items: List = [1., 2.].into_iter().map(Item::new_from_element).collect(); assert_eq!( - math_f((), &ParseCache::default(), items, Item::new_from_element("mean".to_string()), Item::new_from_element(0_i64)).into_element(), + math_f((), &ParseCache::default(), items, Item::new_from_element("mean".to_string()), Item::new_from_element(0_i64)) + .unwrap() + .into_element(), 2, "1.5 rounds to 2" ); @@ -2150,7 +2392,7 @@ mod test { Item::new_from_element("2 + 2".to_string()), Item::new_from_element(0.), ); - assert_eq!(result.into_element(), 4.); + assert_eq!(result.unwrap().into_element(), 4.); } #[test] @@ -2162,7 +2404,7 @@ mod test { Item::new_from_element("(5 * 3) + (10 / 2)".to_string()), Item::new_from_element(0.), ); - assert_eq!(result.into_element(), 20.); + assert_eq!(result.unwrap().into_element(), 20.); } #[test] @@ -2174,7 +2416,7 @@ mod test { Item::new_from_element("x * 2".to_string()), Item::new_from_element(0.), ); - assert_eq!(result.into_element(), 14.); + assert_eq!(result.unwrap().into_element(), 14.); } #[test] @@ -2186,7 +2428,7 @@ mod test { Item::new_from_element("invalid".to_string()), Item::new_from_element(0.), ); - assert_eq!(result.into_element(), 0.); + assert_eq!(result.unwrap_err().0.into_element(), 0.); } #[test] @@ -2195,28 +2437,51 @@ mod test { let first = cache.parse("x * 2").unwrap(); assert!(Arc::ptr_eq(&first, &cache.parse("x * 2").unwrap())); assert!(!Arc::ptr_eq(&first, &cache.parse("x * 3").unwrap())); - assert!(cache.parse("invalid(").is_none()); + assert!(cache.parse("invalid(").is_err()); } #[test] fn test_boolean_items() { // Booleans read as exactly 0 and 1, and logical results convert back let as_bool = Item::new_from_element(false); - assert!(!math_fx((), &ParseCache::default(), Item::new_from_element(true), Item::new_from_element("!x".to_string()), as_bool.clone()).into_element()); - assert!(math_fx((), &ParseCache::default(), Item::new_from_element(false), Item::new_from_element("x == 0".to_string()), as_bool.clone()).into_element()); + assert!( + !math_fx((), &ParseCache::default(), Item::new_from_element(true), Item::new_from_element("!x".to_string()), as_bool.clone()) + .unwrap() + .into_element() + ); + assert!( + math_fx((), &ParseCache::default(), Item::new_from_element(false), Item::new_from_element("x == 0".to_string()), as_bool.clone()) + .unwrap() + .into_element() + ); + + // A result that is not exactly 0 or 1 cannot be a truth value, so the input passes through + let (stand_in, error) = math_fx((), &ParseCache::default(), Item::new_from_element(true), Item::new_from_element("x + 1".to_string()), as_bool.clone()).unwrap_err(); + assert!(stand_in.into_element()); + assert!(matches!(&error, MathExpressionError::OutputType { output_type, .. } if output_type == "Bool")); - // A result that is not exactly 0 or 1 cannot be a truth value, so it reads as false - assert!(!math_fx((), &ParseCache::default(), Item::new_from_element(true), Item::new_from_element("x + 1".to_string()), as_bool.clone()).into_element()); + // An input that is not exactly 0 or 1 cannot pass through as a truth value either, so the output falls back to false + let (stand_in, error) = math_fx((), &ParseCache::default(), Item::new_from_element(2.), Item::new_from_element("x + 1".to_string()), as_bool.clone()).unwrap_err(); + assert!(!stand_in.into_element()); + assert!(matches!(&error, MathExpressionError::InputType(output_type) if output_type == "Bool")); let bools = || [true, true, false].into_iter().map(Item::new_from_element).collect::>(); - assert!(!math_f((), &ParseCache::default(), bools(), Item::new_from_element("&&".to_string()), as_bool.clone()).into_element()); - assert!(math_f((), &ParseCache::default(), bools(), Item::new_from_element("||".to_string()), as_bool.clone()).into_element()); - assert!(!math_f((), &ParseCache::default(), bools(), Item::new_from_element("xor".to_string()), as_bool).into_element()); + assert!( + !math_f((), &ParseCache::default(), bools(), Item::new_from_element("&&".to_string()), as_bool.clone()) + .unwrap() + .into_element() + ); + assert!( + math_f((), &ParseCache::default(), bools(), Item::new_from_element("||".to_string()), as_bool.clone()) + .unwrap() + .into_element() + ); + assert!(!math_f((), &ParseCache::default(), bools(), Item::new_from_element("xor".to_string()), as_bool).unwrap().into_element()); } #[test] fn test_transform_items() { - fn fx(value: T, expression: &str, output_type: U) -> U { + fn fx(value: T, expression: &str, output_type: U) -> Result { math_fx( (), &ParseCache::default(), @@ -2224,40 +2489,50 @@ mod test { Item::new_from_element(expression.to_string()), Item::new_from_element(output_type), ) - .into_element() + .map(Item::into_element) + .map_err(|(stand_in, _)| stand_in.into_element()) } let shear = DAffine2::from_cols(DVec2::new(1., 0.), DVec2::new(0.5, 1.), DVec2::new(5., 4.)); // A Transform binds as `X`, and a matrix result reads back as one, so the default `x` passes it through - assert_eq!(fx(shear, "x", shear), shear); - assert_eq!(fx(shear, "X (2i + 2j)", DVec2::ZERO), DVec2::new(8., 6.)); - assert_eq!(fx(shear, "X^-1 X", shear), DAffine2::IDENTITY); - assert_eq!(fx(shear, "linear(X) + 1i", DAffine2::IDENTITY), DAffine2::from_cols(DVec2::new(1., 0.), DVec2::new(0.5, 1.), DVec2::X)); - assert_eq!(fx(2., "rotation(pi/2) + x i", DAffine2::IDENTITY).translation, DVec2::new(2., 0.)); - assert_eq!(fx(shear, "det(X)", 0.), 1.); + assert_eq!(fx(shear, "x", shear), Ok(shear)); + assert_eq!(fx(shear, "X (2i + 2j)", DVec2::ZERO), Ok(DVec2::new(8., 6.))); + assert_eq!(fx(shear, "X^-1 X", shear), Ok(DAffine2::IDENTITY)); + assert_eq!( + fx(shear, "linear(X) + 1i", DAffine2::IDENTITY), + Ok(DAffine2::from_cols(DVec2::new(1., 0.), DVec2::new(0.5, 1.), DVec2::X)) + ); + assert_eq!(fx(2., "rotation(pi/2) + x i", DAffine2::IDENTITY).unwrap().translation, DVec2::new(2., 0.)); + assert_eq!(fx(shear, "det(X)", 0.), Ok(1.)); + + // A value, or a matrix touching the weight, is no Transform, so the input passes through + assert_eq!(fx(shear, "X 0", DAffine2::IDENTITY), Err(shear)); + assert_eq!(fx(shear, "[1;i] + X 0", DAffine2::IDENTITY), Err(shear)); - // A value, or a matrix touching the weight, is no Transform, so the output falls back to its default - assert_eq!(fx(shear, "X 0", DAffine2::IDENTITY), DAffine2::IDENTITY); - assert_eq!(fx(shear, "[1;i] + X 0", DAffine2::IDENTITY), DAffine2::IDENTITY); + // A Transform can't pass through as a Number, so the output falls back to its default + assert_eq!(fx(shear, "x", 0.), Err(0.)); // A Transform list binds as `A`, `B`, `C`, and a lone operator composes, averages, or counts across it let scale = DAffine2::from_scale(DVec2::splat(2.)); - fn f(items: [DAffine2; 2], expression: &str, output_type: U) -> U { + fn f(items: [DAffine2; 2], expression: &str, output_type: U) -> Result { let items: List = items.into_iter().map(Item::new_from_element).collect(); - math_f((), &ParseCache::default(), items, Item::new_from_element(expression.to_string()), Item::new_from_element(output_type)).into_element() + math_f((), &ParseCache::default(), items, Item::new_from_element(expression.to_string()), Item::new_from_element(output_type)).map(Item::into_element) } - assert_eq!(f([shear, scale], "A B", DAffine2::IDENTITY), shear * scale); - assert_eq!(f([shear, scale], "A C", DAffine2::IDENTITY), shear); - assert_eq!(f([shear, scale], "*", DAffine2::IDENTITY), shear * scale); - assert_eq!(f([shear, scale], "/", DAffine2::IDENTITY), shear * scale.inverse()); - assert_eq!(f([shear, scale], "mean", DAffine2::IDENTITY).translation, DVec2::new(2.5, 2.)); - assert_eq!(f([shear, scale], "count", 0.), 2.); - assert_eq!(f([shear, scale], "det(A)", 0.), 1.); - assert_eq!(f([shear, scale], "min", DAffine2::IDENTITY), DAffine2::IDENTITY); - assert_eq!(f([shear, scale], "a", DAffine2::IDENTITY), DAffine2::IDENTITY); - - // A sum of Transforms doubles the weight row, so it reads as no Transform and the output falls back to its default - assert_eq!(f([shear, scale], "+", DAffine2::IDENTITY), DAffine2::IDENTITY); + assert_eq!(f([shear, scale], "A B", DAffine2::IDENTITY).unwrap(), shear * scale); + assert_eq!(f([shear, scale], "A C", DAffine2::IDENTITY).unwrap(), shear); + assert_eq!(f([shear, scale], "*", DAffine2::IDENTITY).unwrap(), shear * scale); + assert_eq!(f([shear, scale], "/", DAffine2::IDENTITY).unwrap(), shear * scale.inverse()); + assert_eq!(f([shear, scale], "mean", DAffine2::IDENTITY).unwrap().translation, DVec2::new(2.5, 2.)); + assert_eq!(f([shear, scale], "count", 0.).unwrap(), 2.); + assert_eq!(f([shear, scale], "det(A)", 0.).unwrap(), 1.); + assert!(matches!(f([shear, scale], "min", DAffine2::IDENTITY), Err(MathExpressionError::NoReduction { .. }))); + assert!(f([shear, scale], "a", DAffine2::IDENTITY).is_err()); + + // A sum of Transforms doubles the weight row, so it reads as no Transform + assert!(matches!( + f([shear, scale], "+", DAffine2::IDENTITY), + Err(MathExpressionError::OutputType { output_type, .. }) if output_type == "Transform" + )); } #[test] @@ -2270,11 +2545,14 @@ mod test { Item::new_from_element(expression.to_string()), Item::new_from_element(output_type), ) + .unwrap_or_else(|(_, error)| panic!("`{expression}` fails: {error}")) .into_element() } fn f(expression: &str, output_type: U) -> U { let items: List = [DVec2::new(1., 5.), DVec2::new(3., 2.)].into_iter().map(Item::new_from_element).collect(); - math_f((), &ParseCache::default(), items, Item::new_from_element(expression.to_string()), Item::new_from_element(output_type)).into_element() + math_f((), &ParseCache::default(), items, Item::new_from_element(expression.to_string()), Item::new_from_element(output_type)) + .unwrap() + .into_element() } assert_eq!(fx(DVec2::new(3., 4.), "2x + i", DVec2::ZERO), DVec2::new(7., 8.)); @@ -2315,14 +2593,21 @@ mod test { // A full expression reads the items positionally as `a`, `b`, `c`, while a lone token applies across all of them let as_number = Item::new_from_element(0.); assert_eq!( - math_f((), &ParseCache::default(), values(), Item::new_from_element("a - b + c".to_string()), as_number.clone()).into_element(), + math_f((), &ParseCache::default(), values(), Item::new_from_element("a - b + c".to_string()), as_number.clone()) + .unwrap() + .into_element(), 10. ); assert_eq!( - math_f((), &ParseCache::default(), values(), Item::new_from_element("min".to_string()), as_number.clone()).into_element(), + math_f((), &ParseCache::default(), values(), Item::new_from_element("min".to_string()), as_number.clone()) + .unwrap() + .into_element(), 1. ); - assert_eq!(math_f((), &ParseCache::default(), values(), Item::new_from_element("+".to_string()), as_number).into_element(), 12.); + assert_eq!( + math_f((), &ParseCache::default(), values(), Item::new_from_element("+".to_string()), as_number).unwrap().into_element(), + 12. + ); } #[test] @@ -2333,29 +2618,37 @@ mod test { #[test] pub fn add_vectors() { - assert_eq!(super::add((), Item::new_from_element(DVec2::ONE), Item::new_from_element(DVec2::ONE)).into_element(), DVec2::ONE * 2.); + assert_eq!( + super::add((), Item::new_from_element(DVec2::ONE), Item::new_from_element(DVec2::ONE)).unwrap().into_element(), + DVec2::ONE * 2. + ); } #[test] pub fn subtract_f64() { - assert_eq!(super::subtract((), Item::new_from_element(5_f64), Item::new_from_element(3_f64)).into_element(), 2.); + assert_eq!(super::subtract((), Item::new_from_element(5_f64), Item::new_from_element(3_f64)).unwrap().into_element(), 2.); } #[test] pub fn divide_vectors() { - assert_eq!(super::divide((), Item::new_from_element(DVec2::ONE), Item::new_from_element(2_f64)).into_element(), DVec2::ONE / 2.); + assert_eq!( + super::divide((), Item::new_from_element(DVec2::ONE), Item::new_from_element(2_f64)).unwrap().into_element(), + DVec2::ONE / 2. + ); } #[test] pub fn divide_vector_by_partially_zero_vector() { let (numerator, denominator) = (Item::new_from_element(DVec2::new(1., 2.)), Item::new_from_element(DVec2::new(2., 0.))); - assert_eq!(super::divide((), numerator, denominator).into_element(), DVec2::new(0.5, 0.)); + assert_eq!(super::divide((), numerator, denominator).unwrap().into_element(), DVec2::new(0.5, 0.)); } #[test] pub fn modulo_positive() { assert_eq!( - super::modulo((), Item::new_from_element(-5_f64), Item::new_from_element(2_f64), Item::new_from_element(true)).into_element(), + super::modulo((), Item::new_from_element(-5_f64), Item::new_from_element(2_f64), Item::new_from_element(true)) + .unwrap() + .into_element(), 1_f64 ); } @@ -2363,8 +2656,134 @@ mod test { #[test] pub fn modulo_negative() { assert_eq!( - super::modulo((), Item::new_from_element(-5_f64), Item::new_from_element(2_f64), Item::new_from_element(false)).into_element(), + super::modulo((), Item::new_from_element(-5_f64), Item::new_from_element(2_f64), Item::new_from_element(false)) + .unwrap() + .into_element(), -1_f64 ); } + + #[test] + fn expression_errors() { + assert_eq!( + [concrete!(f64), concrete!(i64), concrete!(bool), concrete!(DVec2), concrete!(DAffine2)].map(|ty| ty.to_string()), + ["Number", "Integer", "Bool", "Vec2", "Transform"] + ); + + let fx = |value: f64, expression: &str| { + math_fx( + (), + &ParseCache::default(), + Item::new_from_element(value), + Item::new_from_element(expression.to_string()), + Item::new_from_element(0.), + ) + .map(Item::into_element) + .map_err(|(stand_in, error)| (stand_in.into_element(), error)) + }; + assert!(matches!(fx(3., " "), Err((3., MathExpressionError::Empty)))); + assert!(matches!(fx(3., "y * 2"), Err((3., MathExpressionError::Evaluation(_))))); + let Err((3., MathExpressionError::Invalid(message))) = fx(3., "x +") else { + panic!("`x +` doesn't parse") + }; + assert!(!message.contains(", at "), "{message}"); + + let values = |values: &[f64]| values.iter().copied().map(Item::new_from_element).collect::>(); + let f = |items, expression: &str| math_f((), &ParseCache::default(), items, Item::new_from_element(expression.to_string()), Item::new_from_element(0.)).map(Item::into_element); + assert!(matches!(f(values(&[1.]), ""), Err(MathExpressionError::Empty))); + assert!(matches!(f(values(&[1.]), "a +"), Err(MathExpressionError::Invalid(_)))); + assert_eq!(f(values(&[]), "min").unwrap(), 0.); + assert_eq!(f(values(&[]), "mean").unwrap(), 0.); + assert!(matches!(f(values(&[]), "-"), Err(MathExpressionError::EmptyReduction { .. }))); + assert!(matches!(f(values(&[0., 0.]), "/"), Err(MathExpressionError::NoReduction { .. }))); + } + + #[test] + fn integer_overflow_is_an_error() { + let integer = Item::new_from_element; + assert!(super::add((), integer(i64::MAX), integer(1)).is_err()); + assert!(super::subtract((), integer(i64::MIN), integer(1)).is_err()); + assert!(super::multiply((), integer(i64::MAX), integer(2)).is_err()); + assert!(super::divide((), integer(i64::MIN), integer(-1)).is_err()); + assert_eq!(super::divide((), integer(5), integer(0)).unwrap().into_element(), 0); + assert!(exponent((), integer(2), integer(63)).is_err()); + assert!(exponent((), integer(2), integer(1 << 40)).is_err()); + assert_eq!(exponent((), integer(-1), integer(1 << 40)).unwrap().into_element(), 1); + assert_eq!(exponent((), integer(0), integer(1 << 40)).unwrap().into_element(), 0); + assert_eq!(exponent((), integer(2), integer(-1)).unwrap().into_element(), 0); + assert!(absolute_value((), integer(i64::MIN)).is_err()); + assert_eq!(absolute_value((), integer(i64::MIN + 1)).unwrap().into_element(), i64::MAX); + } + + #[test] + fn modulo_of_zero_is_an_error_and_integer_extremes_are_exact() { + let modulo = |numerator: i64, modulus: i64, always_positive| { + super::modulo((), Item::new_from_element(numerator), Item::new_from_element(modulus), Item::new_from_element(always_positive)).map(Item::into_element) + }; + assert!(modulo(5, 0, true).is_err()); + assert_eq!(modulo(i64::MIN, -1, false).unwrap(), 0); + assert_eq!(modulo(i64::MIN, -1, true).unwrap(), 0); + assert_eq!(modulo(-1, i64::MIN, true).unwrap(), -1); + assert_eq!(modulo(-5, 3, true).unwrap(), 1); + assert_eq!(modulo(5, -3, true).unwrap(), -1); + assert_eq!(modulo(-5, 3, false).unwrap(), -2); + + assert!(super::modulo((), Item::new_from_element(1.), Item::new_from_element(0.), Item::new_from_element(false)).is_err()); + let vec2 = |x, y| Item::new_from_element(DVec2::new(x, y)); + assert!(super::modulo((), vec2(1., 2.), vec2(2., 0.), Item::new_from_element(true)).is_err()); + } + + #[test] + fn remap_refuses_a_flat_input_range_and_leaves_nan_bounds_unclamped() { + let remap = |input_min: f64, input_max: f64, output_min: f64, output_max: f64| { + super::remap( + (), + Item::new_from_element(0.5), + Item::new_from_element(input_min), + Item::new_from_element(input_max), + Item::new_from_element(output_min), + Item::new_from_element(output_max), + Item::new_from_element(true), + ) + .map(Item::into_element) + }; + assert!(remap(1., 1., 0., 1.).is_err()); + assert_eq!(remap(0., 1e-17, 0., 1.).unwrap(), 1.); + assert!(remap(0., 1., f64::NAN, 1.).unwrap().is_nan()); + assert!(remap(0., 1., 0., f64::NAN).unwrap().is_nan()); + } + + #[test] + fn odd_roots_of_negative_numbers_are_real() { + let root_of = |radicand: f64, degree: f64| root((), Item::new_from_element(radicand), Item::new_from_element(degree)).into_element(); + assert_eq!(root_of(-32., 5.), -root_of(32., 5.)); + assert_eq!(root_of(-8., 3.), -2.); + assert!(root_of(-16., 4.).is_nan()); + assert!(root_of(-16., 2.5).is_nan()); + } + + #[test] + fn sign_of_nan_is_nan() { + assert!(sign((), Item::new_from_element(f64::NAN)).into_element().is_nan()); + } + + #[test] + fn vec2_directions_of_extreme_length() { + let vec2 = |x, y| Item::new_from_element(DVec2::new(x, y)); + for scale in [1e200, 1e-200, 1.] { + assert!((normalize((), vec2(3. * scale, 4. * scale)).into_element() - DVec2::new(0.6, 0.8)).length() < 1e-15); + assert!((dot_product((), vec2(scale, scale), vec2(scale, 0.), Item::new_from_element(true)).into_element() - std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-15); + assert!((angle_between((), vec2(scale, scale), vec2(scale, 0.), Item::new_from_element(false)).into_element() + 45.).abs() < 1e-12); + } + assert_eq!(normalize((), vec2(f64::INFINITY, 1.)).into_element(), DVec2::ZERO); + assert!(angle_between((), vec2(f64::NAN, 1.), vec2(1., 0.), Item::new_from_element(false)).into_element().is_nan()); + } + + #[test] + fn string_to_color_refuses_invalid_strings() { + let color = |string: &str| string_to_color((), Item::new_from_element(string.to_string())).map(Item::into_element); + assert_eq!(color("").unwrap(), Color::default()); + assert!(color("not a color").is_err()); + assert!(color("red").is_ok()); + } } From cd07615411c3c0a5e2bf88aaa1c16ff1789454f2 Mon Sep 17 00:00:00 2001 From: Timon Date: Fri, 23 Oct 2026 19:37:06 +0000 Subject: [PATCH 2/8] Fix crashes and report errors in text nodes and Boolean Operation --- Cargo.lock | 3 +- .../document/overlays/utility_types_native.rs | 2 +- node-graph/nodes/gstd/src/text.rs | 6 +- node-graph/nodes/path-bool/Cargo.toml | 1 + node-graph/nodes/path-bool/src/lib.rs | 77 ++++--- node-graph/nodes/text/Cargo.toml | 2 +- node-graph/nodes/text/src/json.rs | 90 +++++--- node-graph/nodes/text/src/lib.rs | 96 +++++++-- node-graph/nodes/text/src/path_builder.rs | 28 +-- node-graph/nodes/text/src/regex.rs | 200 ++++++++++-------- node-graph/nodes/text/src/text_context.rs | 27 ++- node-graph/nodes/text/src/to_path.rs | 44 ++-- 12 files changed, 368 insertions(+), 208 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 44f6b20bc0e..14525feeff0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4286,6 +4286,7 @@ dependencies = [ "node-macro", "smallvec", "text-nodes", + "thiserror 2.0.18", "vector-types", ] @@ -6117,13 +6118,13 @@ dependencies = [ "graphene-hash", "graphene-resource", "ipsum", - "log", "node-macro", "parley", "rand", "serde", "serde_json", "skrifa", + "thiserror 2.0.18", "titlecase", "tsify", "unicode-segmentation", diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs index a5200626150..8a4b93aa0d6 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs @@ -1157,7 +1157,7 @@ impl OverlayContextInternal { // Lay out the text once, taking its dimensions and vector paths from the same thread-local context pass let (text_size, text_list) = TextContext::with_thread_local(|text_context| { let text_size = text_context.bounding_box(text, &FALLBACK_FONT_RESOURCE, typesetting, false); - let text_list = text_context.to_path(text, &FALLBACK_FONT_RESOURCE, typesetting, false); + let text_list = text_context.to_path(text, &FALLBACK_FONT_RESOURCE, typesetting, false).unwrap_or_default(); (text_size, text_list) }); let text_width = text_size.x; diff --git a/node-graph/nodes/gstd/src/text.rs b/node-graph/nodes/gstd/src/text.rs index 0c5e1a1555e..27ababb3ba0 100644 --- a/node-graph/nodes/gstd/src/text.rs +++ b/node-graph/nodes/gstd/src/text.rs @@ -104,8 +104,8 @@ fn text_to_vector( _: impl Ctx, /// A styled text string produced by the **Text** node (or any other string source). string: Item, -) -> Item { - shape_text_item(&string, false).into_iter().next().unwrap_or_default() +) -> Result, TextToVectorError> { + Ok(shape_text_item(&string, false)?.into_iter().next().unwrap_or_default()) } /// Splits a styled text string into a separate vector item for each of its glyphs (letterforms). @@ -114,6 +114,6 @@ fn text_to_vector_glyphs( _: impl Ctx, /// A styled text string produced by the **Text** node (or any other string source). string: Item, -) -> List { +) -> Result, TextToVectorError> { shape_text_item(&string, true) } diff --git a/node-graph/nodes/path-bool/Cargo.toml b/node-graph/nodes/path-bool/Cargo.toml index 4fe070d1fb7..6b942f2e023 100644 --- a/node-graph/nodes/path-bool/Cargo.toml +++ b/node-graph/nodes/path-bool/Cargo.toml @@ -25,3 +25,4 @@ linesweeper = { workspace = true } log = { workspace = true } smallvec = { workspace = true } vector-types = { workspace = true } +thiserror = { workspace = true } diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index e470f4fb94e..616f8bd5ce5 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -10,9 +10,20 @@ use graphic_types::{Graphic, Vector}; use linesweeper::topology::Topology; use linesweeper::{BinaryOp, FillRule, binary_op}; use smallvec::SmallVec; -use vector_types::kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathEl, PathSeg, Point, QuadBez}; +use vector_types::kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathEl, PathSeg, Point, QuadBez, Shape}; pub use vector_types::vector::misc::BooleanOperation; +/// Why a boolean operation gives no result. +#[derive(Debug, thiserror::Error)] +pub enum BooleanOperationError { + #[error("An operand has coordinates that aren't finite")] + NonFiniteCoordinates, + #[error(transparent)] + Text(#[from] text_nodes::TextToVectorError), + #[error("The operands can't be combined because {0}")] + Topology(#[from] linesweeper::Error), +} + // TODO: Fix boolean ops to work by removing .transform() and .one_instance_*() calls, // TODO: since before we used a Vec of single-item `List`s and now we use a single `List` // TODO: with multiple items while still assuming a single item for the boolean operations. @@ -30,12 +41,12 @@ fn boolean_operation( /// Intersection cuts away all but the overlapping areas shared by every path. /// Difference cuts away the areas where an even number of paths overlap, keeping those where an odd number do. operation: Item, -) -> Item { +) -> Result, BooleanOperationError> { let operation = operation.into_element(); // The first index is the bottom of the stack - let flattened = flatten_vector(&content); - let mut result_vector_list = boolean_operation_on_vector_list(&flattened, operation); + let flattened = flatten_vector(&content)?; + let mut result_vector_list = boolean_operation_on_vector_list(&flattened, operation)?; // Replace the transformation matrix with a mutation of the vector points themselves if result_vector_list.element_mut(0).is_some() { @@ -65,7 +76,7 @@ fn boolean_operation( set_paths_at(&mut result_vector_list, 0, result_paths); } - result_vector_list.into_iter().next().unwrap_or_default() + Ok(result_vector_list.into_iter().next().unwrap_or_default()) } #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -104,7 +115,7 @@ impl std::ops::AddAssign for WindingNumber { self.elems = rhs.elems; } else { for (me, them) in self.elems.iter_mut().zip(&rhs.elems) { - *me += *them; + *me = me.wrapping_add(*them); } } } @@ -133,7 +144,7 @@ impl WindingNumber { } } -fn boolean_operation_on_vector_list(vector: &List, boolean_operation: BooleanOperation) -> List { +fn boolean_operation_on_vector_list(vector: &List, boolean_operation: BooleanOperation) -> Result, BooleanOperationError> { const EPSILON: f64 = 1e-5; let mut list = List::new(); let mut paths = Vec::new(); @@ -161,18 +172,15 @@ fn boolean_operation_on_vector_list(vector: &List, boolean_operation: Bo for index in 0..vector.len() { let element = vector.element(index).unwrap(); let element_paths = paths_from_list_row_attributes(vector, index).unwrap_or_else(|| no_paths()); - paths.push(to_bez_path(element, element_paths, vector.attribute_cloned_or_default(ATTR_TRANSFORM, index))); + paths.push(to_bez_path(element, element_paths, vector.attribute_cloned_or_default(ATTR_TRANSFORM, index))?); } - let top = match Topology::::from_paths(paths.iter().enumerate().map(|(idx, path)| (path, (idx, paths.len()))), EPSILON) { - Ok(top) => top, - Err(e) => { - log::error!("Boolean operation failed while building topology: {e}"); - row.set_paths(List::::new()); - list.push(row); - return list; - } - }; + // Far from the origin, the tolerance grows with the coordinates' magnitude like linesweeper's own `binary_op`, since the sweep can't resolve less + let bounds = paths.iter().map(Shape::bounding_box).reduce(|a, b| a.union(b)).unwrap_or_default(); + let magnitude = bounds.min_x().min(bounds.min_y()).abs().max(bounds.max_x().max(bounds.max_y()).abs()); + let epsilon = EPSILON.max(magnitude * f64::EPSILON * 64.); + + let top = Topology::::from_paths(paths.iter().enumerate().map(|(idx, path)| (path, (idx, paths.len()))), epsilon).map_err(linesweeper::Error::from)?; let contours = top.contours(|winding| winding.is_inside(boolean_operation)); let mut result_paths = List::new(); let closed_contours = contours.contours().map(|contour| closed(contour.path.clone())); @@ -180,14 +188,14 @@ fn boolean_operation_on_vector_list(vector: &List, boolean_operation: Bo row.set_paths(result_paths); list.push(row); - list + Ok(list) } /// Flattens graphic content into the operands of a boolean operation, matching `flatten_graphic_list`'s attribute composition. /// Non-path content is dropped, since it contributes no region to operate on. -fn flatten_vector(graphic_list: &List) -> List { - (0..graphic_list.len()) - .flat_map(|index| { +fn flatten_vector(graphic_list: &List) -> Result, BooleanOperationError> { + let operands = (0..graphic_list.len()) + .map(|index| { let graphic = graphic_list.element(index).unwrap(); // Whether the parent carries each attribute is a structural fact, so an absent one never invents a column on the children @@ -234,11 +242,11 @@ fn flatten_vector(graphic_list: &List) -> List { other => other, }; - match graphic { + Ok(match graphic { Graphic::Vector(item) => vec![compose_parent(*item)], Graphic::VectorList(vector) => vector.into_iter().map(compose_parent).collect::>(), - Graphic::Text(item) => text_nodes::shape_text_list(&List::new_from_item(item), false).into_iter().map(compose_parent).collect::>(), - Graphic::TextList(text) => text_nodes::shape_text_list(&text, false).into_iter().map(compose_parent).collect::>(), + Graphic::Text(item) => text_nodes::shape_text_list(&List::new_from_item(item), false)?.into_iter().map(compose_parent).collect::>(), + Graphic::TextList(text) => text_nodes::shape_text_list(&text, false)?.into_iter().map(compose_parent).collect::>(), Graphic::GraphicList(mut graphic) => { if parent_has_transform { for transform in graphic.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { @@ -265,12 +273,12 @@ fn flatten_vector(graphic_list: &List) -> List { // Unioning each group lets it enter the outer operation as one region, which is why this cannot defer // to `flatten_graphic_list`. Splicing children in as sibling operands makes them subtract from each other. - let flattened = flatten_vector(&graphic); + let flattened = flatten_vector(&graphic)?; if flattened.is_empty() { // The union call emits one blank operand even from an empty list Vec::new() } else { - boolean_operation_on_vector_list(&flattened, BooleanOperation::Union).into_iter().collect::>() + boolean_operation_on_vector_list(&flattened, BooleanOperation::Union)?.into_iter().collect::>() } } // Rasters, colors, and gradients bound no region, so they contribute no operand @@ -280,9 +288,11 @@ fn flatten_vector(graphic_list: &List) -> List { Graphic::StrokeList(_) => Vec::new(), // Normalized to GraphicList above Graphic::Graphic(_) => Vec::new(), - } + }) }) - .collect() + .collect::, BooleanOperationError>>()?; + + Ok(operands.into_iter().flatten().collect()) } // This quantization should potentially be removed since it's not conceptually necessary, @@ -309,7 +319,7 @@ fn closed(mut path: BezPath) -> BezPath { path } -fn to_bez_path(vector: &Vector, element_paths: &List, transform: DAffine2) -> BezPath { +fn to_bez_path(vector: &Vector, element_paths: &List, transform: DAffine2) -> Result { let transform = Affine::new(transform.to_cols_array()); let mut path = BezPath::new(); @@ -330,7 +340,10 @@ fn to_bez_path(vector: &Vector, element_paths: &List, transform: DAffine2) } } - path + if !path.is_finite() { + return Err(BooleanOperationError::NonFiniteCoordinates); + } + Ok(path) } pub fn boolean_intersect(a: &BezPath, b: &BezPath) -> Vec { @@ -355,7 +368,7 @@ mod tests { #[test] fn difference_leaves_the_overlap_of_two_shapes_empty() { let content: List = [operand(&Circle::new((0., 0.), 100.)), operand(&Rect::new(50., 50., 200., 200.))].into_iter().collect(); - let result = boolean_operation((), content, Item::new_from_element(BooleanOperation::Difference)); + let result = boolean_operation((), content, Item::new_from_element(BooleanOperation::Difference)).unwrap(); let paths = result.attribute::>(ATTR_PATHS).cloned().unwrap_or_default(); let vector = result.element(); @@ -370,7 +383,7 @@ mod tests { #[test] fn difference_keeps_areas_covered_by_an_odd_number_of_shapes() { let circles = [(0., 0.), (60., 0.), (30., 52.)].map(|center| operand(&Circle::new(center, 50.))); - let result = boolean_operation((), circles.into_iter().collect(), Item::new_from_element(BooleanOperation::Difference)); + let result = boolean_operation((), circles.into_iter().collect(), Item::new_from_element(BooleanOperation::Difference)).unwrap(); let paths = result.attribute::>(ATTR_PATHS).cloned().unwrap_or_default(); let vector = result.element(); diff --git a/node-graph/nodes/text/Cargo.toml b/node-graph/nodes/text/Cargo.toml index c84eb0d3430..162107a05f0 100644 --- a/node-graph/nodes/text/Cargo.toml +++ b/node-graph/nodes/text/Cargo.toml @@ -32,8 +32,8 @@ dyn-any = { workspace = true } glam = { workspace = true } parley = { workspace = true } skrifa = { workspace = true } -log = { workspace = true } serde_json = { workspace = true } +thiserror = { workspace = true } convert_case = { workspace = true } titlecase = { workspace = true } fancy-regex = { workspace = true } diff --git a/node-graph/nodes/text/src/json.rs b/node-graph/nodes/text/src/json.rs index f9ce2c470b5..776284a2ae4 100644 --- a/node-graph/nodes/text/src/json.rs +++ b/node-graph/nodes/text/src/json.rs @@ -4,11 +4,20 @@ use serde_json::Value; use crate::unescape_string; +/// Why a JSON node's input gives no result. +#[derive(Debug, thiserror::Error)] +pub enum JsonError { + #[error("The JSON is invalid: {0}")] + InvalidJson(#[from] serde_json::Error), + #[error("The JSON path `{0}` is invalid")] + InvalidPath(String), +} + // =========== // Format JSON // =========== -/// Reformats a JSON string with control over indentation, line breaking, and spacing. Trailing commas are tolerated. Otherwise-invalid JSON input is returned unchanged. +/// Reformats a JSON string with control over indentation, line breaking, and spacing. Trailing commas are tolerated, while otherwise-invalid JSON gives an error. #[node_macro::node(name("Format JSON"), category("String: JSON"), icon("NodeFormatJson"))] fn format_json( _: impl Ctx, @@ -33,17 +42,20 @@ fn format_json( /// Always break a container (array or object) across lines if it holds another container, even if it would fit within the break length. (Requires *Multi-Line* to take effect.) #[default(true)] break_nested: Item, -) -> Item { +) -> Result, JsonError> { let mut json = json; let (compact, multi_line, break_length, break_nested) = (*compact.element(), *multi_line.element(), *break_length.element(), *break_nested.element()); let indent = indent.element().clone(); let cleaned = strip_trailing_commas(json.element()); - let Ok(value) = serde_json::from_str::(&cleaned) else { return json }; + if cleaned.trim().is_empty() { + return Ok(json); + } + let value = serde_json::from_str::(&cleaned)?; let indent = unescape_string(indent); let colon = if compact { ":" } else { ": " }; let comma_space = if compact { "," } else { ", " }; - let line_width = break_length as usize; + let line_width = usize::try_from(break_length).unwrap_or(usize::MAX); let result = if multi_line { format_value(&value, 0, &indent, colon, comma_space, compact, break_nested, line_width) @@ -52,14 +64,14 @@ fn format_json( }; *json.element_mut() = result; - json + Ok(json) } -/// Strips trailing commas before `]` and `}` to accept JSON-with-trailing-commas input. +/// Blanks out trailing commas before `]` and `}` to accept JSON-with-trailing-commas input. /// Respects string literals so commas inside strings are left untouched. fn strip_trailing_commas(json: &str) -> String { let mut output = String::with_capacity(json.len()); - let mut chars = json.chars().peekable(); + let mut chars = json.chars(); let mut in_string = false; while let Some(c) = chars.next() { @@ -84,15 +96,9 @@ fn strip_trailing_commas(json: &str) -> String { output.push(c); } ',' => { - // Skip any whitespace after the comma - while chars.peek().is_some_and(|c| c.is_ascii_whitespace()) { - chars.next(); - } - - // Drop trailing commas (before `]` or `}`), but keep all others - if !chars.peek().is_some_and(|&c| c == ']' || c == '}') { - output.push(','); - } + // A trailing comma (before `]` or `}`) becomes a space, so error positions still point into the input + let trailing = chars.clone().find(|c| !c.is_ascii_whitespace()).is_some_and(|c| c == ']' || c == '}'); + output.push(if trailing { ' ' } else { ',' }); } _ => output.push(c), } @@ -210,25 +216,27 @@ fn query_json( /// Strips the surrounding double quotes from string values, returning the raw text. Other types are never wrapped in quotes. #[default(true)] unquote_strings: Item, -) -> Item { +) -> Result, JsonError> { let mut json = json; let path = path.element().clone(); let unquote_strings = *unquote_strings.element(); let cleaned = strip_trailing_commas(json.element()); + let segments = parse_json_path(path.trim()).ok_or_else(|| JsonError::InvalidPath(path.trim().to_string()))?; - let result = match (serde_json::from_str::(&cleaned), parse_json_path(path.trim())) { - (Ok(value), Some(segments)) => { - let mut results = Vec::new(); - resolve_all(&value, &segments, !unquote_strings, &mut results); + let result = if cleaned.trim().is_empty() { + String::new() + } else { + let value = serde_json::from_str::(&cleaned)?; - results.into_iter().next().map(|(text, _ty)| text).unwrap_or_default() - } - _ => String::new(), + let mut results = Vec::new(); + resolve_all(&value, &segments, !unquote_strings, &mut results); + + results.into_iter().next().map(|(text, _ty)| text).unwrap_or_default() }; *json.element_mut() = result; - json + Ok(json) } /// Extracts every matched value from a JSON string using a path expression (see that parameter's description for its syntax). A list of zero or more resultant strings is produced. The `[]` path accessor is used to read more than one value. @@ -258,15 +266,18 @@ fn query_json_all( /// Strips the surrounding double quotes from string values, returning the raw text. Other types are never wrapped in quotes. #[default(true)] unquote_strings: Item, -) -> List { +) -> Result, JsonError> { let cleaned = strip_trailing_commas(json.element()); - let Ok(value): Result = serde_json::from_str(&cleaned) else { return List::new() }; - let Some(segments) = parse_json_path(path.element().trim()) else { return List::new() }; + let segments = parse_json_path(path.element().trim()).ok_or_else(|| JsonError::InvalidPath(path.element().trim().to_string()))?; + if cleaned.trim().is_empty() { + return Ok(List::new()); + } + let value: Value = serde_json::from_str(&cleaned)?; let mut results = Vec::new(); resolve_all(&value, &segments, !*unquote_strings.element(), &mut results); - results.into_iter().map(|(text, ty)| Item::new_from_element(text).with_attribute(ATTR_TYPE, ty.to_string())).collect() + Ok(results.into_iter().map(|(text, ty)| Item::new_from_element(text).with_attribute(ATTR_TYPE, ty.to_string())).collect()) } /// A parsed segment of a JSON access path. @@ -484,3 +495,24 @@ fn resolve_all(value: &serde_json::Value, segments: &[JsonPathSegment], quote_st _ => {} } } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn an_error_after_a_trailing_comma_points_into_the_input() { + assert_eq!(strip_trailing_commas("[1, \"a,\",\n]"), "[1, \"a,\" \n]"); + + let error = serde_json::from_str::(&strip_trailing_commas("{\n\t\"a\": [1,],\n\t\"b\": x\n}")).unwrap_err(); + assert_eq!((error.line(), error.column()), (3, 7)); + } + + #[test] + fn an_invalid_path_is_reported_without_json() { + let text = |text: &str| Item::new_from_element(text.to_string()); + assert!(matches!(query_json((), text(""), text(".a["), Item::new_from_element(true)), Err(JsonError::InvalidPath(_)))); + assert!(matches!(query_json_all((), text(""), text(".a["), Item::new_from_element(true)), Err(JsonError::InvalidPath(_)))); + assert!(query_json_all((), text(""), text(""), Item::new_from_element(true)).unwrap().is_empty()); + } +} diff --git a/node-graph/nodes/text/src/lib.rs b/node-graph/nodes/text/src/lib.rs index faf06e684de..d7ab88bc3aa 100644 --- a/node-graph/nodes/text/src/lib.rs +++ b/node-graph/nodes/text/src/lib.rs @@ -22,7 +22,7 @@ use unicode_segmentation::UnicodeSegmentation; pub use core_types as gcore; pub use fallback::FALLBACK_FONT_RESOURCE; pub use font::*; -pub use text_context::{TextContext, for_each_styled_glyph_run}; +pub use text_context::{TextContext, TextToVectorError, for_each_styled_glyph_run}; pub use to_path::*; pub use vector_types; @@ -247,7 +247,7 @@ fn lorem_ipsum( let rng = |n| rng.random_range(0..n); let text = ipsum::generate( - *lorem_intro.element() as usize, + usize::try_from(*lorem_intro.element()).unwrap_or(usize::MAX), (*lorem_intro_in.element()).into(), *length.element() as usize, (*length_in.element()).into(), @@ -319,7 +319,7 @@ fn string_truncate( suffix: Item, ) -> Item { let mut string = string; - let max_length = *length.element() as usize; + let max_length = usize::try_from(*length.element()).unwrap_or(usize::MAX); let grapheme_count = string.element().graphemes(true).count(); if grapheme_count <= max_length { @@ -363,7 +363,7 @@ fn format_number( ) -> Item { let (number, attributes) = number.into_parts(); let number = round_away_float_noise(number); - if number.is_infinite() { + if !number.is_finite() { return Item::from_parts(format_f64(number), attributes); } let (decimal_places, fixed_decimals, use_thousands_separator, start_at_10000) = @@ -391,15 +391,15 @@ fn format_number( let places = requested_places.min(max_places); let formatted = format!("{number:.places$}"); - // If the user requested more decimal places than the float can represent, pad with zeros - let extra_zeros = requested_places.saturating_sub(places); + // If the user requested more decimal places than the float can represent, pad with zeros, which only fixed decimals keep + let extra_zeros = if fixed_decimals { requested_places.saturating_sub(places) } else { 0 }; // Split into sign, whole, and decimal parts let (sign, unsigned) = if let Some(rest) = formatted.strip_prefix('-') { ("-", rest) } else { ("", formatted.as_str()) }; let (whole_string, decimal_string) = match unsigned.split_once('.') { Some((w, d)) => { - let padded = if extra_zeros > 0 { format!("{d}{:0>width$}", "", width = extra_zeros) } else { d.to_string() }; + let padded = format!("{d}{}", "0".repeat(extra_zeros)); (w.to_string(), Some(padded)) } None => (unsigned.to_string(), None), @@ -548,6 +548,13 @@ fn string_reverse( string } +/// A text result longer than a string can hold. +#[derive(Debug, thiserror::Error)] +pub enum TextTooLongError { + #[error("Repeating the string {0} times would be too long")] + Repeat(i64), +} + /// Repeats the string a given number of times, optionally with a separator between each repetition. #[node_macro::node(category("String"), icon("NodeStringRepeat"))] fn string_repeat( @@ -565,14 +572,21 @@ fn string_repeat( /// `\n` (newline), `\r` (carriage return), `\t` (tab), `\0` (null), and `\\` (backslash). #[default(true)] separator_escaping: Item, -) -> Item { +) -> Result, TextTooLongError> { let mut string = string; let separator = separator.element().clone(); let separator = if *separator_escaping.element() { unescape_string(separator) } else { separator }; - let count = *count.element() as usize; + if string.element().is_empty() && separator.is_empty() { + return Ok(string); + } + + let too_long = || TextTooLongError::Repeat(*count.element()); + let count = usize::try_from(*count.element()).map_err(|_| too_long())?; + let capacity = (string.element().len() + separator.len()).checked_mul(count).ok_or_else(too_long)?; - let mut result = String::with_capacity((string.element().len() + separator.len()).saturating_mul(count)); + let mut result = String::new(); + result.try_reserve_exact(capacity).map_err(|_| too_long())?; for i in 0..count { if i > 0 { result.push_str(&separator); @@ -581,7 +595,7 @@ fn string_repeat( } *string.element_mut() = result; - string + Ok(string) } /// Pads the string to a target length by filling with the given repeated substring. If the string already meets or exceeds the target length, it is returned unchanged. @@ -969,13 +983,20 @@ fn string_map( Ok(result) } +/// Read String evaluated where no loop over strings supplies one. +#[derive(Debug, thiserror::Error)] +pub enum ReadStringError { + #[error("No string to read: this node only works inside a loop over string items, like in a String Map node")] + NoLoopItem, +} + /// Reads the current string from within a **String Map** node's loop. #[node_macro::node(category("Data: Context"), icon("NodeReadContext"))] -fn read_string(ctx: impl Ctx + ExtractVarArgs) -> Item { - let Ok(var_arg) = ctx.vararg(0) else { return Item::new_from_element(String::new()) }; +fn read_string(ctx: impl Ctx + ExtractVarArgs) -> Result, ReadStringError> { + let var_arg = ctx.vararg(0).map_err(|_| ReadStringError::NoLoopItem)?; let var_arg = var_arg as &dyn std::any::Any; - var_arg.downcast_ref::>().cloned().unwrap_or_default() + var_arg.downcast_ref::>().cloned().ok_or(ReadStringError::NoLoopItem) } #[cfg(test)] @@ -1033,4 +1054,51 @@ mod tests { assert_eq!(run("__init__", CamelCase, Some("")), "init"); assert_eq!(run(" ", CapitalCase, Some("_")), ""); } + + #[test] + fn format_number_pads_many_decimal_places_and_leaves_nan_alone() { + let format = |number: f64, decimal_places: i64, fixed_decimals: bool| { + format_number( + (), + Item::new_from_element(number), + Item::new_from_element(decimal_places), + Item::new_from_element(".".to_string()), + Item::new_from_element(fixed_decimals), + Item::new_from_element(false), + Item::new_from_element(",".to_string()), + Item::new_from_element(false), + ) + .into_element() + }; + assert_eq!(format(1.5, 70_000, true).len(), "1.".len() + 70_000); + assert_eq!(format(1.5, 70_000, false), "1.5"); + assert_eq!(format(f64::NAN, 2, true), "NaN"); + } + + #[test] + fn string_repeat_refuses_results_too_long_to_hold() { + let repeat = |string: &str, count: i64, separator: &str| { + string_repeat( + (), + Item::new_from_element(string.to_string()), + Item::new_from_element(count), + Item::new_from_element(separator.to_string()), + Item::new_from_element(false), + ) + .map(Item::into_element) + }; + assert_eq!(repeat("ab", 3, ", ").unwrap(), "ab, ab, ab"); + assert!(repeat("ab", i64::MAX, "").is_err()); + assert!(repeat("", i64::MAX, ",,").is_err()); + assert_eq!(repeat("", i64::MAX, "").unwrap(), ""); + } + + #[test] + fn read_string_only_reads_inside_a_loop_over_strings() { + assert!(read_string(OwnedContextImpl::default().into_context()).is_err()); + let string_loop = OwnedContextImpl::default().with_vararg(Box::new(Item::new_from_element("a".to_string()))).into_context(); + assert_eq!(read_string(string_loop).unwrap().into_element(), "a"); + let number_loop = OwnedContextImpl::default().with_vararg(Box::new(Item::new_from_element(1_i64))).into_context(); + assert!(read_string(number_loop).is_err()); + } } diff --git a/node-graph/nodes/text/src/path_builder.rs b/node-graph/nodes/text/src/path_builder.rs index 1a9ba175b3c..a90bc8b63ca 100644 --- a/node-graph/nodes/text/src/path_builder.rs +++ b/node-graph/nodes/text/src/path_builder.rs @@ -1,3 +1,4 @@ +use super::text_context::TextToVectorError; use core_types::list::{Item, List}; use core_types::{ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_TEXT_FRAME, ATTR_TRANSFORM}; use glam::{DAffine2, DVec2}; @@ -64,10 +65,10 @@ impl PathBuilder { style_skew: Option, skew: DAffine2, per_glyph_items: bool, - ) -> bool { + ) -> Result { let location_ref = LocationRef::new(normalized_coords); let settings = DrawSettings::unhinted(Size::new(size), location_ref); - glyph.draw(settings, self).unwrap(); + glyph.draw(settings, self).map_err(TextToVectorError::UndrawableGlyph)?; let has_geometry = !self.glyph_bezpath.is_empty(); // Apply transforms in correct order: style-based skew first, then user-requested skew @@ -100,10 +101,10 @@ impl PathBuilder { } } - has_geometry + Ok(has_geometry) } - pub fn render_glyph_run(&mut self, glyph_run: &GlyphRun<'_, ()>, letter_tilt: f64, per_glyph_items: bool, x_offset: f32, space_extra: f32) { + pub fn render_glyph_run(&mut self, glyph_run: &GlyphRun<'_, ()>, letter_tilt: f64, per_glyph_items: bool, x_offset: f32, space_extra: f32) -> Result<(), TextToVectorError> { let mut run_x = glyph_run.offset() + x_offset; let run_y = glyph_run.baseline(); @@ -148,17 +149,18 @@ impl PathBuilder { run_x += glyph.advance; let glyph_id = GlyphId::from(glyph.id); - if let Some(glyph_outline) = outlines.get(glyph_id) { - if !per_glyph_items { - self.origin = glyph_offset; - } - let drew_geometry = self.draw_glyph(&glyph_outline, font_size, &normalized_coords, glyph_offset, style_skew, skew, per_glyph_items); - - if !drew_geometry && space_extra != 0. && glyph.advance > 0. { - run_x += space_extra; - } + let glyph_outline = outlines.get(glyph_id).ok_or(TextToVectorError::MissingOutline)?; + if !per_glyph_items { + self.origin = glyph_offset; + } + let drew_geometry = self.draw_glyph(&glyph_outline, font_size, &normalized_coords, glyph_offset, style_skew, skew, per_glyph_items)?; + + if !drew_geometry && space_extra != 0. && glyph.advance > 0. { + run_x += space_extra; } } + + Ok(()) } pub fn finalize(mut self) -> List { diff --git a/node-graph/nodes/text/src/regex.rs b/node-graph/nodes/text/src/regex.rs index 2ad12ce43f6..5277e3bd07b 100644 --- a/node-graph/nodes/text/src/regex.rs +++ b/node-graph/nodes/text/src/regex.rs @@ -1,5 +1,82 @@ use core_types::list::{Item, List}; use core_types::{ATTR_END, ATTR_NAME, ATTR_START, Ctx}; +use fancy_regex::{Expr, Regex}; + +/// Why a Regex node's pattern gives no result. +#[derive(Debug, thiserror::Error)] +pub enum RegexError { + #[error("The regex pattern is invalid: {0}")] + InvalidPattern(String), + #[error("The regex pattern can't use `\\K` inside a lookaround")] + KeepOutInLookaround, + #[error("The regex pattern takes too many steps on this string ({0})")] + TooComplex(fancy_regex::RuntimeError), +} + +impl From for RegexError { + fn from(error: fancy_regex::Error) -> Self { + // Leaves out the positions and quoted pattern the regex crates add, which count the flags prepended to the user's pattern + let reason = match error { + fancy_regex::Error::RuntimeError(error) => return Self::TooComplex(error), + fancy_regex::Error::ParseError(_, error) => error.to_string(), + fancy_regex::Error::CompileError(error) => match *error { + fancy_regex::CompileError::InnerError(error) => { + let reason = std::error::Error::source(&error).map_or_else(|| error.to_string(), ToString::to_string); + reason.lines().find_map(|line| line.strip_prefix("error: ")).map_or_else(|| reason.clone(), str::to_string) + } + error => error.to_string(), + }, + error => error.to_string(), + }; + Self::InvalidPattern(reason) + } +} + +fn compile(pattern: &str, case_insensitive: bool, multiline: bool) -> Result { + let flags = match (case_insensitive, multiline) { + (false, false) => "", + (true, false) => "(?i)", + (false, true) => "(?m)", + (true, true) => "(?im)", + }; + Ok(Regex::new(&format!("{flags}{pattern}"))?) +} + +/// Refuses a `\K` inside a lookaround for the nodes that slice the string between matches, which panics in fancy-regex once a match starts before the previous one ended. +fn refuse_keep_out_in_lookaround(pattern: &str) -> Result<(), RegexError> { + let pattern = Expr::parse_tree(pattern)?.expr; + + fn push_groups<'a>(expr: &'a Expr, groups: &mut Vec<&'a Expr>) { + if let Expr::Group(inner) = expr { + groups.push(inner.as_ref()); + } + for child in expr.children_iter() { + push_groups(child, groups); + } + } + let mut groups = vec![&pattern]; + push_groups(&pattern, &mut groups); + + // A subroutine call runs its group's `\K` where the call is, so a call inside a lookaround counts as a `\K` there + let runs_keep_out = |groups_running: &[bool], expr: &Expr| { + let runs = |expr: &Expr| matches!(expr, Expr::KeepOut) || matches!(expr, Expr::SubroutineCall(group) if groups_running.get(*group) == Some(&true)); + runs(expr) || expr.has_descendant(runs) + }; + let mut groups_running = vec![false; groups.len()]; + loop { + let next: Vec = groups.iter().map(|&group| runs_keep_out(&groups_running, group)).collect(); + if next == groups_running { + break; + } + groups_running = next; + } + + let keep_out_in_lookaround = |expr: &Expr| matches!(expr, Expr::LookAround(..)) && runs_keep_out(&groups_running, expr); + if keep_out_in_lookaround(&pattern) || pattern.has_descendant(keep_out_in_lookaround) { + return Err(RegexError::KeepOutInLookaround); + } + Ok(()) +} /// Checks whether the string contains a match for the given regular expression pattern. Optionally restricts the match to only the start and/or end of the string. #[node_macro::node(category("String: Regex"), icon("NodeRegexContains"))] @@ -17,30 +94,22 @@ fn regex_contains( at_start: Item, /// Only match if the pattern appears at the end of the string. at_end: Item, -) -> Item { +) -> Result, RegexError> { let (string, attributes) = string.into_parts(); let pattern = pattern.element(); let (case_insensitive, multiline, at_start, at_end) = (*case_insensitive.element(), *multiline.element(), *at_start.element(), *at_end.element()); - let flags = match (case_insensitive, multiline) { - (false, false) => "", - (true, false) => "(?i)", - (false, true) => "(?m)", - (true, true) => "(?im)", - }; - let anchored_pattern = match (at_start, at_end) { - (true, true) => format!("{flags}\\A(?:{pattern})\\z"), - (true, false) => format!("{flags}\\A(?:{pattern})"), - (false, true) => format!("{flags}(?:{pattern})\\z"), - (false, false) => format!("{flags}{pattern}"), - }; - - let Ok(regex) = fancy_regex::Regex::new(&anchored_pattern) else { - log::error!("Invalid regex pattern: {pattern}"); - return Item::from_parts(false, attributes); + let regex = if at_start || at_end { + // Parsing the pattern alone keeps a stray `)` in it from closing the anchoring group + Expr::parse_tree(pattern)?; + let (start, end) = (if at_start { "\\A" } else { "" }, if at_end { "\\z" } else { "" }); + // The newline ends a trailing `(?x)` comment, which would swallow the group's `)`, and the `(?x)` makes the newline whitespace otherwise + compile(&format!("{start}(?:{pattern}(?x)\n){end}"), case_insensitive, multiline)? + } else { + compile(pattern, case_insensitive, multiline)? }; - Item::from_parts(regex.is_match(&string).unwrap_or(false), attributes) + Ok(Item::from_parts(regex.is_match(&string)?, attributes)) } /// Replaces matches of a regular expression pattern in the string. The replacement string can reference captures: `$0` for the whole match and `$1`, `$2`, etc. for capture groups. @@ -59,33 +128,20 @@ fn regex_replace( case_insensitive: Item, /// Make `^` and `$` match the start and end of each line, not just the whole string. multiline: Item, -) -> Item { +) -> Result, RegexError> { let mut string = string; let pattern = pattern.element().clone(); let replacement = replacement.element().clone(); let (replace_all, case_insensitive, multiline) = (*replace_all.element(), *case_insensitive.element(), *multiline.element()); - let flags = match (case_insensitive, multiline) { - (false, false) => "", - (true, false) => "(?i)", - (false, true) => "(?m)", - (true, true) => "(?im)", - }; - let full_pattern = format!("{flags}{pattern}"); + refuse_keep_out_in_lookaround(&pattern)?; + let regex = compile(&pattern, case_insensitive, multiline)?; - let Ok(regex) = fancy_regex::Regex::new(&full_pattern) else { - log::warn!("Invalid regex pattern: {pattern}"); - return string; - }; - - let result = if replace_all { - regex.replace_all(string.element(), replacement.as_str()).into_owned() - } else { - regex.replace(string.element(), replacement.as_str()).into_owned() - }; + let limit = if replace_all { 0 } else { 1 }; + let result = regex.try_replacen(string.element(), limit, replacement.as_str())?.into_owned(); *string.element_mut() = result; - string + Ok(string) } /// Finds a regex match in the string and returns its components. The result is a list where the first item is the whole match (`$0`) and subsequent items are the capture groups (`$1`, `$2`, etc., if any). @@ -107,38 +163,27 @@ fn regex_find( case_insensitive: Item, /// Make `^` and `$` match the start and end of each line, not just the whole string. multiline: Item, -) -> List { +) -> Result, RegexError> { let string = string.element(); let pattern = pattern.element(); let (match_index, case_insensitive, multiline) = (*match_index.element(), *case_insensitive.element(), *multiline.element()); if pattern.is_empty() { - return List::new(); + return Ok(List::new()); } - let flags = match (case_insensitive, multiline) { - (false, false) => "", - (true, false) => "(?i)", - (false, true) => "(?m)", - (true, true) => "(?im)", - }; - let full_pattern = format!("{flags}{pattern}"); - - let Ok(regex) = fancy_regex::Regex::new(&full_pattern) else { - log::error!("Invalid regex pattern: {pattern}"); - return List::new(); - }; + let regex = compile(pattern, case_insensitive, multiline)?; // Capture group names indexed positionally; index 0 (the whole match) is always None. let capture_names: Vec> = regex.capture_names().map(|name| name.map(str::to_string)).collect(); // Collect all matches since we need to support negative indexing - let matches: Vec<_> = regex.captures_iter(string).filter_map(|c| c.ok()).collect(); + let matches = regex.captures_iter(string).collect::, _>>()?; let resolved_index = if match_index < 0 { let from_end = match_index.unsigned_abs(); if from_end > matches.len() as u64 { - return List::new(); + return Ok(List::new()); } matches.len() - from_end as usize } else { @@ -146,11 +191,11 @@ fn regex_find( }; let Some(captures) = matches.get(resolved_index) else { - return List::new(); + return Ok(List::new()); }; // Index 0 is the whole match, 1+ are capture groups - (0..captures.len()) + Ok((0..captures.len()) .map(|i| { let captured = captures.get(i); let text = captured.map_or(String::new(), |m| m.as_str().to_string()); @@ -162,7 +207,7 @@ fn regex_find( .with_attribute(ATTR_END, end) .with_attribute(ATTR_NAME, name) }) - .collect() + .collect()) } /// Finds all non-overlapping matches of a regular expression pattern in the string, returning a list of the matched substrings. @@ -179,35 +224,24 @@ fn regex_find_all( case_insensitive: Item, /// Make `^` and `$` match the start and end of each line, not just the whole string. multiline: Item, -) -> List { +) -> Result, RegexError> { let string = string.element(); let pattern = pattern.element(); let (case_insensitive, multiline) = (*case_insensitive.element(), *multiline.element()); if pattern.is_empty() { - return List::new(); + return Ok(List::new()); } - let flags = match (case_insensitive, multiline) { - (false, false) => "", - (true, false) => "(?i)", - (false, true) => "(?m)", - (true, true) => "(?im)", - }; - let full_pattern = format!("{flags}{pattern}"); - - let Ok(regex) = fancy_regex::Regex::new(&full_pattern) else { - log::error!("Invalid regex pattern: {pattern}"); - return List::new(); - }; + let regex = compile(pattern, case_insensitive, multiline)?; regex .find_iter(string) - .filter_map(|m| m.ok()) .map(|m| { - Item::new_from_element(m.as_str().to_string()) + let m = m?; + Ok(Item::new_from_element(m.as_str().to_string()) .with_attribute(ATTR_START, m.start() as i64) - .with_attribute(ATTR_END, m.end() as i64) + .with_attribute(ATTR_END, m.end() as i64)) }) .collect() } @@ -226,26 +260,16 @@ fn regex_split( case_insensitive: Item, /// Make `^` and `$` match the start and end of each line, not just the whole string. multiline: Item, -) -> List { +) -> Result, RegexError> { let pattern = pattern.element().clone(); let (case_insensitive, multiline) = (*case_insensitive.element(), *multiline.element()); if pattern.is_empty() { - return List::new_from_item(string); + return Ok(List::new_from_item(string)); } - let flags = match (case_insensitive, multiline) { - (false, false) => "", - (true, false) => "(?i)", - (false, true) => "(?m)", - (true, true) => "(?im)", - }; - let full_pattern = format!("{flags}{pattern}"); - - let Ok(regex) = fancy_regex::Regex::new(&full_pattern) else { - log::error!("Invalid regex pattern: {pattern}"); - return List::new_from_item(string); - }; + refuse_keep_out_in_lookaround(&pattern)?; + let regex = compile(&pattern, case_insensitive, multiline)?; - regex.split(string.element()).filter_map(|s| s.ok()).map(|s| s.to_string()).map(Item::new_from_element).collect() + regex.split(string.element()).map(|part| Ok(Item::new_from_element(part?.to_string()))).collect() } diff --git a/node-graph/nodes/text/src/text_context.rs b/node-graph/nodes/text/src/text_context.rs index c5820353572..140efc6e4a3 100644 --- a/node-graph/nodes/text/src/text_context.rs +++ b/node-graph/nodes/text/src/text_context.rs @@ -13,6 +13,19 @@ thread_local! { static THREAD_TEXT: RefCell = RefCell::new(TextContext::default()); } +/// Why a styled string can't be converted to vector paths. +#[derive(Debug, thiserror::Error)] +pub enum TextToVectorError { + #[error("The font is not a readable font file")] + UnreadableFont, + #[error("The font has a glyph that can't be drawn: {0}")] + UndrawableGlyph(#[source] skrifa::outline::DrawError), + #[error("The font has no vector outlines for some glyphs")] + MissingOutline, + #[error("The `{0}` attribute has the wrong type to use for text")] + MistypedAttribute(&'static str), +} + /// Iterates the glyph runs of a laid-out text in reading order, computing each line's last-line alignment correction /// (`x_offset` and per-space `space_extra`) and skipping runs clipped by `max_height`. Shared by the vector shaper and the /// SVG/Vello text renderers so the alignment logic lives in one place. @@ -131,10 +144,8 @@ impl TextContext { } /// Convert text to vector paths using the specified font and typesetting configuration - pub fn to_path(&mut self, text: &str, font: &Resource, typesetting: TypesettingConfig, per_glyph_items: bool) -> List { - let Some(layout) = self.layout_text(text, font, typesetting) else { - return List::new_from_element(Vector::default()); - }; + pub fn to_path(&mut self, text: &str, font: &Resource, typesetting: TypesettingConfig, per_glyph_items: bool) -> Result, TextToVectorError> { + let layout = self.layout_text(text, font, typesetting).ok_or(TextToVectorError::UnreadableFont)?; let text_frame_size = DVec2::new( typesetting.max_width.unwrap_or_else(|| layout.full_width() as f64), @@ -154,11 +165,15 @@ impl TextContext { let mut path_builder = PathBuilder::new(per_glyph_items, layout.scale() as f64, text_frame_size, first_glyph_offset); + let mut drawn = Ok(()); for_each_styled_glyph_run(&layout, text, typesetting, |glyph_run, x_offset, space_extra| { - path_builder.render_glyph_run(glyph_run, typesetting.letter_tilt, per_glyph_items, x_offset, space_extra); + if drawn.is_ok() { + drawn = path_builder.render_glyph_run(glyph_run, typesetting.letter_tilt, per_glyph_items, x_offset, space_extra); + } }); + drawn?; - path_builder.finalize() + Ok(path_builder.finalize()) } /// Calculate the bounding box of text using the specified font and typesetting configuration diff --git a/node-graph/nodes/text/src/to_path.rs b/node-graph/nodes/text/src/to_path.rs index f149443ba7a..5b429883ccf 100644 --- a/node-graph/nodes/text/src/to_path.rs +++ b/node-graph/nodes/text/src/to_path.rs @@ -1,5 +1,5 @@ use super::TypesettingConfig; -use super::text_context::TextContext; +use super::text_context::{TextContext, TextToVectorError}; use core_types::blending::BlendMode; use core_types::list::{Item, List, NodeIdPath}; use core_types::{ @@ -10,7 +10,7 @@ use glam::{DAffine2, DVec2}; use graphene_resource::Resource; use vector_types::Vector; -pub fn to_path(text: &str, font: &Resource, typesetting: TypesettingConfig, per_glyph_items: bool) -> List { +pub fn to_path(text: &str, font: &Resource, typesetting: TypesettingConfig, per_glyph_items: bool) -> Result, TextToVectorError> { TextContext::with_thread_local(|ctx| ctx.to_path(text, font, typesetting, per_glyph_items)) } @@ -22,33 +22,37 @@ pub fn lines_clipping(text: &str, font: &Resource, typesetting: TypesettingConfi TextContext::with_thread_local(|ctx| ctx.lines_clipping(text, font, typesetting)) } +/// The item's typography attribute, `None` when it is absent, or an error when it holds a type that text doesn't read. +fn typography_attribute(item: &Item, key: &'static str) -> Result, TextToVectorError> { + let Some(value) = item.attributes().get_any(key) else { return Ok(None) }; + value.downcast_ref::().cloned().map(Some).ok_or(TextToVectorError::MistypedAttribute(key)) +} + /// Shapes a single styled string item into vector geometry, reading its font and typesetting from the item's /// attributes (as set by the 'Text' node) and re-applying its transform and blending attributes onto the produced /// paths. With `separate_glyphs`, each glyph becomes its own item; otherwise a single compound path is produced. -pub fn shape_text_item(item: &Item, separate_glyphs: bool) -> List { +pub fn shape_text_item(item: &Item, separate_glyphs: bool) -> Result, TextToVectorError> { let text = item.element(); if text.is_empty() { - return List::new(); + return Ok(List::new()); } // Use fallback font when none is explicitly attached. - let font: Resource = { - let font: Resource = item.attribute_cloned_or_default(ATTR_FONT); - if font.is_empty() { super::FALLBACK_FONT_RESOURCE.clone() } else { font } - }; + let font = typography_attribute::(item, ATTR_FONT)?.filter(|font| !font.is_empty()); + let font = font.unwrap_or_else(|| super::FALLBACK_FONT_RESOURCE.clone()); let defaults = TypesettingConfig::default(); let typesetting = TypesettingConfig { - font_size: item.attribute_cloned_or(ATTR_FONT_SIZE, defaults.font_size), - line_height_ratio: item.attribute_cloned_or(ATTR_LINE_HEIGHT, defaults.line_height_ratio), - letter_spacing: item.attribute_cloned_or(ATTR_LETTER_SPACING, defaults.letter_spacing), - letter_tilt: item.attribute_cloned_or(ATTR_LETTER_TILT, defaults.letter_tilt), - max_width: item.attribute::(ATTR_MAX_WIDTH).copied().filter(|width| *width > 0.), - max_height: item.attribute::(ATTR_MAX_HEIGHT).copied().filter(|height| *height > 0.), - align: item.attribute_cloned_or(ATTR_TEXT_ALIGN, defaults.align), + font_size: typography_attribute(item, ATTR_FONT_SIZE)?.unwrap_or(defaults.font_size), + line_height_ratio: typography_attribute(item, ATTR_LINE_HEIGHT)?.unwrap_or(defaults.line_height_ratio), + letter_spacing: typography_attribute(item, ATTR_LETTER_SPACING)?.unwrap_or(defaults.letter_spacing), + letter_tilt: typography_attribute(item, ATTR_LETTER_TILT)?.unwrap_or(defaults.letter_tilt), + max_width: typography_attribute::(item, ATTR_MAX_WIDTH)?.filter(|width| *width > 0.), + max_height: typography_attribute::(item, ATTR_MAX_HEIGHT)?.filter(|height| *height > 0.), + align: typography_attribute(item, ATTR_TEXT_ALIGN)?.unwrap_or(defaults.align), }; - let vectors = to_path(text, &font, typesetting, separate_glyphs); + let vectors = to_path(text, &font, typesetting, separate_glyphs)?; let transform = item.attribute_cloned_or_default::(ATTR_TRANSFORM); let layer_path = item.attribute::(ATTR_EDITOR_LAYER_PATH).cloned(); let blend_mode = item.attribute::(ATTR_BLEND_MODE).copied(); @@ -76,19 +80,19 @@ pub fn shape_text_item(item: &Item, separate_glyphs: bool) -> List` into vector geometry, flattening the per-item results. -pub fn shape_text_list(strings: &List, separate_glyphs: bool) -> List { +pub fn shape_text_list(strings: &List, separate_glyphs: bool) -> Result, TextToVectorError> { let mut result = List::new(); for index in 0..strings.len() { let Some(item) = strings.clone_item(index) else { continue }; - for produced in shape_text_item(&item, separate_glyphs).into_iter() { + for produced in shape_text_item(&item, separate_glyphs)?.into_iter() { result.push(produced); } } - result + Ok(result) } From bd97c9ac76df8ef268b5a5a5fc8bd10458a909d7 Mon Sep 17 00:00:00 2001 From: Timon Date: Tue, 27 Oct 2026 15:04:43 +0000 Subject: [PATCH 3/8] Allow lazy scope inputs --- node-graph/node-macro/src/codegen.rs | 44 ++++++++++++++-------------- node-graph/node-macro/src/parsing.rs | 36 +++++++++++++++++------ 2 files changed, 49 insertions(+), 31 deletions(-) diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index 1031c295c56..28e920009ad 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -197,28 +197,25 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let value_sources: Vec<_> = regular_fields .iter() - .map(|field| match field.ty.regular() { - Some(RegularParsedField { value_source, .. }) => match value_source { - ParsedValueSource::Default(data) => { - // Check if the data is a string literal by parsing the token stream - let data_str = data.to_string(); - if data_str.starts_with('"') && data_str.ends_with('"') && data_str.len() >= 2 { - quote!(RegistryValueSource::Default(#data)) - } else { - quote!(RegistryValueSource::Default(stringify!(#data))) - } + .map(|field| match field.ty.value_source() { + ParsedValueSource::Default(data) => { + // Check if the data is a string literal by parsing the token stream + let data_str = data.to_string(); + if data_str.starts_with('"') && data_str.ends_with('"') && data_str.len() >= 2 { + quote!(RegistryValueSource::Default(#data)) + } else { + quote!(RegistryValueSource::Default(stringify!(#data))) } - ParsedValueSource::Scope(data) => { - if let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Str(_), .. }) = data.as_ref() { - quote!(RegistryValueSource::Scope(#data)) - } else { - quote!(RegistryValueSource::Scope(#data.as_static_str())) - } + } + ParsedValueSource::Scope(data) => { + if let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Str(_), .. }) = data.as_ref() { + quote!(RegistryValueSource::Scope(#data)) + } else { + quote!(RegistryValueSource::Scope(#data.as_static_str())) } - ParsedValueSource::SourceId => quote!(RegistryValueSource::SourceId), - _ => quote!(RegistryValueSource::None), - }, - None => quote!(RegistryValueSource::None), + } + ParsedValueSource::SourceId => quote!(RegistryValueSource::SourceId), + _ => quote!(RegistryValueSource::None), }) .collect(); @@ -335,7 +332,10 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn .iter() .map(|field| match field.ty.regular() { Some(RegularParsedField { exposed, .. }) => quote!(#exposed), - None => quote!(true), + None => { + let exposed = !field.is_environment(); + quote!(#exposed) + } }) .collect(); @@ -732,7 +732,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn // The frame index is stamped onto each slot's context so lazy connectors can generate uniquely per slot. // A `()` generator frames purely over its param values (no stamp), so its kernel needs no context-extraction bounds. - let has_lazy_connectors = regular_fields.iter().any(|field| matches!(&field.ty, ParsedFieldType::Node(_))); + let has_lazy_connectors = regular_fields.iter().any(|field| matches!(&field.ty, ParsedFieldType::Node(_)) && !field.is_environment()); let slot_context = match has_lazy_connectors { true => quote!(#core_types::OwnedContextImpl::from(__input.clone()).with_index(__slot_index).into_context()), false => quote!(__input.clone()), diff --git a/node-graph/node-macro/src/parsing.rs b/node-graph/node-macro/src/parsing.rs index a3174df2c8f..fddfc8d1bf1 100644 --- a/node-graph/node-macro/src/parsing.rs +++ b/node-graph/node-macro/src/parsing.rs @@ -180,14 +180,7 @@ impl ParsedField { /// Whether the field is environment rather than an argument: `#[data]` state or a `#[scope]`-injected wire. /// Environment fields never classify the node, never supply the element-wise frame, and broadcast by clone. pub(crate) fn is_environment(&self) -> bool { - self.is_data_field - || matches!( - self.ty.regular(), - Some(RegularParsedField { - value_source: ParsedValueSource::Scope(_) | ParsedValueSource::SourceId, - .. - }) - ) + self.is_data_field || matches!(self.ty.value_source(), ParsedValueSource::Scope(_) | ParsedValueSource::SourceId) } } @@ -251,6 +244,13 @@ impl ParsedFieldType { } } + pub fn value_source(&self) -> &ParsedValueSource { + match self { + ParsedFieldType::Regular(field) | ParsedFieldType::Item { field, .. } | ParsedFieldType::List { field, .. } => &field.value_source, + ParsedFieldType::Node(field) => &field.value_source, + } + } + /// The element type `T` of a rank-0 `Item` field, or `None` for any other shape. pub fn item_element(&self) -> Option<&Type> { match self { @@ -403,6 +403,8 @@ pub struct NodeParsedField { /// The peeled `Item` element of `output_type`, if the lazy input's `Output` is declared `Item`. pub output_element: Option, pub implementations: Punctuated, + /// Either `None` or the `#[scope]` wire the lazy input reads. + pub value_source: ParsedValueSource, } #[derive(Clone, Debug)] @@ -1018,7 +1020,7 @@ fn parse_field(pat_ident: PatIdent, ty: Type, attrs: &[Attribute]) -> syn::Resul let (input_type, output_type) = node_input_type .zip(node_output_type) .ok_or_else(|| Error::new_spanned(&ty, "Invalid Node type. Expected `impl Node`"))?; - if !matches!(&value_source, ParsedValueSource::None) { + if !matches!(&value_source, ParsedValueSource::None | ParsedValueSource::Scope(_)) { return Err(Error::new_spanned(&ty, "No default values for `impl Node` allowed")); } let implementations = extract_attribute(attrs, "implementations") @@ -1035,6 +1037,7 @@ fn parse_field(pat_ident: PatIdent, ty: Type, attrs: &[Attribute]) -> syn::Resul output_type, output_element, implementations, + value_source, }), name, description, @@ -1518,6 +1521,7 @@ mod tests { output_type: parse_quote!(T), output_element: None, implementations: Punctuated::new(), + value_source: ParsedValueSource::None, }), number_display_decimal_places: None, number_step: None, @@ -1917,6 +1921,20 @@ mod tests { parse_node_fn(attr, input).unwrap(); } + #[test] + fn test_scope_for_impl_node() { + let attr = quote!(category("")); + let input = quote!( + fn scoped_node(_: impl Ctx, content: Item, #[scope("pipeline")] pipeline: impl Node, Output = Item>) -> Item { + content + } + ); + let parsed = parse_node_fn(attr, input).unwrap(); + let pipeline = &parsed.fields[1]; + assert!(matches!(pipeline.ty.value_source(), ParsedValueSource::Scope(_))); + assert!(pipeline.is_environment()); + } + #[test] #[should_panic(expected = "Data fields (#[data]) cannot be #[constant]")] fn test_constant_data_field() { From fa18e65299cdfcd3b52e0abccf7ec270e7e1e7aa Mon Sep 17 00:00:00 2001 From: Timon Date: Tue, 27 Oct 2026 15:07:58 +0000 Subject: [PATCH 4/8] Report a missing GPU instead of panicking --- Cargo.lock | 1 + .../interpreted-executor/src/node_registry.rs | 2 - node-graph/interpreted-executor/src/util.rs | 4 +- node-graph/libraries/wgpu-executor/Cargo.toml | 1 + node-graph/libraries/wgpu-executor/src/lib.rs | 4 ++ node-graph/node-macro/src/parsing.rs | 1 - .../nodes/gstd/src/platform_application_io.rs | 17 +------- .../nodes/gstd/src/render_background.rs | 18 ++++----- node-graph/nodes/gstd/src/render_cache.rs | 40 ++++++++++++++----- node-graph/nodes/gstd/src/render_node.rs | 14 +++---- .../nodes/gstd/src/render_pixel_preview.rs | 10 ++--- 11 files changed, 58 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 14525feeff0..639d0c5b6ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7307,6 +7307,7 @@ dependencies = [ "node-macro", "raster-types", "rendering", + "thiserror 2.0.18", "vello_gpu", "web-sys", "wgpu", diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 6cad5f0be45..847219b5932 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -93,7 +93,6 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Item, Context => Item]), async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Item, Context => Item]), async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Item, Context => Item]), - async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Item>, Context => Item]), async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Item, Context => Item]), // ========== // MEMO NODES @@ -147,7 +146,6 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), - async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item>]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), ]; // The per-connector input adapter, registered per element type: an `Item` or `List` wire passes through unchanged. diff --git a/node-graph/interpreted-executor/src/util.rs b/node-graph/interpreted-executor/src/util.rs index 5202bedb821..19e4afcf2a9 100644 --- a/node-graph/interpreted-executor/src/util.rs +++ b/node-graph/interpreted-executor/src/util.rs @@ -40,7 +40,7 @@ pub fn wrap_network_in_scope(network: NodeNetwork, editor_api: Arc`. pub output_element: Option, pub implementations: Punctuated, - /// Either `None` or the `#[scope]` wire the lazy input reads. pub value_source: ParsedValueSource, } diff --git a/node-graph/nodes/gstd/src/platform_application_io.rs b/node-graph/nodes/gstd/src/platform_application_io.rs index 67e5a44a110..9bd5d2f0344 100644 --- a/node-graph/nodes/gstd/src/platform_application_io.rs +++ b/node-graph/nodes/gstd/src/platform_application_io.rs @@ -278,22 +278,9 @@ fn pending_stored_resource(_editor_api: &Item>, _hash: &I } #[node_macro::node(category(""), inject_scope)] -fn wgpu_executor(_: impl Ctx, #[scope(editor_api::IDENTIFIER)] editor_api: Item>) -> Item<::wgpu_executor::WgpuExecutor> { - let executor = editor_api - .into_element() - .application_io - .as_ref() - .expect("ApplicationIo not available") - .gpu_executor() - .expect("GPU executor not available") - .clone(); - Item::new_from_element(executor) -} - -#[node_macro::node(category(""), inject_scope)] -fn try_wgpu_executor(_: impl Ctx, #[scope(editor_api::IDENTIFIER)] editor_api: Item>) -> Item> { +fn wgpu_executor(_: impl Ctx, #[scope(editor_api::IDENTIFIER)] editor_api: Item>) -> Result, ::wgpu_executor::NoGpuError> { let executor = editor_api.into_element().application_io.as_ref().and_then(|application_io| application_io.gpu_executor()).cloned(); - Item::new_from_element(executor) + executor.map(Item::new_from_element).ok_or(::wgpu_executor::NoGpuError) } /// Uploads image data from CPU memory into a GPU texture so that GPU-based nodes can process it. diff --git a/node-graph/nodes/gstd/src/render_background.rs b/node-graph/nodes/gstd/src/render_background.rs index 213bd38f04b..e60f8897908 100644 --- a/node-graph/nodes/gstd/src/render_background.rs +++ b/node-graph/nodes/gstd/src/render_background.rs @@ -3,7 +3,7 @@ use core_types::color::Linear; use core_types::list::Item; use core_types::transform::Footprint; use core_types::uuid::generate_uuid; -use core_types::{Ctx, ExtractFootprint}; +use core_types::{Context, Ctx, ExtractFootprint, Interrupt}; use glam::{Affine2, UVec2, Vec2}; use graph_craft::document::value::{RenderOutput, RenderOutputType}; use graphic_types::raster_types::Texture; @@ -14,9 +14,9 @@ use wgpu_executor::{Buffer, WgpuExecutor, WgpuPipeline, WgpuPipelineCache}; #[node_macro::node(category(""))] fn render_background( ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, - #[scope(composite_background_pipeline::IDENTIFIER)] pipeline: Item, + #[scope(composite_background_pipeline::IDENTIFIER)] pipeline: impl Node, Output = Item>, data: Item, -) -> Item { +) -> Result, Interrupt> { let footprint = ctx.footprint(); let render_params = ctx .vararg(0) @@ -25,7 +25,7 @@ fn render_background( .expect("Downcasting render params yielded invalid type"); if !render_params.to_canvas() || render_params.viewport_zoom.unwrap_or(1.) <= 0.0 { - return data; + return Ok(data); } let RenderOutput { data: foreground_data, metadata } = data.into_element(); @@ -35,7 +35,7 @@ fn render_background( let data = match foreground_data { RenderOutputType::Texture(foreground_texture) => { let doc_to_screen = render_params.footprint.transform.as_affine2(); - let blended = pipeline.into_element().run::(&CompositeBackgroundArgs { + let blended = pipeline.eval(Context::default())?.into_element().run::(&CompositeBackgroundArgs { foreground: foreground_texture.as_ref(), backgrounds: &metadata.backgrounds, document_to_screen: doc_to_screen, @@ -115,18 +115,16 @@ fn render_background( _ => unreachable!("Render background node received unsupported render output type"), }; - Item::new_from_element(RenderOutput { data, metadata }) + Ok(Item::new_from_element(RenderOutput { data, metadata })) } #[node_macro::node(category(""), inject_scope)] fn composite_background_pipeline( _ctx: impl Ctx, - #[scope(crate::platform_application_io::try_wgpu_executor::IDENTIFIER)] executor: Item>, + #[scope(crate::platform_application_io::wgpu_executor::IDENTIFIER)] executor: Item, #[data] pipeline: WgpuPipelineCache, ) -> Item { - if let Some(executor) = executor.into_element() { - executor.pipeline_init::(pipeline); - } + executor.into_element().pipeline_init::(pipeline); Item::new_from_element(pipeline.clone()) } diff --git a/node-graph/nodes/gstd/src/render_cache.rs b/node-graph/nodes/gstd/src/render_cache.rs index 0feb0ec295e..845db394aca 100644 --- a/node-graph/nodes/gstd/src/render_cache.rs +++ b/node-graph/nodes/gstd/src/render_cache.rs @@ -3,7 +3,7 @@ use core_types::list::Item; use core_types::math::bbox::AxisAlignedBbox; use core_types::transform::{Footprint, RenderQuality, Transform}; -use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, ExtractAnimationTime, ExtractPointerPosition, ExtractRealTime, Generations, OwnedContextImpl, Poll}; +use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, ExtractAnimationTime, ExtractPointerPosition, ExtractRealTime, Generations, NodeError, OwnedContextImpl, Poll}; use glam::{DAffine2, DVec2, IVec2, UVec2}; use graph_craft::document::value::{RenderOutput, RenderOutputType}; use rendering::{RenderOutputType as RenderOutputTypeRequest, RenderParams}; @@ -320,7 +320,7 @@ fn split_oversized_region(region: RenderRegion, max_region_size: u32) -> Vec>, + #[scope(crate::platform_application_io::wgpu_executor::IDENTIFIER)] executor: impl Node, Output = Item>, data: impl Node, Output = Item> + Send + Sync, #[data] tile_cache: TileCache, ) -> Poll> { @@ -335,10 +335,25 @@ fn render_output_cache( // Fall back to direct render for non-Vello or zero-size viewports let physical_resolution = footprint.resolution; - let executor = executor.into_element(); - let Some(executor) = executor.filter(|_| matches!(render_params.render_output_type, RenderOutputTypeRequest::Vello) && physical_resolution.x > 0 && physical_resolution.y > 0) else { + if !matches!(render_params.render_output_type, RenderOutputTypeRequest::Vello) || physical_resolution.x == 0 || physical_resolution.y == 0 { let context = OwnedContextImpl::from(ctx.clone()).with_footprint(*footprint).with_vararg(Box::new(render_params.clone())); return data.eval(&context.into_context()); + } + + let (executor, executor_partial, executor_error) = match executor.eval(&Context::default()) { + Poll::Final(executor) => (executor.into_element(), false, None), + Poll::Partial(executor) => (executor.into_element(), true, None), + Poll::Fallback(boxed) => { + let (executor, error) = *boxed; + (executor.into_element(), false, Some(error)) + } + Poll::Pending => return Poll::Pending, + Poll::Error(error) => return Poll::Error(error), + }; + let finish = |output, partial, error: Option| match (partial || executor_partial, error.or(executor_error)) { + (true, _) => Poll::Partial(output), + (false, Some(error)) => Poll::Fallback(Box::new((output, error))), + (false, None) => Poll::Final(output), }; let zoom = footprint.scale_magnitudes().x; @@ -365,7 +380,16 @@ fn render_output_cache( max_region_size, &executor, ); - return output.map(Item::new_from_element); + return match output.map(Item::new_from_element) { + Poll::Final(output) => finish(output, false, None), + Poll::Partial(output) => finish(output, true, None), + Poll::Fallback(boxed) => { + let (output, error) = *boxed; + finish(output, false, Some(error)) + } + Poll::Pending => Poll::Pending, + Poll::Error(error) => Poll::Error(error), + }; } let cache_key = CacheKey::new( @@ -418,11 +442,7 @@ fn render_output_cache( data: RenderOutputType::Texture(output_texture), metadata: combined_metadata, }); - match (partial, error) { - (true, _) => Poll::Partial(output), - (false, Some(error)) => Poll::Fallback(Box::new((output, error))), - (false, None) => Poll::Final(output), - } + finish(output, partial, error) } fn render_missing_region( diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index 97a181596a7..b8f4822911d 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -1,7 +1,7 @@ use core_types::list::{Item, List}; use core_types::transform::{Footprint, Transform}; use core_types::{CloneVarArgs, ExtractAll, ExtractVarArgs}; -use core_types::{Color, Context, Ctx, ExtractFootprint, Interrupt, OwnedContextImpl, WasmNotSend}; +use core_types::{Color, Context, Ctx, ExtractFootprint, Interrupt, NodeError, OwnedContextImpl, WasmNotSend}; use graph_craft::document::value::{RenderOutput, RenderOutputType}; use graphene_application_io::{ExportFormat, RenderConfig}; use graphic_types::raster_types::{CPU, Raster}; @@ -73,9 +73,9 @@ fn render_intermediate( #[node_macro::node(category(""))] fn render( ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, - #[scope(crate::platform_application_io::try_wgpu_executor::IDENTIFIER)] executor: Item>, + #[scope(crate::platform_application_io::wgpu_executor::IDENTIFIER)] executor: impl Node, Output = Item>, data: Item, -) -> Item { +) -> Result, Interrupt> { let footprint = ctx.footprint(); let render_params = ctx .vararg(0) @@ -105,7 +105,7 @@ fn render( } } (RenderOutputTypeRequest::Vello, RenderIntermediateType::Vello(data)) => { - let executor = executor.into_element().expect("GPU executor not available"); + let executor = executor.eval(Context::default())?.into_element(); let mut draw = |scene: &mut VelloSceneBuilder| data.render_to_vello(scene, Default::default(), &render_params); let texture = match executor.render_vello(footprint.resolution, footprint.transform, &mut draw) { @@ -114,9 +114,7 @@ fn render( Err(error) => { log::error!("Failed to render Vello scene: {error}"); let limit = glam::UVec2::splat(executor.context().device.limits().max_texture_dimension_2d); - executor - .render_vello(footprint.resolution.min(limit), footprint.transform, &mut |_| {}) - .expect("Failed to render an empty Vello scene") + executor.render_vello(footprint.resolution.min(limit), footprint.transform, &mut |_| {}).map_err(NodeError::new)? } }; RenderOutputType::Texture(texture) @@ -124,7 +122,7 @@ fn render( _ => unreachable!("Render node did not receive its requested data type"), }; - Item::new_from_element(RenderOutput { data, metadata }) + Ok(Item::new_from_element(RenderOutput { data, metadata })) } #[node_macro::node(category(""))] diff --git a/node-graph/nodes/gstd/src/render_pixel_preview.rs b/node-graph/nodes/gstd/src/render_pixel_preview.rs index 7f4bc7cc0ac..cbdd66f4885 100644 --- a/node-graph/nodes/gstd/src/render_pixel_preview.rs +++ b/node-graph/nodes/gstd/src/render_pixel_preview.rs @@ -11,7 +11,7 @@ use wgpu_executor::{WgpuExecutor, WgpuPipeline, WgpuPipelineCache}; #[node_macro::node(category(""))] fn render_pixel_preview( ctx: impl Ctx + ExtractAll + CloneVarArgs + Sync, - #[scope(pixel_preview_pipeline::IDENTIFIER)] pipeline: Item, + #[scope(pixel_preview_pipeline::IDENTIFIER)] pipeline: impl Node, Output = Item>, data: impl Node, Output = Item> + Send + Sync, ) -> Result, Interrupt> { let Some(render_params) = ctx.vararg(0).ok().and_then(|v| v.downcast_ref::()).cloned() else { @@ -62,7 +62,7 @@ fn render_pixel_preview( let logical_transform = DAffine2::from_scale(DVec2::splat(1. / physical_scale)) * footprint.transform; let transform = DAffine2::from_translation(-upstream_min) * logical_transform.inverse() * DAffine2::from_scale(logical_resolution); - let resampled = pipeline.into_element().run::(&PixelPreviewArgs { + let resampled = pipeline.eval(Context::default())?.into_element().run::(&PixelPreviewArgs { source: source_texture.as_ref(), transform: &transform, size: physical_resolution, @@ -78,12 +78,10 @@ fn render_pixel_preview( #[node_macro::node(category(""), inject_scope)] fn pixel_preview_pipeline( _ctx: impl Ctx, - #[scope(crate::platform_application_io::try_wgpu_executor::IDENTIFIER)] executor: Item>, + #[scope(crate::platform_application_io::wgpu_executor::IDENTIFIER)] executor: Item, #[data] pipeline: WgpuPipelineCache, ) -> Item { - if let Some(executor) = executor.into_element() { - executor.pipeline_init::(pipeline); - } + executor.into_element().pipeline_init::(pipeline); Item::new_from_element(pipeline.clone()) } From 3a6a28242528cfdf95e5d15b91f7667432e13519 Mon Sep 17 00:00:00 2001 From: Timon Date: Tue, 27 Oct 2026 15:14:54 +0000 Subject: [PATCH 5/8] Return pipeline errors instead of panicking --- node-graph/libraries/wgpu-executor/src/lib.rs | 1 + .../libraries/wgpu-executor/src/pipeline.rs | 22 +++++++++++++------ node-graph/nodes/brush/src/basic_brush/mod.rs | 10 ++++----- .../nodes/gstd/src/render_background.rs | 18 +++++++++------ .../nodes/gstd/src/render_pixel_preview.rs | 16 +++++++++----- 5 files changed, 42 insertions(+), 25 deletions(-) diff --git a/node-graph/libraries/wgpu-executor/src/lib.rs b/node-graph/libraries/wgpu-executor/src/lib.rs index bc4aeed847c..9a6e29de21b 100644 --- a/node-graph/libraries/wgpu-executor/src/lib.rs +++ b/node-graph/libraries/wgpu-executor/src/lib.rs @@ -22,6 +22,7 @@ pub use context::Context as WgpuContext; pub use context::ContextBuilder as WgpuContextBuilder; pub use pipeline::Pipeline as WgpuPipeline; pub use pipeline::PipelineCache as WgpuPipelineCache; +pub use pipeline::PipelineError as WgpuPipelineError; pub use raster_types::Texture; pub use rendering::vello_scene_builder::VelloSceneBuilder; pub use wgpu::Backends as WgpuBackends; diff --git a/node-graph/libraries/wgpu-executor/src/pipeline.rs b/node-graph/libraries/wgpu-executor/src/pipeline.rs index 031d2b9dda7..95c9e5d99e6 100644 --- a/node-graph/libraries/wgpu-executor/src/pipeline.rs +++ b/node-graph/libraries/wgpu-executor/src/pipeline.rs @@ -13,6 +13,15 @@ pub trait Pipeline: Any + Send + Sync + Sized { fn run(&self, executor: &WgpuExecutor, args: &Self::Args<'_>) -> Self::Out; } +/// Why a [`PipelineCache`] can't run its pipeline. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum PipelineError { + #[error("The GPU pipeline was not set up")] + NotInitialized, + #[error("The GPU pipeline cache holds a pipeline other than `{expected}`")] + TypeMismatch { expected: &'static str }, +} + #[derive(Default, Clone, DynAny)] pub struct PipelineCache { pipeline: Arc>>, @@ -25,13 +34,12 @@ impl PipelineCache { self.pipeline.get_or_init(|| Box::new(P::create(executor))); } - pub fn run(&self, args: &P::Args<'_>) -> P::Out { - let executor = self.executor.get().expect("PipelineCache not initialized"); - let entry = self.pipeline.get().expect("PipelineCache not initialized"); - let pipeline = (**entry) - .downcast_ref::

() - .unwrap_or_else(|| panic!("PipelineCache type mismatch: run::<{}>() but init used a different pipeline type", std::any::type_name::

(),)); - pipeline.run(executor, args) + pub fn run(&self, args: &P::Args<'_>) -> Result { + let (Some(executor), Some(entry)) = (self.executor.get(), self.pipeline.get()) else { + return Err(PipelineError::NotInitialized); + }; + let pipeline = (**entry).downcast_ref::

().ok_or(PipelineError::TypeMismatch { expected: std::any::type_name::

() })?; + Ok(pipeline.run(executor, args)) } } diff --git a/node-graph/nodes/brush/src/basic_brush/mod.rs b/node-graph/nodes/brush/src/basic_brush/mod.rs index d25f034e876..5031dece058 100644 --- a/node-graph/nodes/brush/src/basic_brush/mod.rs +++ b/node-graph/nodes/brush/src/basic_brush/mod.rs @@ -12,7 +12,7 @@ use core_types::{ATTR_TRANSFORM, Ctx, ExtractFootprint}; use graphic_types::Graphic; use pipeline::{BasicBrushPipeline, BasicBrushPipelineArgs}; use raster_types::{GPU, Raster}; -use wgpu_executor::{WgpuExecutor, WgpuPipelineCache}; +use wgpu_executor::{WgpuExecutor, WgpuPipelineCache, WgpuPipelineError}; #[node_macro::node(category("Raster"), icon("NodeBasicBrush"))] fn basic_brush( @@ -20,7 +20,7 @@ fn basic_brush( strokes: List, #[widget(ParsedWidgetOverride::Hidden)] _cache: Item, #[scope(basic_brush_pipeline::IDENTIFIER)] pipeline: Item, -) -> List> { +) -> Result>, WgpuPipelineError> { let (cache, pipeline) = (_cache.into_element(), pipeline.into_element()); let mut stack = vec![strokes.into_iter()]; let mut strokes = Vec::new(); @@ -57,11 +57,11 @@ fn basic_brush( strokes: &strokes, cache: &cache, }; - let Some((texture, transform)) = pipeline.run::(&args) else { - return List::new(); + let Some((texture, transform)) = pipeline.run::(&args)? else { + return Ok(List::new()); }; let raster = Raster::::new_gpu(texture); - List::new_from_item(Item::new_from_element(raster).with_attribute(ATTR_TRANSFORM, transform)) + Ok(List::new_from_item(Item::new_from_element(raster).with_attribute(ATTR_TRANSFORM, transform))) } #[node_macro::node(category(""), inject_scope)] diff --git a/node-graph/nodes/gstd/src/render_background.rs b/node-graph/nodes/gstd/src/render_background.rs index e60f8897908..52ca59a2f79 100644 --- a/node-graph/nodes/gstd/src/render_background.rs +++ b/node-graph/nodes/gstd/src/render_background.rs @@ -3,7 +3,7 @@ use core_types::color::Linear; use core_types::list::Item; use core_types::transform::Footprint; use core_types::uuid::generate_uuid; -use core_types::{Context, Ctx, ExtractFootprint, Interrupt}; +use core_types::{Context, Ctx, ExtractFootprint, Interrupt, NodeError}; use glam::{Affine2, UVec2, Vec2}; use graph_craft::document::value::{RenderOutput, RenderOutputType}; use graphic_types::raster_types::Texture; @@ -35,12 +35,16 @@ fn render_background( let data = match foreground_data { RenderOutputType::Texture(foreground_texture) => { let doc_to_screen = render_params.footprint.transform.as_affine2(); - let blended = pipeline.eval(Context::default())?.into_element().run::(&CompositeBackgroundArgs { - foreground: foreground_texture.as_ref(), - backgrounds: &metadata.backgrounds, - document_to_screen: doc_to_screen, - zoom: render_params.viewport_zoom.unwrap_or(1.).to_f32(), - }); + let blended = pipeline + .eval(Context::default())? + .into_element() + .run::(&CompositeBackgroundArgs { + foreground: foreground_texture.as_ref(), + backgrounds: &metadata.backgrounds, + document_to_screen: doc_to_screen, + zoom: render_params.viewport_zoom.unwrap_or(1.).to_f32(), + }) + .map_err(NodeError::from)?; RenderOutputType::Texture(blended) } diff --git a/node-graph/nodes/gstd/src/render_pixel_preview.rs b/node-graph/nodes/gstd/src/render_pixel_preview.rs index cbdd66f4885..00648a60a3c 100644 --- a/node-graph/nodes/gstd/src/render_pixel_preview.rs +++ b/node-graph/nodes/gstd/src/render_pixel_preview.rs @@ -1,6 +1,6 @@ use core_types::list::Item; use core_types::transform::{Footprint, Transform}; -use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, Interrupt, OwnedContextImpl}; +use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, Interrupt, NodeError, OwnedContextImpl}; use glam::{DAffine2, DVec2, UVec2, Vec2}; use graph_craft::document::value::{RenderOutput, RenderOutputType}; use graphic_types::raster_types::Texture; @@ -62,11 +62,15 @@ fn render_pixel_preview( let logical_transform = DAffine2::from_scale(DVec2::splat(1. / physical_scale)) * footprint.transform; let transform = DAffine2::from_translation(-upstream_min) * logical_transform.inverse() * DAffine2::from_scale(logical_resolution); - let resampled = pipeline.eval(Context::default())?.into_element().run::(&PixelPreviewArgs { - source: source_texture.as_ref(), - transform: &transform, - size: physical_resolution, - }); + let resampled = pipeline + .eval(Context::default())? + .into_element() + .run::(&PixelPreviewArgs { + source: source_texture.as_ref(), + transform: &transform, + size: physical_resolution, + }) + .map_err(NodeError::from)?; result.data = RenderOutputType::Texture(resampled); From 3a6dc694b163a8426cb0127db91091b4f678d7a8 Mon Sep 17 00:00:00 2001 From: Timon Date: Tue, 27 Oct 2026 03:02:52 +0000 Subject: [PATCH 6/8] Fix crashes and report errors in raster, brush and I/O nodes --- Cargo.lock | 2 + editor/src/messages/frontend/utility_types.rs | 2 +- .../node_graph/document_node_definitions.rs | 4 +- .../messages/portfolio/document_migration.rs | 12 ++ .../core-types/src/transfer_curve.rs | 13 ++- .../libraries/core-types/src/transform.rs | 4 + node-graph/libraries/raster-types/Cargo.toml | 1 + .../libraries/raster-types/src/image.rs | 76 +++++++++++-- node-graph/libraries/raster-types/src/lib.rs | 2 +- .../wgpu-executor/src/texture_conversion.rs | 22 +++- .../nodes/brush/src/basic_brush/region.rs | 3 + .../nodes/gstd/src/platform_application_io.rs | 11 +- node-graph/nodes/raster/Cargo.toml | 2 + node-graph/nodes/raster/src/adjustments.rs | 38 +++++-- node-graph/nodes/raster/src/blending_nodes.rs | 98 +++++++++++++---- .../nodes/raster/src/color_lookup_table.rs | 8 +- .../src/color_lookup_table/format_csp.rs | 7 ++ node-graph/nodes/raster/src/filter.rs | 15 ++- node-graph/nodes/raster/src/std_nodes.rs | 103 +++++++++++------- 19 files changed, 323 insertions(+), 100 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 639d0c5b6ed..1681e5df161 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4835,6 +4835,7 @@ dependencies = [ "raster-types", "serde", "spirv-std", + "thiserror 2.0.18", "tokio", "tsify", "vector-types", @@ -4872,6 +4873,7 @@ dependencies = [ "node-macro", "serde", "serde_json", + "thiserror 2.0.18", "tsify", "wasm-bindgen", "wgpu", diff --git a/editor/src/messages/frontend/utility_types.rs b/editor/src/messages/frontend/utility_types.rs index 37ad351ee27..bb18d0ad590 100644 --- a/editor/src/messages/frontend/utility_types.rs +++ b/editor/src/messages/frontend/utility_types.rs @@ -197,7 +197,7 @@ mod tests { for file_type in [FileType::Png, FileType::Jpg, FileType::Webp, FileType::Tiff, FileType::Bmp, FileType::Tga, FileType::Ico] { let encoded = file_type.encode(2, 1, pixels.clone()).unwrap_or_else(|error| panic!("{file_type:?}: {error}")); - let decoded = Image::from_encoded(&encoded).unwrap_or_else(|| panic!("{file_type:?} should read back")); + let decoded = Image::from_encoded(&encoded).unwrap_or_else(|error| panic!("{file_type:?} should read back: {error}")); assert_eq!((decoded.width, decoded.height), (2, 1), "{file_type:?}"); // Only JPG lacks transparency, so it lands on white 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 43823d5fec1..52d0ad7e69f 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 @@ -640,13 +640,13 @@ fn document_node_definitions() -> HashMap AxisAlignedBbox { + if self.transform.matrix2.determinant() == 0. { + return AxisAlignedBbox::ZERO; + } + let inverse = self.transform.inverse(); let res = self.resolution.as_dvec2(); let c0 = inverse.transform_point2(DVec2::ZERO); diff --git a/node-graph/libraries/raster-types/Cargo.toml b/node-graph/libraries/raster-types/Cargo.toml index 217f9bda7f9..b622c1b3895 100644 --- a/node-graph/libraries/raster-types/Cargo.toml +++ b/node-graph/libraries/raster-types/Cargo.toml @@ -33,6 +33,7 @@ base64 = { workspace = true } bytemuck = { workspace = true } image = { workspace = true } serde_json = { workspace = true } +thiserror = { workspace = true } # Optional workspace dependencies serde = { workspace = true, optional = true } diff --git a/node-graph/libraries/raster-types/src/image.rs b/node-graph/libraries/raster-types/src/image.rs index 2533d117da2..097493cbe92 100644 --- a/node-graph/libraries/raster-types/src/image.rs +++ b/node-graph/libraries/raster-types/src/image.rs @@ -130,6 +130,14 @@ impl core_types::CacheHash for Image

{ } } +#[derive(Debug, thiserror::Error)] +pub enum ImageError { + #[error("The {width}×{height} px image is too large")] + TooLarge { width: u32, height: u32 }, + #[error("The file is not an image in a supported format, or it is damaged")] + Undecodable(#[source] ::image::ImageError), +} + impl Image

{ pub fn new(width: u32, height: u32, color: P) -> Self { Self { @@ -139,6 +147,23 @@ impl Image

{ base64_string: None, } } + + /// An image filled with `color`, failing where the pixel count overflows the `u32` pixel indices or can't be allocated. + pub fn try_new(width: u32, height: u32, color: P) -> Result { + let too_large = || ImageError::TooLarge { width, height }; + let count = width.checked_mul(height).ok_or_else(too_large)? as usize; + + let mut data = Vec::new(); + data.try_reserve_exact(count).map_err(|_| too_large())?; + data.resize(count, color); + + Ok(Self { + width, + height, + data, + base64_string: None, + }) + } } impl Image { @@ -153,8 +178,8 @@ impl Image { } } - /// Decodes an image file of any supported format. - pub fn from_encoded(data: &[u8]) -> Option { + /// Decodes an image file of any supported format, or gives the reason it can't be. + pub fn from_encoded(data: &[u8]) -> Result { let image = decode(data)?; // Float samples hold linear light, as in HDR and EXR files, while integer samples are gamma encoded @@ -178,7 +203,7 @@ impl Image { }) .collect(); - Some(Image { + Ok(Image { width: image.width(), height: image.height(), data, @@ -188,7 +213,7 @@ impl Image { /// The pixel size of an image file, if it decodes in full as [`Image::from_encoded`] needs it to. pub fn encoded_size(data: &[u8]) -> Option<(u32, u32)> { - decode(data).map(|image| (image.width(), image.height())) + decode(data).ok().map(|image| (image.width(), image.height())) } pub fn to_png(&self) -> Vec { @@ -202,9 +227,17 @@ impl Image { } /// A TGA file has no signature to recognize it by, so a file of no recognized format is tried as one. -fn decode(data: &[u8]) -> Option<::image::DynamicImage> { +fn decode(data: &[u8]) -> Result<::image::DynamicImage, ImageError> { let format = ::image::guess_format(data).unwrap_or(::image::ImageFormat::Tga); - ::image::load_from_memory_with_format(data, format).ok() + ::image::load_from_memory_with_format(data, format).map_err(|error| { + // The memory limit error carries no size, which the header gives + if let ::image::ImageError::Limits(_) = error + && let Ok((width, height)) = ::image::ImageReader::with_format(std::io::Cursor::new(data), format).into_dimensions() + { + return ImageError::TooLarge { width, height }; + } + ImageError::Undecodable(error) + }) } use super::*; @@ -346,12 +379,37 @@ mod test { let mut encoded = Vec::new(); pixels.write_to(&mut std::io::Cursor::new(&mut encoded), format).unwrap(); - let image = Image::from_encoded(&encoded).unwrap_or_else(|| panic!("{format:?} should decode")); + let image = Image::from_encoded(&encoded).unwrap_or_else(|error| panic!("{format:?} should decode: {error}")); assert_eq!(Image::encoded_size(&encoded), Some((2, 1)), "{format:?}"); assert_eq!(image.to_flat_u8().0, pixels.as_raw().as_slice(), "{format:?}"); } - assert!(Image::from_encoded(b"not an image").is_none()); + assert!(Image::from_encoded(b"not an image").is_err()); + } + + #[test] + fn a_file_that_does_not_decode_gives_its_reason() { + use super::*; + + assert!(matches!(Image::from_encoded(b"not an image"), Err(ImageError::Undecodable(_)))); + + // A BMP header for a 32-bit image of 30000×30000 pixels, which is far beyond the decoder's memory limit + let mut file = b"BM".to_vec(); + for field in [54_u32, 0, 54, 40, 30000, 30000] { + file.extend(field.to_le_bytes()); + } + file.extend([1, 0, 32, 0]); + file.extend([0; 24]); + let error = Image::from_encoded(&file).unwrap_err(); + assert!(matches!(error, ImageError::TooLarge { width: 30000, height: 30000 }), "{error:?}"); + } + + #[test] + fn an_image_too_large_for_its_pixel_indices_is_not_made() { + use super::*; + + assert!(matches!(Image::try_new(65536, 65536, Color::BLACK), Err(ImageError::TooLarge { width: 65536, height: 65536 }))); + assert_eq!(Image::try_new(3, 2, Color::BLACK).unwrap().data, vec![Color::BLACK; 6]); } #[test] @@ -371,7 +429,7 @@ mod test { file.extend([0, 255, 0, 0]); assert!(::image::load_from_memory_with_format(&file, ::image::ImageFormat::Tga).is_ok()); - assert!(Image::from_encoded(&file).is_none()); + assert!(Image::from_encoded(&file).is_err()); } #[test] diff --git a/node-graph/libraries/raster-types/src/lib.rs b/node-graph/libraries/raster-types/src/lib.rs index 83340625d50..c29e9cb8473 100644 --- a/node-graph/libraries/raster-types/src/lib.rs +++ b/node-graph/libraries/raster-types/src/lib.rs @@ -2,7 +2,7 @@ pub mod image; pub mod raster_types; // Re-exports for convenience -pub use image::Image; +pub use image::{Image, ImageError}; pub use raster_types::*; // Re-export color types from no-std-types diff --git a/node-graph/libraries/wgpu-executor/src/texture_conversion.rs b/node-graph/libraries/wgpu-executor/src/texture_conversion.rs index 933a7d7109c..32cea6787ab 100644 --- a/node-graph/libraries/wgpu-executor/src/texture_conversion.rs +++ b/node-graph/libraries/wgpu-executor/src/texture_conversion.rs @@ -10,9 +10,27 @@ use wgpu::{Extent3d, TextureFormat}; /// Uploads CPU image data to a GPU texture fn upload_to_texture(executor: &WgpuExecutor, queue: &wgpu::Queue, image: &Raster) -> Texture { - let rgba8_data: Vec = image.data.iter().map(|x| (*x).into()).collect(); - let texture = executor.request_texture_with_format(glam::UVec2::new(image.width, image.height), TextureFormat::Rgba8UnormSrgb); + + if image.width == 0 || image.height == 0 { + queue.write_texture( + texture.as_image_copy(), + bytemuck::bytes_of(&SRGBA8::TRANSPARENT), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4), + rows_per_image: Some(1), + }, + Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + ); + return texture; + } + + let rgba8_data: Vec = image.data.iter().map(|x| (*x).into()).collect(); queue.write_texture( texture.as_image_copy(), bytemuck::cast_slice(rgba8_data.as_slice()), diff --git a/node-graph/nodes/brush/src/basic_brush/region.rs b/node-graph/nodes/brush/src/basic_brush/region.rs index 54c78be392a..5a77928e2e1 100644 --- a/node-graph/nodes/brush/src/basic_brush/region.rs +++ b/node-graph/nodes/brush/src/basic_brush/region.rs @@ -17,6 +17,9 @@ impl Region { pub(crate) fn new(footprint: &Footprint) -> Option { let margin = DVec2::splat(2. / footprint.scale().max_element()); let viewport = footprint.viewport_bounds_in_local_space(); + if !viewport.size().cmpgt(DVec2::ZERO).all() { + return None; + } let bounds = AxisAlignedBbox { start: viewport.start - margin, end: viewport.end + margin, diff --git a/node-graph/nodes/gstd/src/platform_application_io.rs b/node-graph/nodes/gstd/src/platform_application_io.rs index 9bd5d2f0344..97a52782c51 100644 --- a/node-graph/nodes/gstd/src/platform_application_io.rs +++ b/node-graph/nodes/gstd/src/platform_application_io.rs @@ -20,8 +20,8 @@ pub use graph_craft::document::value::RenderOutputType; pub use graphene_canvas_utils as canvas_utils; #[cfg(target_family = "wasm")] use graphic_types::Graphic; -use graphic_types::raster_types::Image; use graphic_types::raster_types::{CPU, GPU, Raster}; +use graphic_types::raster_types::{Image, ImageError}; #[cfg(target_family = "wasm")] use rendering::{Render, RenderParams, RenderSvgSegmentList, SvgRender}; use std::sync::Arc; @@ -163,11 +163,14 @@ fn pending_resource(_primary: &(), _url: &Item) -> Item { /// /// Supports the formats: PNG, JPG, GIF, WEBP, TIFF, BMP, TGA, ICO, HDR, EXR. #[node_macro::node(category("Data: I/O"), icon("NodeBytesToImage"), name("Bytes to Image"))] -fn bytes_to_image(_: impl Ctx, data: Item) -> Item> { +fn bytes_to_image(_: impl Ctx, data: Item) -> Result>, ImageError> { let data = data.into_element(); - let Some(image) = Image::from_encoded(data.as_ref()) else { return Item::default() }; + if data.is_empty() { + return Ok(Item::default()); + } + let image = Image::from_encoded(data.as_ref())?; - Item::new_from_element(Raster::new_cpu(image)) + Ok(Item::new_from_element(Raster::new_cpu(image))) } #[cfg(target_family = "wasm")] diff --git a/node-graph/nodes/raster/Cargo.toml b/node-graph/nodes/raster/Cargo.toml index dc489867d30..1b794bb567f 100644 --- a/node-graph/nodes/raster/Cargo.toml +++ b/node-graph/nodes/raster/Cargo.toml @@ -34,6 +34,7 @@ std = [ "dep:fastnoise-lite", "dep:kurbo", "dep:color", + "dep:thiserror", ] wasm = [ "core-types/wasm", @@ -75,6 +76,7 @@ fastnoise-lite = { workspace = true, optional = true } serde = { workspace = true, optional = true } kurbo = { workspace = true, optional = true } color = { workspace = true, optional = true } +thiserror = { workspace = true, optional = true } # Workspace Wasm dependencies tsify = { workspace = true, optional = true } diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index cbc2751fa1d..60ceb97e6b8 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -2,7 +2,7 @@ use crate::adjust::Adjust; #[cfg(feature = "std")] -use crate::color_lookup_table::LutCache; +use crate::color_lookup_table::{LutCache, LutParseError}; use core::fmt::Debug; #[cfg(feature = "std")] use core_types::list::{Item, List}; @@ -710,6 +710,11 @@ fn curves + Send>( image } +#[cfg(feature = "std")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("A control point has a coordinate that isn't finite")] +pub struct NonFinitePointError; + /// Builds a transfer curve from a `Vec2[]` of control points, each mapping the input value at its x to the output value at its y. A smooth spline runs through them, holding the outermost points' values beyond them. #[cfg(feature = "std")] #[node_macro::node(category("Raster: Adjustment"), icon("NodePointsToTransferCurve"), name("Points to Transfer Curve"))] @@ -717,9 +722,12 @@ fn points_to_transfer_curve( _: impl Ctx, /// The control points, in any order, with both coordinates on the 0 to 1 range. points: List, -) -> Item { +) -> Result, NonFinitePointError> { let points: Vec = points.iter_element_values().copied().collect(); - Item::new_from_element(TransferCurve::new(points)) + if !points.iter().all(|point| point.is_finite()) { + return Err(NonFinitePointError); + } + Ok(Item::new_from_element(TransferCurve::new(points))) } // Aims for interoperable compatibility with: @@ -2182,9 +2190,9 @@ fn selective_color>( } }; - let offset_r = f32::clamp(ink(c) * slope_r, -r, -r + 1.) * color_parameter_group_scale_factor; - let offset_g = f32::clamp(ink(m) * slope_g, -g, -g + 1.) * color_parameter_group_scale_factor; - let offset_b = f32::clamp(ink(y) * slope_b, -b, -b + 1.) * color_parameter_group_scale_factor; + let offset_r = (ink(c) * slope_r).max(-r).min(-r + 1.) * color_parameter_group_scale_factor; + let offset_g = (ink(m) * slope_g).max(-g).min(-g + 1.) * color_parameter_group_scale_factor; + let offset_b = (ink(y) * slope_b).max(-b).min(-b + 1.) * color_parameter_group_scale_factor; // An 8-bit PSD document sums the groups' 8-bit offsets, which this float node does not currently attempt to reproduce sum += Vec3::new(offset_r, offset_g, offset_b); @@ -2547,9 +2555,12 @@ fn color_lookup + Send>( #[widget(ParsedWidgetOverride::Custom = "lut_file")] lut_file: Item, #[data] lut_cache: LutCache, -) -> Item { +) -> Result, LutParseError> { let mut image = image; - let Ok(lut) = lut_cache.parse(lut_file.element()) else { return image }; + if lut_file.element().is_empty() { + return Ok(image); + } + let lut = lut_cache.parse(lut_file.element())?; image.element_mut().adjust(|color| { // Lookup tables address the gamma-encoded channels @@ -2559,7 +2570,7 @@ fn color_lookup + Send>( Color::from_gamma_srgb_channels(r, g, b, a) }); - image + Ok(image) } #[cfg(feature = "std")] @@ -3068,6 +3079,15 @@ mod tests { assert_close(run_selective_color([100., 50., 130.], RelativeAbsolute::Relative, groups), [100., 191., 130.]); } + #[test] + fn selective_color_survives_a_channel_that_is_not_a_number() { + let mut groups = [[0.; 4]; 9]; + groups[7] = [10., 0., 0., 0.]; + for mode in [RelativeAbsolute::Relative, RelativeAbsolute::Absolute] { + run_selective_color([f32::NAN, 100., 130.], mode, groups); + } + } + /// Runs Posterize on one gamma-space gray value (0..255) and returns the gamma-space result on the same scale. fn run_posterize(value: f32, levels: i64) -> f32 { let pixel = Color::from_gamma_srgb_channels(value / 255., value / 255., value / 255., 1.); diff --git a/node-graph/nodes/raster/src/blending_nodes.rs b/node-graph/nodes/raster/src/blending_nodes.rs index eb3ad45ed5a..de507c61de3 100644 --- a/node-graph/nodes/raster/src/blending_nodes.rs +++ b/node-graph/nodes/raster/src/blending_nodes.rs @@ -1,9 +1,11 @@ use crate::adjust::Adjust; #[cfg(feature = "std")] +use crate::std_nodes::SizeMismatchError; +#[cfg(feature = "std")] use core_types::list::Item; use no_std_types::Ctx; use no_std_types::blending::BlendMode; -use no_std_types::color::{Color, Pixel}; +use no_std_types::color::Color; #[cfg(not(feature = "std"))] use no_std_types::list::ShaderItem as Item; #[cfg(feature = "std")] @@ -11,41 +13,71 @@ use raster_types::{CPU, Raster}; #[cfg(feature = "std")] use vector_types::{Gradient, GradientStop}; -pub trait Blend { - fn blend(&self, under: &Self, blend_fn: impl Fn(P, P) -> P) -> Self; -} -impl Blend for Color { - fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Self { - blend_fn(*self, *under) - } -} +#[cfg(feature = "std")] +pub use blend_std::Blend; #[cfg(feature = "std")] mod blend_std { use super::*; - use core::cmp::Ordering; + use glam::UVec2; + use no_std_types::color::Pixel; use raster_types::Image; use raster_types::Raster; + pub trait Blend: Sized { + /// Whether there are no pixels to blend, as in the empty raster an unconnected input arrives as. + fn is_empty(&self) -> bool { + false + } + + /// Blends `self` over `under`, failing where the two can't be lined up. + fn blend(&self, under: &Self, blend_fn: impl Fn(P, P) -> P) -> Result; + } + impl Blend for Color { + fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Result { + Ok(blend_fn(*self, *under)) + } + } + impl Blend for Raster { - fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Self { - let data = self.data.iter().zip(under.data.iter()).map(|(a, b)| blend_fn(*a, *b)).collect(); + fn is_empty(&self) -> bool { + raster_types::Storage::is_empty(self) + } - Raster::new_cpu(Image { + fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Result { + let size = |raster: &Self| UVec2::new(raster.width, raster.height); + + // An empty Under is blended as transparent pixels + let data = if under.is_empty() { + self.data.iter().map(|&a| blend_fn(a, Color::TRANSPARENT)).collect() + } else if size(self) != size(under) { + return Err(SizeMismatchError { + first: "Over", + first_size: size(self), + second: "Under", + second_size: size(under), + }); + } else { + self.data.iter().zip(under.data.iter()).map(|(a, b)| blend_fn(*a, *b)).collect() + }; + + Ok(Raster::new_cpu(Image { data, width: self.width, height: self.height, base64_string: None, - }) + })) } } impl Blend for Gradient { // TODO: This joining is unfaithful in several ways: it samples only at stop positions so midpoint curves flatten away; // TODO: it evaluates both sources with default whole-ramp attributes rather than their own (which this element-level impl cannot read); // TODO: and the output keeps over's attributes despite being sampled with defaults - fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Self { + fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Result { let mut combined_stops = self.positions(false).into_iter().chain(under.positions(false)).collect::>(); - combined_stops.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + // A stop at a position that isn't a number is left out, as gradient sampling leaves it out + combined_stops.retain(|position| !position.is_nan()); + combined_stops.sort_by(f64::total_cmp); combined_stops.dedup_by(|a, b| (*a - *b).abs() < 1e-6); let over_evaluator = self.evaluator(Default::default()); let under_evaluator = under.evaluator(Default::default()); @@ -55,14 +87,15 @@ mod blend_std { }); // Positions stay explicit because eliding them needs the cyclic flag this impl can't read, and a wrong guess would relocate the stops - Gradient::new(stops) + Ok(Gradient::new(stops)) } } } pub use no_std_types::blending::{apply_blend_mode, blend_colors}; -#[node_macro::node(category("Raster"), icon("NodeMix"), cfg(feature = "std"))] +#[cfg(feature = "std")] +#[node_macro::node(category("Raster"), icon("NodeMix"))] fn mix + Send>( _: impl Ctx, #[implementations( @@ -86,14 +119,18 @@ fn mix + Send>( #[hard(0..100)] #[default(100.)] opacity: Item, -) -> Item { +) -> Result, SizeMismatchError> { + if over.element().is_empty() { + return Ok(under); + } + let mut over = over; let blend_mode = blend_mode.into_element(); let opacity = (opacity.into_element() / 100.) as f32; - let blended = over.element().blend(under.element(), |a, b| blend_colors(a, b, blend_mode, opacity)); + let blended = over.element().blend(under.element(), |a, b| blend_colors(a, b, blend_mode, opacity))?; *over.element_mut() = blended; - over + Ok(over) } // TODO: Rename to "Paint Overlay" and take a `Graphic` paint like the `Fill` node, enabling this to serve the cases of Color Overlay, Gradient Overlay, and Pattern Overlay. @@ -160,4 +197,23 @@ mod test { // The output should just be the original green and alpha channels (as we multiply them by 1 and other channels by 0) assert_eq!(result.data[0], Color::from_rgbaf32_unchecked(0., image_color.g(), 0., image_color.a())); } + + #[test] + fn mix_takes_the_other_side_where_one_is_empty_and_rejects_rasters_of_different_sizes() { + use core_types::ATTR_TRANSFORM; + use glam::DAffine2; + + let raster = |width: u32, color: Color| Item::new_from_element(Raster::new_cpu(Image::new(width, 1, color))).with_attribute(ATTR_TRANSFORM, DAffine2::from_scale((width as f64, 1.).into())); + let mix = |over, under| super::mix((), over, under, BlendMode::Normal.into(), 100.0.into()); + + let over_alone = mix(raster(2, Color::RED), Item::default()).unwrap(); + assert_eq!(over_alone.element().data, vec![Color::RED; 2]); + + let under_alone = mix(Item::default(), raster(2, Color::BLUE)).unwrap(); + assert_eq!(under_alone.element().data, vec![Color::BLUE; 2]); + assert_eq!(under_alone.attribute_cloned_or_default::(ATTR_TRANSFORM), DAffine2::from_scale((2., 1.).into())); + + let error = mix(raster(2, Color::RED), raster(3, Color::BLUE)).unwrap_err(); + assert_eq!(error.to_string(), "Over and Under must be the same size, but Over is 2×1 pixels and Under is 3×1"); + } } diff --git a/node-graph/nodes/raster/src/color_lookup_table.rs b/node-graph/nodes/raster/src/color_lookup_table.rs index 837f6c3beeb..c90a647e3a4 100644 --- a/node-graph/nodes/raster/src/color_lookup_table.rs +++ b/node-graph/nodes/raster/src/color_lookup_table.rs @@ -40,13 +40,16 @@ impl LutCache { } /// Why a file could not be read as a lookup table. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum LutParseError { /// An ICC profile that describes a device's colors, not an "abstract" or "device link" profile that remaps them. + #[error("The ICC profile describes a device's colors, but only abstract and device link profiles work as LUTs")] IccProfileClass, /// An ICC "abstract" profile that does not map Lab to Lab, or a "device link" profile that does not map RGB to RGB. + #[error("The ICC profile's color spaces are unsupported, as a device link profile must map RGB to RGB and an abstract profile Lab to Lab")] IccColorSpaces, /// Not a lookup table in any of the formats, or one that is damaged. + #[error("The file is not a LUT in a supported format, or it is damaged")] Unreadable, } @@ -93,7 +96,8 @@ impl Curve { return outputs[count - 1]; } - let upper = inputs.partition_point(|&input| input <= value).min(count - 1); + // A NaN value leaves the search without a segment, so the clamp keeps it in bounds + let upper = inputs.partition_point(|&input| input <= value).clamp(1, count - 1); let lower = upper - 1; let t = (value - inputs[lower]) / (inputs[upper] - inputs[lower]).max(f32::EPSILON); outputs[lower] + t * (outputs[upper] - outputs[lower]) diff --git a/node-graph/nodes/raster/src/color_lookup_table/format_csp.rs b/node-graph/nodes/raster/src/color_lookup_table/format_csp.rs index 0d50ebda379..dfd491ce451 100644 --- a/node-graph/nodes/raster/src/color_lookup_table/format_csp.rs +++ b/node-graph/nodes/raster/src/color_lookup_table/format_csp.rs @@ -81,6 +81,13 @@ mod tests { assert_close(lut.apply([1., 0.5, 2.]), [0.5, 0.25, 1.]); } + #[test] + fn pre_lut_passes_a_value_that_is_not_a_number_through() { + let text = "CSPLUTV100\n1D\n2\n0 2\n0 1\n2\n0 2\n0 1\n2\n0 2\n0 1\n2\n0 0 0\n1 1 1\n"; + let lut = Lut::parse(text.as_bytes()).unwrap(); + assert!(lut.apply([f32::NAN, 0.5, 2.])[0].is_nan()); + } + #[test] fn pre_lut_under_two_points_is_identity() { let text = "CSPLUTV100\n1D\n0\n1\n0\n2\n0 0 0\n1 1 1\n"; diff --git a/node-graph/nodes/raster/src/filter.rs b/node-graph/nodes/raster/src/filter.rs index 91d0cafe04a..6d3c94283ee 100644 --- a/node-graph/nodes/raster/src/filter.rs +++ b/node-graph/nodes/raster/src/filter.rs @@ -149,9 +149,10 @@ fn median_filter( } // 1D gaussian kernel -fn gaussian_kernel(radius: f64) -> Vec { +fn gaussian_kernel(radius: f64, image_extent: u32) -> Vec { // Given radius, compute the size of the kernel that's approximately three times the radius - let kernel_radius = (3. * radius).ceil() as usize; + // Taps further out than the image extends never land on a pixel, so the kernel stops there while its weights keep the full radius + let kernel_radius = ((3. * radius).ceil() as usize).min(image_extent as usize); let kernel_size = 2 * kernel_radius + 1; let mut gaussian_kernel: Vec = vec![0.; kernel_size]; @@ -175,7 +176,7 @@ fn gaussian_kernel(radius: f64) -> Vec { } fn gaussian_blur_algorithm(buffer: Image, radius: f64, gamma: bool) -> Image { - let kernel = gaussian_kernel(radius); + let kernel = gaussian_kernel(radius, buffer.width.max(buffer.height)); if gamma { let working = premultiply_gamma(buffer); let blurred = gaussian_separable(working, &kernel, |r, g, b, a| PremultipliedGammaPixel { r, g, b, a }); @@ -262,6 +263,8 @@ where let mut x_axis = Image::new(width, height, P::default()); let mut y_axis = Image::new(width, height, P::default()); + let radius = radius.min(width.max(height) as f64) as i32; + for pass in [false, true] { let (max, old_buffer, current_buffer) = match pass { false => (width, &buffer, &mut x_axis), @@ -274,7 +277,7 @@ where let (mut r_sum, mut g_sum, mut b_sum, mut a_sum, mut weight_sum) = (0., 0., 0., 0., 0.); let i = [x, y][pass]; - for d in (i as i32 - radius as i32).max(0)..=(i as i32 + radius as i32).min(max as i32 - 1) { + for d in (i as i32 - radius).max(0)..=(i as i32 + radius).min(max as i32 - 1) { if let Some(px) = old_buffer.get_pixel([d as u32, x][pass], [y, d as u32][pass]) { let weight = 1.; r_sum += px.r() as f64 * weight; @@ -298,8 +301,8 @@ fn median_filter_algorithm(original_buffer: Image, radius: u32) -> Image< let (width, height) = original_buffer.dimensions(); let mut output = Image::new(width, height, Color::TRANSPARENT); - // Pre-allocate and reuse buffers outside the loops to avoid repeated allocations. - let window_capacity = ((2 * radius + 1).pow(2)) as usize; + let window_span = |extent: u32| (radius as usize).saturating_mul(2).saturating_add(1).min(extent as usize); + let window_capacity = window_span(width) * window_span(height); let mut r_vals: Vec = Vec::with_capacity(window_capacity); let mut g_vals: Vec = Vec::with_capacity(window_capacity); let mut b_vals: Vec = Vec::with_capacity(window_capacity); diff --git a/node-graph/nodes/raster/src/std_nodes.rs b/node-graph/nodes/raster/src/std_nodes.rs index 8fd6d3df2d6..ecf2f7ace23 100644 --- a/node-graph/nodes/raster/src/std_nodes.rs +++ b/node-graph/nodes/raster/src/std_nodes.rs @@ -8,13 +8,13 @@ use core_types::math::bbox::Bbox; use core_types::transform::Transform; use dyn_any::DynAny; use fastnoise_lite; -use glam::{DAffine2, DVec2, Vec2}; +use glam::{DAffine2, DVec2, UVec2, Vec2}; use graphene_resource::Resource; use rand::prelude::*; use rand_chacha::ChaCha8Rng; -use raster_types::Image; use raster_types::{Bitmap, BitmapMut}; use raster_types::{CPU, Raster}; +use raster_types::{Image, ImageError}; use std::fmt::Debug; use std::hash::Hash; @@ -30,6 +30,16 @@ impl From for Error { } } +/// Two rasters that must line up pixel for pixel but differ in size. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("{first} and {second} must be the same size, but {first} is {}×{} pixels and {second} is {}×{}", .first_size.x, .first_size.y, .second_size.x, .second_size.y)] +pub struct SizeMismatchError { + pub first: &'static str, + pub first_size: UVec2, + pub second: &'static str, + pub second_size: UVec2, +} + #[node_macro::node(category("Raster"), icon("NodeChannelsToImage"), name("Channels to Image"))] fn channels_to_image( _: impl Ctx, @@ -38,24 +48,31 @@ fn channels_to_image( #[expose] green: Item>, #[expose] blue: Item>, #[expose] alpha: Item>, -) -> Item> { +) -> Result>, SizeMismatchError> { // An unconnected channel arrives as the default zero-sized raster, which counts as absent let present = |channel: Item>| (channel.element().width > 0 && channel.element().height > 0).then_some(channel); let (red, green, blue, alpha) = (present(red), present(green), present(blue), present(alpha)); // Take this item's transform and blending attributes from the first present channel let Some(attributes) = [&red, &green, &blue, &alpha].iter().find_map(|channel| channel.as_ref()).map(|channel| channel.attributes().clone()) else { - return Item::default(); + return Ok(Item::default()); }; - // All present channels must share the same dimensions; a mismatch yields the default zero-sized raster - let channel_dimensions = [&red, &green, &blue, &alpha].map(|channel| channel.as_ref().map(|channel| (channel.element().width, channel.element().height))); - let Some(&(width, height)) = channel_dimensions.iter().flatten().next() else { - return Item::default(); + // All present channels must share the same dimensions + let channel_dimensions = [("Red", &red), ("Green", &green), ("Blue", &blue), ("Alpha", &alpha)] + .map(|(name, channel)| channel.as_ref().map(|channel| (name, UVec2::new(channel.element().width, channel.element().height)))); + let Some(&(first, first_size)) = channel_dimensions.iter().flatten().next() else { + return Ok(Item::default()); }; - if channel_dimensions.iter().flatten().any(|&(other_width, other_height)| other_width != width || other_height != height) { - return Item::default(); + if let Some(&(second, second_size)) = channel_dimensions.iter().flatten().find(|&&(_, size)| size != first_size) { + return Err(SizeMismatchError { + first, + first_size, + second, + second_size, + }); } + let (width, height) = (first_size.x, first_size.y); // Set each output pixel's channels from the present inputs, defaulting absent color channels to 0 and absent alpha to 1 let mut image = Image::new(width, height, Color::TRANSPARENT); @@ -82,7 +99,7 @@ fn channels_to_image( } } - Item::from_parts(Raster::new_cpu(image), attributes) + Ok(Item::from_parts(Raster::new_cpu(image), attributes)) } #[node_macro::node(category("Raster"), icon("NodeMask"))] @@ -103,9 +120,8 @@ fn mask( let mut row = image; let image_size = DVec2::new(row.element().width as f64, row.element().height as f64); let stencil_transform: DAffine2 = stencil.attribute_cloned_or_default(ATTR_TRANSFORM); - let mask_size = stencil_transform.scale_magnitudes(); - if mask_size == DVec2::ZERO { + if stencil_transform.matrix2.determinant() == 0. { return row; } @@ -132,20 +148,22 @@ fn mask( } #[node_macro::node(category(""))] -fn extend_image_to_bounds(_: impl Ctx, image: Item>, bounds: Item) -> Item> { +fn extend_image_to_bounds(_: impl Ctx, image: Item>, bounds: Item) -> Result>, (Item>, ImageError)> { let bounds = *bounds.element(); let image_transform: DAffine2 = image.attribute_cloned_or_default(ATTR_TRANSFORM); let image_aabb = Bbox::unit().affine_transform(image_transform).to_axis_aligned_bbox(); let bounds_aabb = Bbox::unit().affine_transform(bounds.transform()).to_axis_aligned_bbox(); if image_aabb.contains(bounds_aabb.start) && image_aabb.contains(bounds_aabb.end) { - return image; + return Ok(image); } - let (image, mut attributes) = image.into_parts(); - let (image_width, image_height) = (image.width, image.height); + let (image_width, image_height) = (image.element().width, image.element().height); if image_width == 0 || image_height == 0 { - return empty_image(bounds, Color::TRANSPARENT); + return empty_image(bounds, Color::TRANSPARENT).map_err(|error| (image, error)); + } + if image_transform.matrix2.determinant() == 0. { + return Ok(image); } let orig_image_scale = DVec2::new(image_width as f64, image_height as f64); @@ -157,7 +175,11 @@ fn extend_image_to_bounds(_: impl Ctx, image: Item>, bounds: Item new_image, + Err(error) => return Err((image, error)), + }; + let (image, mut attributes) = image.into_parts(); let offset_in_new_image = (-new_start).as_uvec2(); for y in 0..image_height { let old_start = y * image_width; @@ -172,17 +194,17 @@ fn extend_image_to_bounds(_: impl Ctx, image: Item>, bounds: Item Item> { +fn empty_image(transform: DAffine2, color: Color) -> Result>, ImageError> { let width = transform.transform_vector2(DVec2::new(1., 0.)).length() as u32; let height = transform.transform_vector2(DVec2::new(0., 1.)).length() as u32; - let image = Image::new(width, height, color); + let image = Image::try_new(width, height, color)?; - Item::new_from_element(Raster::new_cpu(image)).with_attribute(ATTR_TRANSFORM, transform) + Ok(Item::new_from_element(Raster::new_cpu(image)).with_attribute(ATTR_TRANSFORM, transform)) } /// Displays an image from a file. @@ -195,11 +217,14 @@ fn image( /// The image file to display. #[widget(ParsedWidgetOverride::Custom = "image_file")] resource: Item, -) -> Item> { +) -> Result>, ImageError> { let resource = resource.into_element(); - let Some(image) = Image::from_encoded(resource.as_ref()) else { return Item::default() }; + if resource.is_empty() { + return Ok(Item::default()); + } + let image = Image::from_encoded(resource.as_ref())?; - Item::new_from_element(Raster::new_cpu(image)) + Ok(Item::new_from_element(Raster::new_cpu(image))) } /// Generates customizable procedural noise patterns. @@ -238,7 +263,7 @@ fn noise_pattern( #[widget(ParsedWidgetOverride::Custom = "noise_inspector_cellular_jitter")] #[default(1.)] cellular_jitter: Item, -) -> Item> { +) -> Result>, ImageError> { let (clip, seed, scale, domain_warp_amplitude) = (*clip.element(), *seed.element(), *scale.element(), *domain_warp_amplitude.element()); let (fractal_octaves, fractal_lacunarity, fractal_gain) = (*fractal_octaves.element(), *fractal_lacunarity.element(), *fractal_gain.element()); let (fractal_weighted_strength, fractal_ping_pong_strength, cellular_jitter) = (*fractal_weighted_strength.element(), *fractal_ping_pong_strength.element(), *cellular_jitter.element()); @@ -262,7 +287,7 @@ fn noise_pattern( // If the image would not be visible, return an empty image if size.x <= 0. || size.y <= 0. { - return Item::default(); + return Ok(Item::default()); } let transform = DAffine2::from_translation(offset) * DAffine2::from_scale(size); @@ -272,7 +297,7 @@ fn noise_pattern( let height = (size.y * footprint_scale.y) as u32; // All - let mut image = Image::new(width, height, Color::from_luminance(0.5)); + let mut image = Image::try_new(width, height, Color::from_luminance(0.5))?; let mut noise = fastnoise_lite::FastNoiseLite::with_seed(seed as i32); noise.set_frequency(Some(1. / (scale as f32).max(f32::EPSILON))); @@ -308,7 +333,7 @@ fn noise_pattern( } } - return Item::new_from_element(Raster::new_cpu(image)).with_attribute(ATTR_TRANSFORM, transform); + return Ok(Item::new_from_element(Raster::new_cpu(image)).with_attribute(ATTR_TRANSFORM, transform)); } }; noise.set_noise_type(Some(noise_type)); @@ -366,11 +391,11 @@ fn noise_pattern( } } - Item::new_from_element(Raster::new_cpu(image)).with_attribute(ATTR_TRANSFORM, transform) + Ok(Item::new_from_element(Raster::new_cpu(image)).with_attribute(ATTR_TRANSFORM, transform)) } #[node_macro::node(category("Raster"), icon("NodeMandelbrot"))] -fn mandelbrot(ctx: impl ExtractFootprint + Send) -> Item> { +fn mandelbrot(ctx: impl ExtractFootprint + Send) -> Result>, ImageError> { let footprint = ctx.footprint(); let viewport_bounds = footprint.viewport_bounds_in_local_space(); @@ -382,14 +407,14 @@ fn mandelbrot(ctx: impl ExtractFootprint + Send) -> Item> { // If the image would not be visible, return an empty image if size.x <= 0. || size.y <= 0. { - return Item::default(); + return Ok(Item::default()); } let scale = footprint.scale(); let width = (size.x * scale.x) as u32; let height = (size.y * scale.y) as u32; - let mut data = Vec::with_capacity(width as usize * height as usize); + let mut image = Image::try_new(width, height, Color::TRANSPARENT)?; let max_iter = 255; let scale = 3. * size.as_vec2() / Vec2::new(width as f32, height as f32); @@ -400,17 +425,11 @@ fn mandelbrot(ctx: impl ExtractFootprint + Send) -> Item> { let c = pos * scale + coordinate_offset; let iter = mandelbrot_impl(c, max_iter); - data.push(map_color(iter, max_iter)); + *image.get_mut(x as usize, y as usize) = map_color(iter, max_iter); } } - Item::new_from_element(Raster::new_cpu(Image { - width, - height, - data, - ..Default::default() - })) - .with_attribute(ATTR_TRANSFORM, DAffine2::from_translation(offset) * DAffine2::from_scale(size)) + Ok(Item::new_from_element(Raster::new_cpu(image)).with_attribute(ATTR_TRANSFORM, DAffine2::from_translation(offset) * DAffine2::from_scale(size))) } #[inline(always)] From d1322ad28839171d7feab0832d918a2b23fbb712 Mon Sep 17 00:00:00 2001 From: Timon Date: Tue, 27 Oct 2026 03:02:52 +0000 Subject: [PATCH 7/8] Fix crashes and report errors in vector and transform nodes --- Cargo.lock | 3 + .../shapes/shape_utility.rs | 6 +- node-graph/libraries/vector-types/Cargo.toml | 1 + .../vector/algorithms/bezpath_algorithms.rs | 80 ++- .../src/vector/algorithms/intersection.rs | 5 +- .../src/vector/algorithms/offset_bezpath.rs | 24 +- .../src/vector/algorithms/shapes.rs | 68 ++- .../src/vector/algorithms/weld.rs | 104 ++-- .../src/vector/vector_attributes.rs | 18 +- .../vector-types/src/vector/vector_types.rs | 39 +- node-graph/nodes/transform/Cargo.toml | 1 + .../nodes/transform/src/transform_nodes.rs | 23 +- node-graph/nodes/vector/Cargo.toml | 1 + .../nodes/vector/src/generator_nodes.rs | 149 +++-- node-graph/nodes/vector/src/lib.rs | 3 - node-graph/nodes/vector/src/vector_nodes.rs | 573 ++++++++++++++---- node-graph/nodes/vector/src/voronoi.rs | 103 ++-- 17 files changed, 912 insertions(+), 289 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1681e5df161..3d1e4c05744 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6526,6 +6526,7 @@ dependencies = [ "node-macro", "rand", "serde", + "thiserror 2.0.18", "vector-types", ] @@ -6787,6 +6788,7 @@ dependencies = [ "repeat-nodes", "rustc-hash 2.1.1", "serde", + "thiserror 2.0.18", "tokio", "tsify", "vector-types", @@ -6816,6 +6818,7 @@ dependencies = [ "rustc-hash 2.1.1", "serde", "serde_json", + "thiserror 2.0.18", "tinyvec", "tsify", "wasm-bindgen", diff --git a/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs b/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs index c099bbed181..b79b0898488 100644 --- a/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs +++ b/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs @@ -480,7 +480,8 @@ pub fn star_outline(layer: Option, document: &DocumentMessa let viewport = document.metadata().transform_to_viewport(layer); let points = sides as u64; - let targets: Vec = vec![ClickTargetType::Path(star_polygon_bezpath(DVec2::ZERO, points, radius1, radius2))]; + let Ok(star) = star_polygon_bezpath(DVec2::ZERO, points, radius1, radius2) else { return }; + let targets: Vec = vec![ClickTargetType::Path(star)]; overlay_context.outline(targets.iter(), viewport, None); } @@ -495,7 +496,8 @@ pub fn polygon_outline(layer: Option, document: &DocumentMe let viewport = document.metadata().transform_to_viewport(layer); let points = sides as u64; - let targets: Vec = vec![ClickTargetType::Path(regular_polygon_bezpath(DVec2::ZERO, points, radius))]; + let Ok(polygon) = regular_polygon_bezpath(DVec2::ZERO, points, radius) else { return }; + let targets: Vec = vec![ClickTargetType::Path(polygon)]; overlay_context.outline(targets.iter(), viewport, None); } diff --git a/node-graph/libraries/vector-types/Cargo.toml b/node-graph/libraries/vector-types/Cargo.toml index 4eddfa5db19..84de8cb308a 100644 --- a/node-graph/libraries/vector-types/Cargo.toml +++ b/node-graph/libraries/vector-types/Cargo.toml @@ -40,6 +40,7 @@ rustc-hash = { workspace = true } polycool = { workspace = true } robust = { workspace = true } tinyvec = { workspace = true } +thiserror = { workspace = true } # Optional workspace dependencies serde = { workspace = true, optional = true } diff --git a/node-graph/libraries/vector-types/src/vector/algorithms/bezpath_algorithms.rs b/node-graph/libraries/vector-types/src/vector/algorithms/bezpath_algorithms.rs index 33062248258..ca473547317 100644 --- a/node-graph/libraries/vector-types/src/vector/algorithms/bezpath_algorithms.rs +++ b/node-graph/libraries/vector-types/src/vector/algorithms/bezpath_algorithms.rs @@ -81,9 +81,20 @@ pub fn tangent_on_bezpath(bezpath: &BezPath, t_value: TValue, segments_length: O dvec2_to_point(pathseg_tangent(segment, t)) } -/// Computes sample locations along a bezpath, returning parametric `(segment_index, t)` pairs and whether the path was closed. +/// The failure of [`compute_sample_locations`]: the path would need more samples than the most it places on one subpath. +#[derive(Debug, thiserror::Error)] +pub enum SampleCountError { + #[error("The separation is too small for this path: it would need more than {limit} points")] + SeparationTooSmall { limit: usize }, + #[error("The quantity is more than the maximum of {limit} points")] + QuantityTooLarge { limit: usize }, +} + +/// Computes sample locations along a bezpath, returning parametric `(segment_index, t)` pairs and whether the path was closed, +/// or `None` when there's nothing to sample. /// The `bezpath` is used for euclidean-to-parametric conversion, and `segments_length` provides pre-calculated world-space segment lengths. /// Callers can evaluate these locations on any bezpath with the same topology (e.g., an untransformed version). +#[allow(clippy::type_complexity)] pub fn compute_sample_locations( bezpath: &BezPath, point_spacing_type: PointSpacingType, @@ -92,7 +103,7 @@ pub fn compute_sample_locations( stop_offset: f64, adaptive_spacing: bool, segments_length: &[f64], -) -> Option<(Vec<(usize, f64)>, bool)> { +) -> Result, bool)>, SampleCountError> { let was_closed = matches!(bezpath.elements().last(), Some(PathEl::ClosePath)); // Calculate the total length of the collected segments. @@ -103,36 +114,44 @@ pub fn compute_sample_locations( // Sanity check that the usable length is positive. if used_length <= 0. { - return None; + return Ok(None); } - const SAFETY_MAX_COUNT: f64 = 10_000. - 1.; - + const MAX_POINTS: usize = 10_000; // Determine the number of points to generate along the path. let sample_count = match point_spacing_type { PointSpacingType::Separation => { let spacing = amount.min(used_length - f64::EPSILON); - if adaptive_spacing { + let count = if adaptive_spacing { // Calculate point count to evenly distribute points while covering the entire path. // With adaptive spacing, we widen or narrow the points as necessary to ensure the last point is always at the end of the path. - (used_length / spacing).round().min(SAFETY_MAX_COUNT) + (used_length / spacing).round() } else { // Calculate point count based on exact spacing, which may not cover the entire path. // Without adaptive spacing, we just evenly space the points at the exact specified spacing, usually falling short before the end of the path. - let count = (used_length / spacing + f64::EPSILON).floor().min(SAFETY_MAX_COUNT); - if count != SAFETY_MAX_COUNT { - used_length -= used_length % spacing; - } - count + (used_length / spacing + f64::EPSILON).floor() + }; + if count >= MAX_POINTS as f64 { + return Err(SampleCountError::SeparationTooSmall { limit: MAX_POINTS }); + } + if !adaptive_spacing { + used_length -= used_length % spacing; } + count + } + PointSpacingType::Quantity => { + let count = (amount - 1.).floor(); + if count >= MAX_POINTS as f64 { + return Err(SampleCountError::QuantityTooLarge { limit: MAX_POINTS }); + } + count.max(1.) } - PointSpacingType::Quantity => (amount - 1.).floor().clamp(1., SAFETY_MAX_COUNT), }; // Skip if there are no points to generate. if sample_count < 1. { - return None; + return Ok(None); } // Decide how many loop-iterations: if closed, skip the last duplicate point @@ -169,7 +188,7 @@ pub fn compute_sample_locations( locations.push((next_segment_index, t)); } - Some((locations, was_closed)) + Ok(Some((locations, was_closed))) } #[derive(Debug, Clone, Copy)] @@ -277,7 +296,8 @@ pub(crate) fn pathseg_length_centroid_and_length(segment: PathSeg, accuracy: Opt /// Finds the parametric `t` value on the given path segment corresponding to a fractional arc-length `distance` (0–1). /// Delegates to kurbo's `inv_arclen` which uses the ITP method with incremental arc-length computation. pub fn eval_pathseg_euclidean(segment: PathSeg, distance: f64, accuracy: f64) -> f64 { - segment.inv_arclen(distance.clamp(0., 1.) * segment.arclen(accuracy), accuracy) + let length = segment.arclen(accuracy); + segment.inv_arclen(distance.clamp(0., 1.) * length, accuracy.max(length * 1e-14)) } /// Converts from a bezpath (composed of multiple segments) to a point along a certain segment represented. @@ -385,6 +405,10 @@ pub fn poisson_disk_points(bezpath_index: usize, bezpaths: &[(BezPath, Rect)], s let width = this_bbox.width(); let height = this_bbox.height(); + if width.is_infinite() || height.is_infinite() { + return Vec::new(); + } + poisson_disk_sample(offset, width, height, separation_disk_diameter, point_in_shape_checker, line_intersect_shape_checker, rng) } @@ -645,7 +669,7 @@ pub fn bezpath_area_centroid_and_area(bezpath: &BezPath, error: Option, min /// The length centroid is the center of mass for the arc length of the solid shape's perimeter. /// An infinitely thin wire forming the path's shape would balance at this point. /// -/// It will return `None` if no segment is present. +/// It will return `None` if no segment has any length. /// - `accuracy` is used to approximate the curve. /// - `always_closed` is to consider the path as closed always. pub fn bezpath_length_centroid_and_length(bezpath: &BezPath, accuracy: Option, always_closed: bool) -> Option<(DVec2, f64)> { @@ -654,6 +678,7 @@ pub fn bezpath_length_centroid_and_length(bezpath: &BezPath, accuracy: Option) -> Vec<(f6 PathSeg::Cubic(cubic_bez) => cubic_bez, }; + let size = approx_bounding_box(segment).size(); + let accuracy_for_size = DEFAULT_ACCURACY.max(size.width.max(size.height) * 1e-9); + // Get 2 copies of the reduced curves - let quads1 = cubic_bez.to_quads(DEFAULT_ACCURACY).map(|(t1, t2, quad_bez)| (t1, t2, PathSeg::Quad(quad_bez))).collect::>(); + let quads1 = cubic_bez.to_quads(accuracy_for_size).map(|(t1, t2, quad_bez)| (t1, t2, PathSeg::Quad(quad_bez))).collect::>(); let quads2 = quads1.clone(); let num_curves = quads1.len(); diff --git a/node-graph/libraries/vector-types/src/vector/algorithms/offset_bezpath.rs b/node-graph/libraries/vector-types/src/vector/algorithms/offset_bezpath.rs index 112df67a7cf..9c0d8e48af6 100644 --- a/node-graph/libraries/vector-types/src/vector/algorithms/offset_bezpath.rs +++ b/node-graph/libraries/vector-types/src/vector/algorithms/offset_bezpath.rs @@ -1,21 +1,30 @@ use super::bezpath_algorithms::{clip_simple_bezpaths, miter_line_join, round_line_join}; use super::consts::MAX_COINCIDENT_POINT_DISTANCE; use crate::vector::misc::point_to_dvec2; -use kurbo::{BezPath, Join, ParamCurve, PathEl, PathSeg}; +use kurbo::{BezPath, Join, ParamCurve, PathEl, PathSeg, Rect, Shape}; /// Value to control smoothness and mathematical accuracy to offset a cubic Bezier. const CUBIC_REGULARIZATION_ACCURACY: f64 = 0.5; /// Squared version to avoid sqrt in distance checks. const MAX_COINCIDENT_POINT_DISTANCE_SQUARED: f64 = MAX_COINCIDENT_POINT_DISTANCE * MAX_COINCIDENT_POINT_DISTANCE; const MAX_FITTED_SEGMENTS: usize = 10000; +/// The coordinate magnitude from which lyon's curve intersection, used to clip the offset pieces, panics converting it to an `i64`. +const MAX_CLIPPED_COORDINATE: f64 = i64::MAX as f64; + +/// The failure of [`offset_bezpath`]. +#[derive(Debug, thiserror::Error)] +pub enum OffsetPathError { + #[error("The shape or offset distance is too large to offset")] + TooLarge, +} /// Reduces the segments of the bezpath into simple subcurves, then offset each subcurve a set `distance` away. /// The intersections of segments of the subpath are joined using the method specified by the `join` argument. -pub fn offset_bezpath(bezpath: &BezPath, distance: f64, join: Join, miter_limit: Option) -> BezPath { +pub fn offset_bezpath(bezpath: &BezPath, distance: f64, join: Join, miter_limit: Option) -> Result { // An offset at a distance 0 from the curve is simply the same curve. // An offset of a single point is not defined. if distance == 0. || bezpath.get_seg(1).is_none() { - return bezpath.clone(); + return Ok(bezpath.clone()); } let mut bezpaths = bezpath @@ -45,7 +54,12 @@ pub fn offset_bezpath(bezpath: &BezPath, distance: f64, join: Join, miter_limit: .collect::>(); if bezpaths.is_empty() { - return BezPath::new(); + return Ok(BezPath::new()); + } + + let too_large = |bounds: Rect| [bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().any(|coordinate| coordinate.abs() >= MAX_CLIPPED_COORDINATE); + if bezpaths.iter().any(|bezpath| too_large(bezpath.bounding_box())) { + return Err(OffsetPathError::TooLarge); } // Clip or join consecutive subpaths @@ -152,5 +166,5 @@ pub fn offset_bezpath(bezpath: &BezPath, distance: f64, join: Join, miter_limit: offset_bezpath.close_path(); } - offset_bezpath + Ok(offset_bezpath) } diff --git a/node-graph/libraries/vector-types/src/vector/algorithms/shapes.rs b/node-graph/libraries/vector-types/src/vector/algorithms/shapes.rs index ff21a27b5b7..1da901ae3c0 100644 --- a/node-graph/libraries/vector-types/src/vector/algorithms/shapes.rs +++ b/node-graph/libraries/vector-types/src/vector/algorithms/shapes.rs @@ -13,6 +13,19 @@ use std::f64::consts::TAU; /// Constant from const HANDLE_OFFSET_FACTOR: f64 = 0.551784777779014; +/// Relative growth per radian below which spiral arc lengths use a circle, since the closed forms divide by the growth. +const SPIRAL_CIRCLE_GROWTH: f64 = 1e-6; + +/// The most anchors a polygon or star can have before allocating them overflows the address space. +const MAX_POLYGON_ANCHORS: u64 = (isize::MAX as usize / size_of::()) as u64; + +/// The failure of [`regular_polygon_bezpath`] and [`star_polygon_bezpath`]. +#[derive(Debug, thiserror::Error)] +pub enum PolygonError { + #[error("A shape with {0} sides is too large to fit in memory")] + TooManySides(u64), +} + /// An anchor point with its optional incoming and outgoing handle positions, in absolute coordinates. #[derive(Clone)] struct Anchor { @@ -64,8 +77,8 @@ pub fn rounded_rectangle_bezpath(corner1: DVec2, corner2: DVec2, corner_radii: [ let handle_offset = radius * HANDLE_OFFSET_FACTOR; vec![ - Anchor::new(point1, None, Some(point1 + handle_offset * (corner - point1).normalize())), - Anchor::new(point2, Some(point2 + handle_offset * (corner - point2).normalize()), None), + Anchor::new(point1, None, Some(point1 + handle_offset * (corner - point1).normalize_or_zero())), + Anchor::new(point2, Some(point2 + handle_offset * (corner - point2).normalize_or_zero()), None), ] }; @@ -162,7 +175,12 @@ pub fn arc_bezpath(radius: f64, start_angle: f64, sweep_angle: f64, arc_type: Ar } /// Constructs a regular polygon (ngon). Based on `sides` and `radius`, which is the distance from the center to any vertex. -pub fn regular_polygon_bezpath(center: DVec2, sides: u64, radius: f64) -> BezPath { +/// Fails when it has more sides than can be allocated. +pub fn regular_polygon_bezpath(center: DVec2, sides: u64, radius: f64) -> Result { + if sides > MAX_POLYGON_ANCHORS { + return Err(PolygonError::TooManySides(sides)); + } + let sides = sides.max(3); let angle_increment = TAU / (sides as f64); let positions = (0..sides).map(|i| { @@ -170,11 +188,16 @@ pub fn regular_polygon_bezpath(center: DVec2, sides: u64, radius: f64) -> BezPat center + radius * DVec2::new(f64::cos(angle), f64::sin(angle)) }); - polyline_bezpath(positions, true) + Ok(polyline_bezpath(positions, true)) } /// Constructs a star polygon (n-star). See [`regular_polygon_bezpath`], but with interspersed vertices at an `inner_radius`. -pub fn star_polygon_bezpath(center: DVec2, sides: u64, radius: f64, inner_radius: f64) -> BezPath { +pub fn star_polygon_bezpath(center: DVec2, sides: u64, radius: f64, inner_radius: f64) -> Result { + // Each side has an outer and an inner anchor + if sides > MAX_POLYGON_ANCHORS / 2 { + return Err(PolygonError::TooManySides(sides)); + } + let sides = sides.max(2); let angle_increment = 0.5 * TAU / (sides as f64); let positions = (0..sides * 2).map(|i| { @@ -183,7 +206,7 @@ pub fn star_polygon_bezpath(center: DVec2, sides: u64, radius: f64, inner_radius center + radius * DVec2::new(f64::cos(angle), f64::sin(angle)) }); - polyline_bezpath(positions, true) + Ok(polyline_bezpath(positions, true)) } /// Constructs a line from `point1` to `point2`. @@ -224,8 +247,26 @@ pub fn arrow_bezpath(start: DVec2, end: DVec2, shaft_width: f64, head_width: f64 polyline_bezpath(positions, true) } +/// The failure of [`spiral_bezpath`]. +#[derive(Debug, thiserror::Error)] +pub enum SpiralError { + #[error("The angular resolution must be greater than 0")] + AngularResolutionNotPositive, + #[error("The number of turns must be finite")] + TurnsNotFinite, + #[error("The start angle is too large to step through at this angular resolution")] + AngleTooLarge, +} + /// Constructs a spiral winding from an inner radius `a` out to `outer_radius`, sampled every `delta_theta` radians. -pub fn spiral_bezpath(a: f64, outer_radius: f64, turns: f64, start_angle: f64, delta_theta: f64, spiral_type: SpiralType) -> BezPath { +pub fn spiral_bezpath(a: f64, outer_radius: f64, turns: f64, start_angle: f64, delta_theta: f64, spiral_type: SpiralType) -> Result { + if delta_theta.is_nan() || delta_theta <= 0. { + return Err(SpiralError::AngularResolutionNotPositive); + } + if turns == f64::INFINITY { + return Err(SpiralError::TurnsNotFinite); + } + let mut anchors = Vec::new(); let mut prev_in_handle = None; let theta_end = turns * TAU + start_angle; @@ -236,6 +277,10 @@ pub fn spiral_bezpath(a: f64, outer_radius: f64, turns: f64, start_angle: f64, d let mut theta = start_angle; while theta < theta_end { let theta_next = f64::min(theta + delta_theta, theta_end); + // A step lost to the angle's float precision would never reach the end + if theta_next == theta { + return Err(SpiralError::AngleTooLarge); + } let p0 = spiral_point(theta, a, b, spiral_type); let p3 = spiral_point(theta_next, a, b, spiral_type); @@ -260,7 +305,7 @@ pub fn spiral_bezpath(a: f64, outer_radius: f64, turns: f64, start_angle: f64, d theta = theta_next; } - bezpath_from_anchors(&anchors, false) + Ok(bezpath_from_anchors(&anchors, false)) } pub fn calculate_growth_factor(a: f64, turns: f64, outer_radius: f64, spiral_type: SpiralType) -> f64 { @@ -309,6 +354,9 @@ fn log_spiral_point(theta: f64, a: f64, b: f64) -> DVec2 { /// Computes arc length along a logarithmic spiral between two angles. fn log_spiral_arc_length(theta_start: f64, theta_end: f64, a: f64, b: f64) -> f64 { let factor = (1. + b * b).sqrt(); + if b.abs() < SPIRAL_CIRCLE_GROWTH { + return a * (b * (theta_start + theta_end) / 2.).exp() * factor * (theta_end - theta_start); + } (a / b) * factor * ((b * theta_end).exp() - (b * theta_start).exp()) } @@ -337,6 +385,10 @@ fn archimedean_spiral_tangent(theta: f64, a: f64, b: f64) -> DVec2 { /// Computes arc length along an Archimedean spiral between two angles. fn archimedean_spiral_arc_length(theta_start: f64, theta_end: f64, a: f64, b: f64) -> f64 { + let middle_radius = a + b * (theta_start + theta_end) / 2.; + if b.abs() <= middle_radius.abs() * SPIRAL_CIRCLE_GROWTH { + return middle_radius.abs() * (theta_end - theta_start); + } archimedean_spiral_arc_length_origin(theta_end, a, b) - archimedean_spiral_arc_length_origin(theta_start, a, b) } diff --git a/node-graph/libraries/vector-types/src/vector/algorithms/weld.rs b/node-graph/libraries/vector-types/src/vector/algorithms/weld.rs index 36756e755db..2fffc16a904 100644 --- a/node-graph/libraries/vector-types/src/vector/algorithms/weld.rs +++ b/node-graph/libraries/vector-types/src/vector/algorithms/weld.rs @@ -59,56 +59,58 @@ impl WeldExt for Vector { }) .collect::>(); - // Collect points and segments to delete at the end to avoid invalidating indices + // Each collapse set keeps its first point, which every other point in the set merges into + let mut collapse_group = FxHashMap::default(); + let mut kept_points = Vec::with_capacity(collapse.len()); let mut points_to_delete = FxHashSet::default(); - let mut segments_to_delete = FxHashSet::default(); - for (mut collapse_set, average_pos) in collapse.into_iter().zip(average_position) { - // Remove any segments where both endpoints are in the collapse set - for segment_index in 0..working.segment_ids.len() { - let start = working.point_ids[working.segment_start[segment_index]]; - let end = working.point_ids[working.segment_end[segment_index]]; - if collapse_set.contains(&start) && collapse_set.contains(&end) { - segments_to_delete.insert(working.segment_ids[segment_index]); + for (group, collapse_set) in collapse.iter().enumerate() { + let first_id = collapse_set.iter().copied().next().unwrap(); + kept_points.push((first_id, indices.point_to_offset[&first_id])); + + for &id in collapse_set { + collapse_group.insert(id, group); + if id != first_id { + points_to_delete.insert(id); } } + } - // Delete all points but the first, set its position to the average, and update segments - let first_id = collapse_set.iter().copied().next().unwrap(); - collapse_set.remove(&first_id); - let first_offset = indices.point_to_offset[&first_id]; - - // Look for segments with endpoints in `collapse_set` and replace them with the point we are collapsing to - for segment_index in 0..working.segment_ids.len() { - let start_id = working.point_ids[working.segment_start[segment_index]]; - let end_id = working.point_ids[working.segment_end[segment_index]]; - - // Update Bezier handles for moved points - if start_id == first_id { - let point_position = working.point_positions[working.segment_start[segment_index]]; - working.segment_handles[segment_index].move_start(average_pos - point_position); - } - if end_id == first_id { - let point_position = working.point_positions[working.segment_end[segment_index]]; - working.segment_handles[segment_index].move_end(average_pos - point_position); - } + // Collect segments to delete at the end to avoid invalidating indices + let mut segments_to_delete = FxHashSet::default(); + for segment_index in 0..working.segment_ids.len() { + let start_id = working.point_ids[working.segment_start[segment_index]]; + let end_id = working.point_ids[working.segment_end[segment_index]]; + let (start_group, end_group) = (collapse_group.get(&start_id).copied(), collapse_group.get(&end_id).copied()); - // Replace removed points with the collapsed point - if collapse_set.contains(&start_id) { - let point_position = working.point_positions[working.segment_start[segment_index]]; + // Remove any segments where both endpoints are in the same collapse set + if start_group.is_some() && start_group == end_group { + segments_to_delete.insert(working.segment_ids[segment_index]); + } + + // Move the segment's ends in a collapse set, along with their handles, onto the point the set collapses to + if let Some(group) = start_group { + let point_position = working.point_positions[working.segment_start[segment_index]]; + working.segment_handles[segment_index].move_start(average_position[group] - point_position); + + let (first_id, first_offset) = kept_points[group]; + if start_id != first_id { working.segment_start[segment_index] = first_offset; - working.segment_handles[segment_index].move_start(average_pos - point_position); } - if collapse_set.contains(&end_id) { - let point_position = working.point_positions[working.segment_end[segment_index]]; + } + if let Some(group) = end_group { + let point_position = working.point_positions[working.segment_end[segment_index]]; + working.segment_handles[segment_index].move_end(average_position[group] - point_position); + + let (first_id, first_offset) = kept_points[group]; + if end_id != first_id { working.segment_end[segment_index] = first_offset; - working.segment_handles[segment_index].move_end(average_pos - point_position); } } + } - // Update the position of the collapsed point + // Update the positions of the collapsed points + for (&(_, first_offset), average_pos) in kept_points.iter().zip(average_position) { working.point_positions[first_offset] = average_pos; - - points_to_delete.extend(collapse_set) } // Drop the collapsed segments, then the merged-away points, remapping segment endpoints @@ -404,13 +406,13 @@ fn weld_nearby_points(vector: &mut Vector, paths: &mut List, transform: DA // Create a spatial grid with cell size of 'distance' use std::collections::HashMap; - let mut grid: HashMap<(i32, i32), Vec> = HashMap::new(); + let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); // Add points to grid cells without collecting all positions first for i in 0..point_count { let pos = transform.transform_point2(working.point_positions[i]); - let grid_x = ((pos.x - min_x) / distance).floor() as i32; - let grid_y = ((pos.y - min_y) / distance).floor() as i32; + let grid_x = ((pos.x - min_x) / distance).floor() as i64; + let grid_y = ((pos.y - min_y) / distance).floor() as i64; grid.entry((grid_x, grid_y)).or_default().push(i); } @@ -428,15 +430,16 @@ fn weld_nearby_points(vector: &mut Vector, paths: &mut List, transform: DA } let pos_i = transform.transform_point2(working.point_positions[i]); - let grid_x = ((pos_i.x - min_x) / distance).floor() as i32; - let grid_y = ((pos_i.y - min_y) / distance).floor() as i32; + let grid_x = ((pos_i.x - min_x) / distance).floor() as i64; + let grid_y = ((pos_i.y - min_y) / distance).floor() as i64; let mut group = vec![i]; // Check only neighboring cells (3x3 grid around current cell) for dx in -1..=1 { for dy in -1..=1 { - let neighbor_cell = (grid_x + dx, grid_y + dy); + // The saturating cast puts infinite points in the grid's edge cells, which have no neighbors beyond + let Some(neighbor_cell) = grid_x.checked_add(dx).zip(grid_y.checked_add(dy)) else { continue }; if let Some(indices) = grid.get(&neighbor_cell) { for &j in indices { @@ -697,6 +700,19 @@ mod tests { vector.0.iter_attribute_values::(ATTR_ID).map(|ids| ids.map(|id| id.inner()).collect()).unwrap_or_default() } + // A point too far off for the grid of cells to hold sits at its edge, which has no cells beyond it to check + #[test] + fn weld_all_beside_an_infinite_point() { + let mut vector = pool(&[(0., 0.), (0.05, 0.), (f64::INFINITY, 0.)]); + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [0, 1, 2], []); + + vector.weld_all(&mut paths, DAffine2::IDENTITY, 0.1); + + assert_eq!(vector.0.len(), 2); + assert!(near(vector.0.element(0), (0.025, 0.))); + } + // Two lines meeting across a small gap join there, while two close points of another path stay apart since only one is an end #[test] fn flow_ends_join_without_touching_other_points() { diff --git a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs index db5f167359c..cb7cbfee095 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs @@ -333,7 +333,7 @@ impl SegmentGraph { let tangent = self.segment(side.segment_index, side.reversed).tangent_at_start(); tangent.y.atan2(tangent.x) }); - angle[0].partial_cmp(&angle[1]).unwrap_or(std::cmp::Ordering::Equal) + angle[0].total_cmp(&angle[1]) }) } @@ -1519,4 +1519,20 @@ mod tests { assert!(loop_area > 0., "the inner loop should wind positively after normalization"); assert_eq!(references(&paths), vec![vec![0, 1, 4, 3, 2, 1, 5]], "the path stays whole around its flipped loop"); } + + // Sorting more than 20 segment ends around a junction by their angles mustn't panic where NaN geometry gives no angle + #[test] + fn faces_around_a_junction_with_nan_spokes() { + // The spokes are stored out of angle order, since the sort only notices the inconsistent comparisons when it has to reorder + let spokes = 24; + let ring = (0..spokes).map(|index| match index % 3 { + 0 => DVec2::NAN, + _ => DVec2::from_angle((index * 7 % spokes) as f64 / spokes as f64 * std::f64::consts::TAU) * 10., + }); + let points: Vec = std::iter::once(DVec2::ZERO).chain(ring).collect(); + let segments: Vec<(usize, usize)> = (1..=spokes).flat_map(|index| [(0, index), (index, index % spokes + 1)]).collect(); + let (vector, paths) = build_vector(&points, &segments); + + vector.construct_faces(&paths); + } } diff --git a/node-graph/libraries/vector-types/src/vector/vector_types.rs b/node-graph/libraries/vector-types/src/vector/vector_types.rs index 05fa4876090..641023006c1 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_types.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_types.rs @@ -797,9 +797,11 @@ impl Vector { pub fn concat(&mut self, paths: &mut List, additional: &Self, additional_paths: &List, transform_of_additional: DAffine2, collision_hash_seed: u64, taken_ids: &mut IdCollisionSet) { let offset = self.0.len() as u32; + // If the pool lacks the ID column, appending IDs pads its points with a default one, which is then taken too + let first_untaken_point = if self.0.iter_attribute_values::(ATTR_ID).is_some() { self.0.len() } else { 0 }; + // Remap colliding stable IDs so both sides stay addressable after the merge. // Appended IDs are only checked against the IDs taken before this call. - let mut appended_point_ids = Vec::new(); for index in 0..additional.0.len() { let Some(&position) = additional.0.element(index) else { continue }; let mut item = Item::new_from_element(transform_of_additional.transform_point2(position)); @@ -811,7 +813,6 @@ impl Vector { id }; item.set_attribute(ATTR_ID, id); - appended_point_ids.push(id); } self.0.push(item); @@ -837,7 +838,9 @@ impl Vector { paths.push(item); } - taken_ids.points.extend(appended_point_ids); + // Read back from the pool, since appending points without IDs to a pool with the column pads them with a default one + let appended_point_ids = self.0.iter_attribute_values::(ATTR_ID).into_iter().flatten().skip(first_untaken_point); + taken_ids.points.extend(appended_point_ids.copied()); taken_ids.segments.extend(appended_segment_ids); } @@ -1038,6 +1041,36 @@ mod tests { assert_eq!(vector.index_of_point_id(PointId::ZERO), Some(0)); } + #[test] + fn concat_with_tracked_ids_matches_collecting_them_anew() { + let triangle = BezPath::from_vec(vec![ + PathEl::MoveTo((0., 0.).into()), + PathEl::LineTo((1., 0.).into()), + PathEl::LineTo((0., 1.).into()), + PathEl::ClosePath, + ]); + let (triangle, triangle_paths) = Vector::from_bezpath(&triangle); + let (curve, curve_paths) = Vector::from_bezpath(&open_curve_bezpath()); + let unnamed = Vector(List::new_from_element(DVec2::ONE)); + let parts = [ + (&unnamed, &List::new()), + (&triangle, &triangle_paths), + (&lone_anchor(DVec2::X), &List::new()), + (&curve, &curve_paths), + (&triangle, &triangle_paths), + ]; + + let (mut each, mut each_paths) = (Vector::default(), List::new()); + let (mut tracked, mut tracked_paths, mut ids) = (Vector::default(), List::new(), IdCollisionSet::default()); + for (seed, (part, part_paths)) in parts.into_iter().enumerate() { + let mut collected = IdCollisionSet::of(&each, &each_paths); + each.concat(&mut each_paths, part, part_paths, DAffine2::IDENTITY, seed as u64, &mut collected); + tracked.concat(&mut tracked_paths, part, part_paths, DAffine2::IDENTITY, seed as u64, &mut ids); + } + + assert_eq!((tracked, tracked_paths), (each, each_paths)); + } + // Handles carry the unnamed filler, so a zero-named anchor behind them is still the one found by ID #[test] fn handles_do_not_shadow_a_zero_named_anchor() { diff --git a/node-graph/nodes/transform/Cargo.toml b/node-graph/nodes/transform/Cargo.toml index f2d61195570..fe28f7a7d0f 100644 --- a/node-graph/nodes/transform/Cargo.toml +++ b/node-graph/nodes/transform/Cargo.toml @@ -27,6 +27,7 @@ node-macro = { workspace = true } # Workspace dependencies glam = { workspace = true } rand = { workspace = true } +thiserror = { workspace = true } # Optional workspace dependencies serde = { workspace = true, optional = true } diff --git a/node-graph/nodes/transform/src/transform_nodes.rs b/node-graph/nodes/transform/src/transform_nodes.rs index 6c1a5aa8347..d6fbe0bf893 100644 --- a/node-graph/nodes/transform/src/transform_nodes.rs +++ b/node-graph/nodes/transform/src/transform_nodes.rs @@ -160,14 +160,27 @@ fn extract_transform(_: impl Ctx, #[implementations(Graphic, Vector, Ra Item::new_from_element(content.attribute_cloned_or_default(ATTR_TRANSFORM)) } +#[derive(Debug, thiserror::Error)] +pub enum InvertTransformError { + #[error("The transform can't be inverted because it collapses to a line or point")] + Singular, + #[error("The transform can't be inverted because it has values that aren't finite")] + NotFinite, +} + /// Produces the inverse of the input transform, which is the transform that undoes the effect of the original transform. #[node_macro::node(category("Transform"), icon("NodeInvertTransform"))] -fn invert_transform(_: impl Ctx, transform: Item) -> Item { - let (transform, attributes) = transform.into_parts(); - - let result = transform.inverse(); +fn invert_transform(_: impl Ctx, transform: Item) -> Result, (Item, InvertTransformError)> { + let element = *transform.element(); + if !element.is_finite() { + return Err((transform, InvertTransformError::NotFinite)); + } + let Some(result) = element.matrix2.determinant().is_normal().then(|| element.inverse()).filter(DAffine2::is_finite) else { + return Err((transform, InvertTransformError::Singular)); + }; - Item::from_parts(result, attributes) + let (_, attributes) = transform.into_parts(); + Ok(Item::from_parts(result, attributes)) } /// Extracts the translation component from the input transform. diff --git a/node-graph/nodes/vector/Cargo.toml b/node-graph/nodes/vector/Cargo.toml index 1815974dfb1..bab6a295bc1 100644 --- a/node-graph/nodes/vector/Cargo.toml +++ b/node-graph/nodes/vector/Cargo.toml @@ -38,6 +38,7 @@ rand = { workspace = true } rustc-hash = { workspace = true } log = { workspace = true } qrcodegen = { workspace = true } +thiserror = { workspace = true } # Optional workspace dependencies serde = { workspace = true, optional = true } diff --git a/node-graph/nodes/vector/src/generator_nodes.rs b/node-graph/nodes/vector/src/generator_nodes.rs index b88d5b0d4c3..12697de4547 100644 --- a/node-graph/nodes/vector/src/generator_nodes.rs +++ b/node-graph/nodes/vector/src/generator_nodes.rs @@ -4,10 +4,10 @@ use core_types::{CacheHash, Ctx}; use dyn_any::DynAny; use glam::DVec2; use graphic_types::Vector; -use vector_types::vector::algorithms::shapes; +use vector_types::vector::SetVectorPaths; +use vector_types::vector::algorithms::shapes::{self, PolygonError, SpiralError}; use vector_types::vector::misc::SpiralType; use vector_types::vector::misc::{ArcType, BoxCorners, GridTopology, GridType}; -use vector_types::vector::{Path, SetVectorPaths}; /// Generates a circle shape with a chosen radius. #[node_macro::node(category("Vector: Geometry"), icon("NodeCircle"))] @@ -69,7 +69,7 @@ fn spiral( #[unit("°")] #[default(90.)] angular_resolution: Item, -) -> Item { +) -> Result, SpiralError> { let (turns, start_angle, inner_radius, outer_radius, angular_resolution) = ( *turns.element(), *start_angle.element(), @@ -77,14 +77,15 @@ fn spiral( *outer_radius.element(), *angular_resolution.element(), ); - Vector::item_from_bezpath(&shapes::spiral_bezpath( + + Ok(Vector::item_from_bezpath(&shapes::spiral_bezpath( inner_radius, outer_radius, turns, start_angle.to_radians(), angular_resolution.to_radians(), spiral_type.into_element(), - )) + )?)) } /// Generates an ellipse shape (an oval or stretched circle) with the chosen radii. @@ -132,6 +133,9 @@ fn rectangle( let size = DVec2::new(*width.element(), *height.element()); let radii = corner_radius.element().to_corner_values(); + let mirror = DVec2::new(if size.x < 0. { -1. } else { 1. }, if size.y < 0. { -1. } else { 1. }); + let size = size * mirror; + // Scale down overlapping adjacent radii to fit, following the CSS spec: let radii = if *clamped.element() { let radii = radii.map(|radius| radius.max(0.)); @@ -150,7 +154,12 @@ fn rectangle( radii }; - Vector::item_from_bezpath(&shapes::rounded_rectangle_bezpath(size / -2., size / 2., radii)) + let mut bezpath = shapes::rounded_rectangle_bezpath(size / -2., size / 2., radii); + if mirror != DVec2::ONE { + bezpath.apply_affine(kurbo::Affine::scale_non_uniform(mirror.x, mirror.y)); + } + + Vector::item_from_bezpath(&bezpath) } /// Builds a set of four corner values, such as a rectangle's corner radii, from a list of one, two, three, or four values. @@ -175,9 +184,9 @@ fn regular_polygon( #[unit(" px")] #[default(50)] radius: Item, -) -> Item { +) -> Result, PolygonError> { let points = *sides.element() as u64; - Vector::item_from_bezpath(&shapes::regular_polygon_bezpath(DVec2::ZERO, points, *radius.element())) + Ok(Vector::item_from_bezpath(&shapes::regular_polygon_bezpath(DVec2::ZERO, points, *radius.element())?)) } /// Generates an n-pointed star shape with inner and outer points at chosen radii from the center. @@ -194,9 +203,9 @@ fn star( #[unit(" px")] #[default(25)] radius_2: Item, -) -> Item { +) -> Result, PolygonError> { let points = *sides.element() as u64; - Vector::item_from_bezpath(&shapes::star_polygon_bezpath(DVec2::ZERO, points, *radius_1.element(), *radius_2.element())) + Ok(Vector::item_from_bezpath(&shapes::star_polygon_bezpath(DVec2::ZERO, points, *radius_1.element(), *radius_2.element())?)) } /// Generates a heart shape with adjustable lobes, cleft, sides, and point. @@ -272,6 +281,12 @@ pub enum QRCodeErrorCorrectionLevel { High, } +#[derive(Debug, thiserror::Error)] +pub enum QrCodeError { + #[error("The text is too long for a QR code at this error correction level ({0})")] + TextTooLong(#[from] qrcodegen::DataTooLong), +} + /// Generates a QR code from the input text. #[node_macro::node(category("Vector: Geometry"), icon("NodeQrCode"), name("QR Code"))] fn qr_code( @@ -287,7 +302,7 @@ fn qr_code( size: Item, error_correction: Item, individual_squares: Item, -) -> Item { +) -> Result, QrCodeError> { let (text, error_correction) = (text.into_element(), error_correction.into_element()); let (has_size, size, individual_squares) = (*has_size.element(), *size.element(), *individual_squares.element()); @@ -298,9 +313,7 @@ fn qr_code( QRCodeErrorCorrectionLevel::High => qrcodegen::QrCodeEcc::High, }; - let Ok(qr_code) = qrcodegen::QrCode::encode_text(&text, ecc) else { - return Item::new_from_element(Vector::default()).with_paths(List::::new()); - }; + let qr_code = qrcodegen::QrCode::encode_text(&text, ecc)?; let (mut vector, paths) = match individual_squares { true => { @@ -323,7 +336,7 @@ fn qr_code( vector.transform(glam::DAffine2::from_scale(DVec2::splat(size / qr_code.size() as f64))); } - Item::new_from_element(vector).with_paths(paths) + Ok(Item::new_from_element(vector).with_paths(paths)) } /// Generates an arrow from the origin to the chosen coordinate. @@ -376,6 +389,12 @@ impl GridSpacing for DVec2 { } } +#[derive(Debug, thiserror::Error)] +pub enum GridError { + #[error("The isometric angles cancel out, which makes the cells infinitely wide")] + AnglesCancelOut, +} + /// Generates a rectangular or isometric grid of points with the chosen number of columns and rows, joined into paths by the chosen topology. #[node_macro::node(category("Vector: Geometry"), icon("NodeGrid"), inspector("grid_inspector"))] fn grid( @@ -400,7 +419,7 @@ fn grid( #[unit("°")] #[default(30., 30.)] angles: Item, -) -> Item { +) -> Result, GridError> { let (grid_type, topology, staggered, columns, rows, angles) = ( lattice.into_element(), topology.into_element(), @@ -417,6 +436,9 @@ fn grid( // Isometric grid spacing based on the two skew angles. Unused for rectangular grids. let tan_a = angle_a.to_radians().tan(); let tan_b = angle_b.to_radians().tan(); + if grid_type.is_isometric() && tan_a + tan_b == 0. { + return Err(GridError::AnglesCancelOut); + } let isometric_spacing = DVec2::new(y_spacing / (tan_a + tan_b), y_spacing); // The position of the grid point at column `x`, row `y`. @@ -432,9 +454,9 @@ fn grid( DVec2::new(isometric_spacing.x * x as f64, isometric_spacing.y * y as f64 + offset_y_fraction * isometric_spacing.x) }; - Lattice::new(grid_type, columns, rows, staggered && topology.supports_staggered(), position) + Ok(Lattice::new(grid_type, columns, rows, staggered && topology.supports_staggered(), position) .layout(topology) - .into_item() + .into_item()) } #[cfg(test)] @@ -451,17 +473,20 @@ mod tests { #[test] fn isometric_grid_test() { - // Doesn't crash with weird angles - grid( - (), - (), - item(GridType::IsometricTiling), - item(GridTopology::Edges), - item(false), - item(5_i64), - item(5_i64), - item(0.), - item((0., 0.).into()), + // Doesn't crash with weird angles, and errors where they cancel out + assert!( + grid( + (), + (), + item(GridType::IsometricTiling), + item(GridTopology::Edges), + item(false), + item(5_i64), + item(5_i64), + item(0.), + item((0., 0.).into()), + ) + .is_err() ); grid( (), @@ -473,7 +498,8 @@ mod tests { item(5_i64), item(90.), item((90., 90.).into()), - ); + ) + .unwrap(); // Works properly let grid = grid( @@ -486,7 +512,8 @@ mod tests { item(5_i64), item(10.), item((30., 30.).into()), - ); + ) + .unwrap(); let paths = shared_paths_from_item_attributes(&grid); assert_eq!(grid.element().anchor_points(&paths).count(), 5 * 5); assert_eq!(grid.element().segment_iter(&paths).count(), 4 * 5 + 4 * 9); @@ -509,7 +536,8 @@ mod tests { item(5_i64), item(10.), item((40., 30.).into()), - ); + ) + .unwrap(); let paths = shared_paths_from_item_attributes(&grid); assert_eq!(grid.element().anchor_points(&paths).count(), 5 * 5); assert_eq!(grid.element().segment_iter(&paths).count(), 4 * 5 + 4 * 9); @@ -533,7 +561,8 @@ mod tests { item(3_i64), item(10.), item((30., 30.).into()), - ); + ) + .unwrap(); let paths = shared_paths_from_item_attributes(&grid); let vector = grid.element(); @@ -554,7 +583,8 @@ mod tests { item(4_i64), item(10.), item((30., 30.).into()), - ); + ) + .unwrap(); let paths = shared_paths_from_item_attributes(&grid); let vector = grid.element(); @@ -576,7 +606,8 @@ mod tests { item(3_i64), item(10.), item((30., 30.).into()), - ); + ) + .unwrap(); let paths = shared_paths_from_item_attributes(&grid); let vector = grid.element(); assert_eq!(vector.stroke_manipulator_groups(&paths).filter(|(_, closed)| *closed).count(), 4); @@ -624,9 +655,55 @@ mod tests { item(1.), item(QRCodeErrorCorrectionLevel::Low), item(true), - ); + ) + .unwrap(); let paths = shared_paths_from_item_attributes(&qr); assert!(qr.element().anchor_points(&paths).count() > 0); assert!(!qr.element().segment_ids(&paths).is_empty()); } + + #[test] + fn qr_code_reports_text_too_long() { + let qr = qr_code((), (), item("x".repeat(3000)), item(false), item(1.), item(QRCodeErrorCorrectionLevel::High), item(true)); + assert!(matches!(qr, Err(QrCodeError::TextTooLong(_)))); + } + + // A radius small enough to underflow leaves no direction from its anchor to the corner to put the handle along + #[test] + fn rectangle_with_a_vanishing_corner_radius() { + let rectangle = rectangle((), (), item(100.), item(100.), item(BoxCorners::from(vec![1e-160, 10., 0., 0.])), item(false), item(true)); + assert!(rectangle.element().positions().all(|position| position.is_finite())); + } + + // A negative width mirrors the rectangle, rounding its corners inward as at the positive width + #[test] + fn rectangle_with_a_negative_width_keeps_its_rounded_corners() { + let anchors = |width: f64, clamped: bool| { + let rectangle = rectangle((), (), item(width), item(100.), item(BoxCorners::from(vec![30.])), item(clamped), item(false)); + rectangle.element().anchor_positions(&shared_paths_from_item_attributes(&rectangle)) + }; + + for clamped in [false, true] { + let mirrored: Vec = anchors(50., clamped).into_iter().map(|position| position * DVec2::new(-1., 1.)).collect(); + assert_eq!(anchors(-50., clamped), mirrored); + } + } + + #[test] + fn polygons_report_more_sides_than_can_be_allocated() { + assert!(matches!(regular_polygon((), (), item(i64::MAX), item(50.)), Err(PolygonError::TooManySides(sides)) if sides == i64::MAX as u64)); + assert!(matches!(star((), (), item(i64::MAX), item(50.), item(25.)), Err(PolygonError::TooManySides(sides)) if sides == i64::MAX as u64)); + assert!(regular_polygon((), (), item(6), item(50.)).is_ok()); + } + + // With the inner and outer radius the same, the spiral doesn't grow, so it winds around a circle + #[test] + fn spiral_between_equal_radii_is_a_circle() { + for spiral_type in [SpiralType::Archimedean, SpiralType::Logarithmic] { + let spiral = spiral((), (), item(spiral_type), item(2.), item(0.), item(25.), item(25.), item(90.)).unwrap(); + assert!(spiral.element().positions().all(|position| position.is_finite())); + let anchors = spiral.element().anchor_positions(&shared_paths_from_item_attributes(&spiral)); + assert!(anchors.iter().all(|anchor| (anchor.length() - 25.).abs() < 1e-9)); + } + } } diff --git a/node-graph/nodes/vector/src/lib.rs b/node-graph/nodes/vector/src/lib.rs index 740cb480490..3da8313a7f4 100644 --- a/node-graph/nodes/vector/src/lib.rs +++ b/node-graph/nodes/vector/src/lib.rs @@ -5,9 +5,6 @@ pub mod vector_modification_nodes; mod vector_nodes; mod voronoi; -#[macro_use] -extern crate log; - // Re-export for convenience pub use core_types as gcore; pub use generator_nodes::*; diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 7f4cff14f6c..dc2000b599c 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -1,7 +1,8 @@ +use crate::voronoi::PointDiagramError; use core::cmp::Ordering; +use core::convert::Infallible; use core::f64::consts::{PI, TAU}; use core::hash::{Hash, Hasher}; -use core_types::Interrupt; use core_types::blending::BlendMode; use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::list::{ATTR_APPEARANCE, ATTR_ID, ATTR_PATHS, Item, ItemAttributeValues, List, ListDyn, NodeIdPath}; @@ -11,6 +12,7 @@ use core_types::{ ATTR_BLEND_MODE, ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_GRADIENT_FORM, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, CloneVarArgs, Color, Context, Ctx, ExtractAll, OwnedContextImpl, }; +use core_types::{Interrupt, NodeError}; use glam::{DAffine2, DMat2, DVec2}; use graphic_types::Vector; use graphic_types::graphic::{bake_paint_transforms, is_paint_present}; @@ -25,10 +27,10 @@ use vector_types::GradientForm; use vector_types::gradient::{build_transform_with_y_preservation, initial_gradient_transform_for_bounding_box}; use vector_types::vector::SetVectorPaths; use vector_types::vector::algorithms::bezpath_algorithms::{ - self, TValue, bezpath_area_centroid_and_area, bezpath_length_centroid_and_length, eval_pathseg_euclidean, evaluate_bezpath, split_bezpath, tangent_on_bezpath, + self, SampleCountError, TValue, bezpath_area_centroid_and_area, bezpath_length_centroid_and_length, eval_pathseg_euclidean, evaluate_bezpath, split_bezpath, tangent_on_bezpath, }; use vector_types::vector::algorithms::concave_hull::concave_hull_of_geometry; -use vector_types::vector::algorithms::offset_bezpath::offset_bezpath; +use vector_types::vector::algorithms::offset_bezpath::{OffsetPathError, offset_bezpath}; use vector_types::vector::algorithms::spline::{solve_spline_first_handle_closed, solve_spline_first_handle_open}; use vector_types::vector::algorithms::weld::WeldExt; use vector_types::vector::misc::{ @@ -162,14 +164,20 @@ impl VectorItemMut for Item { /// Geometry counterpart to [`VectorItemMut`] for the element-wise modifier nodes, running a per-item transformation over /// every vector reachable from the content. Geometry is never inherited, so this recurses into nested groups. trait MapVectorItems: Sized { - fn map_vector_items(content: Item, f: impl FnMut(Item) -> Item) -> Item; + fn map_vector_items(content: Item, mut f: impl FnMut(Item) -> Item) -> Item { + let Ok(content) = Self::try_map_vector_items(content, |item| Ok::<_, Infallible>(f(item))); + content + } + + /// Runs `f` on every vector item reachable from the content, stopping at the first error. + fn try_map_vector_items(content: Item, f: impl FnMut(Item) -> Result, E>) -> Result, E>; /// Mutable access to each reachable vector at once, each paired with a copy of its paths, for callers that must await between elements. fn vector_elements_mut(content: &mut Item) -> Vec<(&mut Vector, List)>; } impl MapVectorItems for Vector { - fn map_vector_items(content: Item, mut f: impl FnMut(Item) -> Item) -> Item { + fn try_map_vector_items(content: Item, mut f: impl FnMut(Item) -> Result, E>) -> Result, E> { f(content) } @@ -181,22 +189,24 @@ impl MapVectorItems for Vector { } impl MapVectorItems for Graphic { - fn map_vector_items(content: Item, mut f: impl FnMut(Item) -> Item) -> Item { - fn map_nested(graphic: &mut Graphic, f: &mut impl FnMut(Item) -> Item) { + fn try_map_vector_items(content: Item, mut f: impl FnMut(Item) -> Result, E>) -> Result, E> { + fn map_nested(graphic: &mut Graphic, f: &mut impl FnMut(Item) -> Result, E>) -> Result<(), E> { match graphic { // Collecting from zero items would drop the attribute columns, so an empty list is left alone - Graphic::VectorList(list) if !list.is_empty() => *list = std::mem::take(list).into_iter().map(&mut *f).collect(), - Graphic::GraphicList(list) => list.iter_element_values_mut().for_each(|nested| map_nested(nested, f)), - Graphic::Vector(item) => **item = f(std::mem::take(&mut **item)), - Graphic::Graphic(item) => map_nested(item.element_mut(), f), + Graphic::VectorList(list) if !list.is_empty() => *list = std::mem::take(list).into_iter().map(&mut *f).collect::>()?, + Graphic::GraphicList(list) => list.iter_element_values_mut().try_for_each(|nested| map_nested(nested, f))?, + Graphic::Vector(item) => **item = f(std::mem::take(&mut **item))?, + Graphic::Graphic(item) => map_nested(item.element_mut(), f)?, _ => {} } + + Ok(()) } let mut content = content; - map_nested(content.element_mut(), &mut f); + map_nested(content.element_mut(), &mut f)?; - content + Ok(content) } fn vector_elements_mut(content: &mut Item) -> Vec<(&mut Vector, List)> { @@ -264,6 +274,12 @@ impl ExpandVectorItems for Graphic { } } +#[derive(Debug, thiserror::Error)] +pub enum AssignColorsError { + #[error("The gradient has no colors to assign")] + EmptyGradient, +} + /// Uniquely sets the fill and/or stroke style of every vector element to individual colors sampled along a chosen gradient. #[node_macro::node(category("Vector: Style"), icon("NodeAssignColors"), path(graphene_core::vector))] fn assign_colors( @@ -293,13 +309,17 @@ fn assign_colors( #[widget(ParsedWidgetOverride::Custom = "assign_colors_repeat_every")] #[hard(0..)] repeat_every: Item, -) -> T +) -> Result where T: VectorListIterMut + Send, { let (fill, stroke, reverse, randomize) = (fill.into_element(), stroke.into_element(), reverse.into_element(), randomize.into_element()); let (seed, repeat_every) = (seed.into_element(), repeat_every.into_element()); + if (fill || stroke) && gradient.element().is_empty() { + return Err(AssignColorsError::EmptyGradient); + } + let mut content = content; let length = content.vector_count(); // The factor spans 0..=1, so the spread deliberately stays Pad (Repeat would wrap the final element onto the first stop's color) @@ -350,7 +370,7 @@ where } }); - content + Ok(content) } /// Applies a fill style to the vector content, giving an appearance to the area within the interior of the geometry. @@ -591,8 +611,14 @@ fn copy_to_points( random_scale_min + scale_rng.random::() * random_scale_difference } else { // Weighted (see ) - let horizontal_scale_factor = 1. - 2_f64.powf(random_scale_bias); - let scale_factor = (1. - scale_rng.random::() * horizontal_scale_factor).log2() / random_scale_bias; + let random = scale_rng.random::(); + let scale_factor = if random_scale_bias > 0. { + // Rearranged as `b + log2(r + (1 - r) 2^-b)` so a large bias can't overflow `2^b`, with the log floored at `-b` where `2^-b` underflows + 1. + (random + (1. - random) * (-random_scale_bias).exp2()).log2().max(-random_scale_bias) / random_scale_bias + } else { + let horizontal_scale_factor = 1. - 2_f64.powf(random_scale_bias); + (1. - random * horizontal_scale_factor).log2() / random_scale_bias + }; random_scale_min + scale_factor * random_scale_difference } } else { @@ -640,6 +666,9 @@ fn round_corners( V::map_vector_items(source, |source| { let (radius, roundness, edge_length_limit, min_angle_threshold) = (*radius.element(), *roundness.element(), *edge_length_limit.element(), *min_angle_threshold.element()); let source_transform: DAffine2 = source.attribute_cloned_or_default(ATTR_TRANSFORM); + if !source_transform.matrix2.determinant().is_normal() { + return source; + } let source_transform_inverse = source_transform.inverse(); let source_paths = shared_paths_from_item_attributes(&source); let (source, mut attributes) = source.into_parts(); @@ -728,8 +757,16 @@ fn round_subpath_corners(subpath: &BezPath, radius: f64, roundness: f64, edge_le return segment; } - let start_t = if start_cut > 0. { segment.inv_arclen(start_cut.min(length), DEFAULT_ACCURACY) } else { 0. }; - let end_t = if end_cut > 0. { segment.inv_arclen((length - end_cut).max(0.), DEFAULT_ACCURACY) } else { 1. }; + let start_t = if start_cut > 0. { + eval_pathseg_euclidean(segment, start_cut / length, DEFAULT_ACCURACY) + } else { + 0. + }; + let end_t = if end_cut > 0. { + eval_pathseg_euclidean(segment, 1. - end_cut / length, DEFAULT_ACCURACY) + } else { + 1. + }; segment.subsegment(start_t..end_t.max(start_t)) }) .collect(); @@ -1129,39 +1166,42 @@ fn extrude(_: impl Ctx, #[implementations(Graphic, Vec }) } +#[derive(Debug, thiserror::Error)] +pub enum BoxWarpError { + #[error("The rectangle to warp onto needs at least 4 points")] + TooFewCorners, +} + #[node_macro::node(category("Vector: Deform"), icon("NodeBoxWarp"), path(core_types::vector))] -fn box_warp(_: impl Ctx, #[implementations(Graphic, Vector)] content: Item, #[expose] rectangle: Item) -> Item { +fn box_warp(_: impl Ctx, #[implementations(Graphic, Vector)] content: Item, #[expose] rectangle: Item) -> Result, BoxWarpError> { let target_transform: DAffine2 = rectangle.attribute_cloned_or_default(ATTR_TRANSFORM); let target_paths = shared_paths_from_item_attributes(&rectangle); let target = rectangle.into_element(); - V::map_vector_items(content, |content| { + // Extract first 4 points from target shape to form the quadrilateral + // Apply the target's transform to get points in world space + let target_points: Vec = target.anchor_positions(&target_paths).iter().map(|&p| target_transform.transform_point2(p)).take(4).collect(); + let dst_corners = match target_points[..] { + [] => return Ok(content), + [top_left, top_right, bottom_right, bottom_left] => [top_left, top_right, bottom_right, bottom_left], + _ => return Err(BoxWarpError::TooFewCorners), + }; + + Ok(V::map_vector_items(content, |content| { let mut row = content; { let transform: DAffine2 = row.attribute_cloned_or_default(ATTR_TRANSFORM); let paths = shared_paths_from_item_attributes(&row); let vector = std::mem::take(row.element_mut()); - // Get the bounding box of the source vector geometry - let source_bbox = vector.bounding_box_with_transform(&paths, transform).unwrap_or([DVec2::ZERO, DVec2::ONE]); - - // Extract first 4 points from target shape to form the quadrilateral - // Apply the target's transform to get points in world space - let target_points: Vec = target.anchor_positions(&target_paths).iter().map(|&p| target_transform.transform_point2(p)).take(4).collect(); - - // If we have fewer than 4 points, use the corners of the source bounding box - // This handles the degenerative case - let dst_corners = if target_points.len() >= 4 { - [target_points[0], target_points[1], target_points[2], target_points[3]] - } else { - warn!("Target shape has fewer than 4 points. Using source bounding box instead."); - [ - source_bbox[0], - DVec2::new(source_bbox[1].x, source_bbox[0].y), - source_bbox[1], - DVec2::new(source_bbox[0].x, source_bbox[1].y), - ] - }; + // Get the bounding box of the source vector geometry, or of its points where no paths are drawn + let source_bbox = vector + .bounding_box_with_transform(&paths, transform) + .or_else(|| match BoundingBox::bounding_box(&vector, transform, false) { + RenderBoundingBox::Rectangle(bounds) => Some(bounds), + _ => None, + }) + .unwrap_or([DVec2::ZERO, DVec2::ONE]); // Apply the warp let mut result = vector.clone(); @@ -1186,7 +1226,7 @@ fn box_warp(_: impl Ctx, #[implementations(Graphic, Ve row.set_attribute(ATTR_TRANSFORM, DAffine2::IDENTITY); } row - }) + })) } // Interpolate within a quadrilateral using normalized coordinates (0-1) @@ -1592,8 +1632,8 @@ fn relax_points( #[default(1.)] #[hard(0..1000)] iterations: Item, -) -> Item { - V::map_vector_items(source, |source| { +) -> Result, PointDiagramError> { + V::try_map_vector_items(source, |source| { let mut source = source; let iterations = *iterations.element(); @@ -1601,14 +1641,16 @@ fn relax_points( let indices = vector.anchor_indices(paths); let sites: Vec = indices.iter().filter_map(|&index| vector.position(index)).collect(); - for (&index, new_position) in indices.iter().zip(crate::voronoi::relax_sites(&sites, iterations)) { + for (&index, new_position) in indices.iter().zip(crate::voronoi::relax_sites(&sites, iterations)?) { if let Some(position) = vector.0.element_mut(index) { *position = new_position; } } - }); - source + Ok(()) + })?; + + Ok(source) }) } @@ -1616,20 +1658,22 @@ fn relax_points( /// /// When Connect Cells is off, every cell becomes its own closed, fillable subpath. When on, the cells share their common points and segments, forming a single connected mesh. #[node_macro::node(category("Vector: Point"), icon("NodeVoronoiCells"), path(core_types::vector))] -fn voronoi_cells(_: impl Ctx, #[implementations(Graphic, Vector)] source: Item, connect_cells: Item) -> Item { - V::map_vector_items(source, |source| { +fn voronoi_cells(_: impl Ctx, #[implementations(Graphic, Vector)] source: Item, connect_cells: Item) -> Result, PointDiagramError> { + V::try_map_vector_items(source, |source| { let mut source = source; let connect_cells = *connect_cells.element(); edit_item_geometry(&mut source, |vector, paths| { let sites = vector.anchor_positions(paths); - let cells = crate::voronoi::voronoi_cells(&sites); + let cells = crate::voronoi::voronoi_cells(&sites)?; if !cells.is_empty() { replace_with_polygons(vector, paths, cells, connect_cells); } - }); - source + Ok(()) + })?; + + Ok(source) }) } @@ -1637,23 +1681,25 @@ fn voronoi_cells(_: impl Ctx, #[implementations(Graphi /// /// When Connect Cells is off, every triangle becomes its own closed, fillable subpath. When on, the triangles share their common points and segments, forming a single connected mesh. #[node_macro::node(category("Vector: Point"), icon("NodeTriangulate"), path(core_types::vector))] -fn triangulate(_: impl Ctx, #[implementations(Graphic, Vector)] source: Item, connect_cells: Item) -> Item { - V::map_vector_items(source, |source| { +fn triangulate(_: impl Ctx, #[implementations(Graphic, Vector)] source: Item, connect_cells: Item) -> Result, PointDiagramError> { + V::try_map_vector_items(source, |source| { let mut source = source; let connect_cells = *connect_cells.element(); edit_item_geometry(&mut source, |vector, paths| { let sites = vector.anchor_positions(paths); - let triangles = crate::voronoi::delaunay_triangles(&sites); + let triangles = crate::voronoi::delaunay_triangles(&sites)?; if !triangles.is_empty() { // `delaunator` emits triangle vertices clockwise; reverse to `[a, c, b]` so triangles wind counter-clockwise to // match the Voronoi cells and the rest of the framework's fill winding. let polygons = triangles.iter().map(|&[a, b, c]| vec![sites[a], sites[c], sites[b]]).collect(); replace_with_polygons(vector, paths, polygons, connect_cells); } - }); - source + Ok(()) + })?; + + Ok(source) }) } @@ -1755,12 +1801,15 @@ fn offset_path( #[hard(0..)] #[default(4.)] miter_limit: Item, -) -> Item { - V::map_vector_items(content, |content| { +) -> Result, OffsetPathError> { + V::try_map_vector_items(content, |content| { let mut content = content; let (distance, join, miter_limit) = (*distance.element(), *join.element(), *miter_limit.element()); let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); + if !transform_attribute.matrix2.determinant().is_normal() { + return Ok(content); + } let transform = Affine::new(transform_attribute.to_cols_array()); let source_paths = shared_paths_from_item_attributes(&content); let vector = std::mem::take(content.element_mut()); @@ -1782,7 +1831,7 @@ fn offset_path( StrokeJoin::Round => kurbo::Join::Round, }, Some(miter_limit), - ); + )?; bezpath_out.apply_affine(transform.inverse()); @@ -1795,7 +1844,7 @@ fn offset_path( *content.element_mut() = result; content.set_paths(result_paths); - content + Ok(content) }) } @@ -1857,7 +1906,7 @@ fn solidify_stroke_list_with_snapshot(source: List) -> List { } /// Replaces each item's stroke with filled outline geometry, emitting a separate fill item beside it when the -/// original carried a fill. Grows the item count by up to 2x. +/// original carried a fill. Grows the item count by up to 2x. An item without a stroke to outline passes through as it was. fn solidify_stroke_list(content: List) -> List { // A fill exists when its coverage carries paint that draws something let has_fills: Vec = (0..content.len()) @@ -1875,7 +1924,9 @@ fn solidify_stroke_list(content: List) -> List { let (vector, attributes) = row.into_parts(); let appearance = attributes.get::(ATTR_APPEARANCE).cloned().unwrap_or_default(); - let stroke = appearance.first_coverage_of(Cover::Stroke).map(Coverage::stroke_params).unwrap_or_default(); + let Some(stroke) = appearance.first_coverage_of(Cover::Stroke).map(Coverage::stroke_params).filter(|stroke| stroke.weight != 0.) else { + return vec![Item::from_parts(vector, attributes)]; + }; // List order is paint order: a stroke coverage before the first fill coverage paints below it let stroke_below = appearance .first_index_of(Cover::Stroke) @@ -2105,8 +2156,8 @@ fn sample_polyline( #[unit(" px")] stop_offset: Item, adaptive_spacing: Item, -) -> Item { - V::map_vector_items(content, |content| { +) -> Result, SampleCountError> { + V::try_map_vector_items(content, |content| { let mut content = content; let (spacing, separation, quantity) = (*spacing.element(), *separation.element(), *quantity.element()); let (start_offset, stop_offset, adaptive_spacing) = (*start_offset.element(), *stop_offset.element(), *adaptive_spacing.element()); @@ -2128,6 +2179,12 @@ fn sample_polyline( let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); world_bezpath.apply_affine(Affine::new(transform_attribute.to_cols_array())); + // A subpath with non-finite coordinates has no lengths to space the samples by, so it passes through + if !world_bezpath.is_finite() { + sample_bezpaths.push(local_bezpath); + continue; + } + // Per-segment perimeter lengths (transform-baked) for distance-based spacing let segment_lengths: Vec = world_bezpath.segments().map(pathseg_perimeter).collect(); @@ -2138,7 +2195,7 @@ fn sample_polyline( // Compute sample locations using world-space distances, then evaluate positions on the untransformed bezpath. // This avoids needing to invert the transform (which fails when the transform is singular, e.g. zero scale). - let Some((locations, was_closed)) = bezpath_algorithms::compute_sample_locations(&world_bezpath, spacing, amount, start_offset, stop_offset, adaptive_spacing, &segment_lengths) else { + let Some((locations, was_closed)) = bezpath_algorithms::compute_sample_locations(&world_bezpath, spacing, amount, start_offset, stop_offset, adaptive_spacing, &segment_lengths)? else { continue; }; @@ -2165,10 +2222,18 @@ fn sample_polyline( *content.element_mut() = result; content.set_paths(result_paths); - content + Ok(content) }) } +/// The finest tolerance worth honoring on a path. Finer ones make no visible difference but take unbounded time and memory to reach, +/// and float precision runs out sooner the farther the coordinates are from the origin. +fn tolerance_floor(bezpath: &BezPath) -> f64 { + let bounds = bezpath.bounding_box(); + let magnitude = bounds.x0.abs().max(bounds.x1.abs()).max(bounds.y0.abs()).max(bounds.y1.abs()); + (magnitude * 1e-12).max(1e-6) +} + /// Simplifies vector paths by reducing the number of curve segments while preserving the overall shape within the given tolerance. #[node_macro::node(category("Vector: Path"), icon("NodeSimplify"), path(core_types::vector))] fn simplify( @@ -2192,14 +2257,24 @@ fn simplify( V::map_vector_items(content, |mut item| { let transform_attribute: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + if !transform_attribute.matrix2.determinant().is_normal() { + return item; + } let transform = Affine::new(transform_attribute.to_cols_array()); let inverse_transform = transform.inverse(); let item_paths = shared_paths_from_item_attributes(&item); - let simplified_bezpaths = item.element().stroke_bezpath_iter(&item_paths).map(|mut bezpath| { - bezpath.apply_affine(transform); + let simplified_bezpaths = item.element().stroke_bezpath_iter(&item_paths).map(|bezpath| { + let mut document_bezpath = bezpath.clone(); + document_bezpath.apply_affine(transform); + + // Kurbo's curve fitting never converges on non-finite coordinates, and drops a subpath whose segments all have zero length + if !document_bezpath.is_finite() || document_bezpath.segments().all(|segment| segment.bounding_box().size() == kurbo::Size::ZERO) { + return bezpath; + } - let mut simplified = simplify_bezpath(bezpath, tolerance, &options); + let tolerance = tolerance.max(tolerance_floor(&document_bezpath)); + let mut simplified = simplify_bezpath(document_bezpath, tolerance, &options); simplified.apply_affine(inverse_transform); simplified @@ -2290,20 +2365,31 @@ fn decimate( } let transform_attribute: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); + if !transform_attribute.matrix2.determinant().is_normal() { + return content; + } let transform = Affine::new(transform_attribute.to_cols_array()); let inverse_transform = transform.inverse(); let mut decimated_bezpaths = Vec::new(); let content_paths = shared_paths_from_item_attributes(&content); - for mut bezpath in content.element().stroke_bezpath_iter(&content_paths).collect::>() { + for local_bezpath in content.element().stroke_bezpath_iter(&content_paths).collect::>() { + let mut bezpath = local_bezpath.clone(); bezpath.apply_affine(transform); + // The distance to a non-finite point can't be measured, so such a subpath passes through + if !bezpath.is_finite() { + decimated_bezpaths.push(local_bezpath); + continue; + } + let is_closed = matches!(bezpath.elements().last(), Some(PathEl::ClosePath)); // Flatten the bezpath into line segments, then collect the points + let flatten_tolerance = (tolerance * 0.5).max(tolerance_floor(&bezpath)); let mut points = Vec::new(); - kurbo::flatten(bezpath, tolerance * 0.5, |el| match el { + kurbo::flatten(bezpath, flatten_tolerance, |el| match el { PathEl::MoveTo(p) | PathEl::LineTo(p) => { points.push(DVec2::new(p.x, p.y)); } @@ -2344,6 +2430,12 @@ fn decimate( }) } +#[derive(Debug, thiserror::Error)] +pub enum ProgressionError { + #[error("Progression is not a number")] + NotANumber, +} + /// Cuts a path at a given progression from 0 to 1 along the path, creating two new subpaths from the original one (if the path is initially open) or one open subpath (if the path is initially closed). /// /// If multiple subpaths make up the path, the whole number part of the progression value selects the subpath and the decimal part determines the position along it. @@ -2360,8 +2452,12 @@ fn cut_path( reverse: Item, /// Traverse the path using each segment's Bézier curve parameterization instead of the Euclidean distance. Faster to compute but doesn't respect actual distances. parameterized_distance: Item, -) -> Item { - V::map_vector_items(content, |content| { +) -> Result, ProgressionError> { + if progression.element().is_nan() { + return Err(ProgressionError::NotANumber); + } + + Ok(V::map_vector_items(content, |content| { let mut content = content; let (progression, reverse, parameterized_distance) = (*progression.element(), *reverse.element(), *parameterized_distance.element()); @@ -2395,7 +2491,7 @@ fn cut_path( } content - }) + })) } /// The position, tangent direction, and normal direction at a point along a path, split into separate node outputs. @@ -2410,6 +2506,14 @@ pub struct PathEvaluation { pub normal: Item, } +#[derive(Debug, thiserror::Error)] +pub enum EvaluatePathError { + #[error(transparent)] + Progression(#[from] ProgressionError), + #[error("The content has no path to evaluate")] + NoPath, +} + /// Determines the position, tangent direction, and normal direction at a point on the path, given by its progression from 0 to 1 along the path. /// /// If multiple subpaths make up the path, the whole number part of the progression value selects the subpath and the decimal part determines the position along it. @@ -2425,9 +2529,12 @@ fn evaluate_path( reverse: Item, /// Traverse the path using each segment's Bézier curve parameterization instead of the Euclidean distance. Faster to compute but doesn't respect actual distances. parameterized_distance: Item, -) -> PathEvaluation { +) -> Result { let (progression, reverse, parameterized_distance) = (progression.into_element(), reverse.into_element(), parameterized_distance.into_element()); let euclidean = !parameterized_distance; + if progression.is_nan() { + return Err(ProgressionError::NotANumber.into()); + } let paths = paths_from_item_attributes(&content).unwrap_or_else(|| no_paths()); let transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); @@ -2438,11 +2545,7 @@ fn evaluate_path( let index = if progression >= bezpath_count { (bezpath_count - 1.) as usize } else { progression as usize }; let Some((bezpath, transform)) = bezpaths.get_mut(index) else { - return PathEvaluation { - position: Item::new_from_element(DVec2::ZERO), - tangent: Item::new_from_element(DVec2::ZERO), - normal: Item::new_from_element(DVec2::ZERO), - }; + return Err(EvaluatePathError::NoPath); }; let t = if progression == bezpath_count { 1. } else { progression.fract() }; @@ -2463,11 +2566,11 @@ fn evaluate_path( // Rotating a quarter turn counterclockwise as seen in the viewport, where the Y axis points down, matches the 'Offset Points' normal let normal = -tangent.perp(); - PathEvaluation { + Ok(PathEvaluation { position: Item::new_from_element(position), tangent: Item::new_from_element(tangent), normal: Item::new_from_element(normal), - } + }) } #[node_macro::node(category("Vector: Point"), icon("NodeScatterPoints"), path(core_types::vector), memoize)] @@ -2613,10 +2716,12 @@ fn inverse_linear_or_repair(linear: DMat2) -> DMat2 { let col1 = linear.col(1); let col0_exists = col0.length_squared() > (f64::EPSILON * 1e3).powi(2); let col1_exists = col1.length_squared() > (f64::EPSILON * 1e3).powi(2); + // Scaled down first, since a column too long to square would normalize to zero + let unit_perpendicular = |column: DVec2| (column / column.abs().max_element()).perp().normalize(); let repaired = match (col0_exists, col1_exists) { - (true, _) => DMat2::from_cols(col0, col0.perp().normalize()), - (false, true) => DMat2::from_cols(col1.perp().normalize(), col1), + (true, _) => DMat2::from_cols(col0, unit_perpendicular(col0)), + (false, true) => DMat2::from_cols(unit_perpendicular(col1), col1), (false, false) => DMat2::IDENTITY, }; repaired.inverse() @@ -2768,7 +2873,7 @@ fn morph( distribution: Item, /// An optional control path whose anchor points correspond to each object. Curved segments between points will shape the morph trajectory instead of traveling straight. If there is a break between path segments, the separate subpaths are selected by index from the integer part of the progression value. For example, `[1, 2)` morphs along the segments of the second subpath, and so on. path: Item, -) -> Item { +) -> Result, ProgressionError> { /// Promotes a segment's handle pair to cubic-equivalent Bézier control points. /// For linear segments (both None), handles are placed at their respective anchors (zero-length) /// so that interpolation against another zero-length cubic doesn't introduce unwanted curvature. @@ -2789,7 +2894,8 @@ fn morph( /// For closed paths, the "last segment" is the closing segment from the last back to the first manipulator. fn subdivide_last_manipulator_segment(manips: &mut Vec, closed: bool) { let len = manips.len(); - if len < 2 { + // A closed loop through one anchor still has its closing segment, from the anchor back to itself + if len == 0 || (len == 1 && !closed) { return; } @@ -3003,7 +3109,11 @@ fn morph( // Not enough elements to interpolate between, so we return the input as-is if content.len() <= 1 { - return content.into_iter().next().unwrap_or_default(); + return Ok(content.into_iter().next().unwrap_or_default()); + } + + if progression.is_nan() { + return Err(ProgressionError::NotANumber); } // Build the control path for the morph trajectory. @@ -3053,7 +3163,7 @@ fn morph( // If the control path has no segments, return the first item if segment_count == 0 { - return content.into_iter().next().unwrap_or_default(); + return Ok(content.into_iter().next().unwrap_or_default()); } // Determine if the selected subpath is closed (has a closing segment connecting its end back to its start) @@ -3167,7 +3277,7 @@ fn morph( // Use indexed access to borrow only the two elements we need let (Some(source_element), Some(target_element)) = (content.element(source_index), content.element(target_index)) else { - return Item::default(); + return Ok(Item::default()); }; // Lerp blending attributes: opacity/fill interpolate, blend_mode/clip step at the midpoint @@ -3245,7 +3355,7 @@ fn morph( attributes.insert(ATTR_TRANSFORM, lerped_transform); attributes.insert(ATTR_EDITOR_MERGED_LAYERS, graphic_list_content); - return Item::from_parts(endpoint_element.clone(), attributes); + return Ok(Item::from_parts(endpoint_element.clone(), attributes)); } let mut working = WorkingVector::default(); @@ -3430,7 +3540,7 @@ fn morph( item.set_attribute(ATTR_APPEARANCE, appearance); } - item + Ok(item) } fn bevel_algorithm(vector: &mut Vector, paths: &mut List, transform: DAffine2, distance: f64) { @@ -3561,7 +3671,8 @@ fn bevel_algorithm(vector: &mut Vector, paths: &mut List, transform: DAffi match sorted_segments.last() { Some(&last) => { if let Some(index) = start_points.iter().position(|&p| p == end_points[last]) { - if index == 0 { + // The chain ends where it runs into a segment it already passed through + if sorted_segments.contains(&index) { break; } sorted_segments.push(index); @@ -3623,7 +3734,7 @@ fn bevel_algorithm(vector: &mut Vector, paths: &mut List, transform: DAffi next_bezier = PathSeg::Line(Line::new(next_bezier.start(), next_bezier.end())); } - let inverse_transform = if transform.matrix2.determinant() != 0. { transform.inverse() } else { Default::default() }; + let inverse_transform = transform.inverse(); if index == 0 && next_index == 1 { first_original_length = bezier.perimeter(DEFAULT_ACCURACY); @@ -3699,6 +3810,9 @@ fn bevel_algorithm(vector: &mut Vector, paths: &mut List, transform: DAffi if distance <= 1. { return; } + if !transform.matrix2.determinant().is_normal() { + return; + } let mut working = WorkingVector::flatten(vector, paths); if working.segment_ids.len() > 1 { @@ -3840,6 +3954,12 @@ fn area(ctx: impl Ctx + CloneVarArgs + ExtractAll, content: impl Node, Output = Item>, centroid_type: Item) -> Result, Interrupt> { let centroid_type = centroid_type.into_element(); @@ -3848,18 +3968,25 @@ fn centroid(ctx: impl Ctx + CloneVarArgs + ExtractAll, content: impl Node, transform: DAffine2, centroid_type: CentroidType) -> DVec2 { +/// The area- or length-weighted centroid of one vector element's subpaths, averaging anchor positions when the weights all vanish, +/// or `None` for an element without points. A subpath with non-finite coordinates makes the centroid NaN. +fn element_centroid(element: &Vector, paths: &List, transform: DAffine2, centroid_type: CentroidType) -> Option { // All subpath centroid positions added together as if they were vectors from the origin, weighted by area or length let mut centroid = DVec2::ZERO; let mut sum = 0.; for bezpath in element.stroke_bezpath_iter(paths) { + if !bezpath.is_finite() { + return Some(DVec2::NAN); + } + let partial = match centroid_type { CentroidType::Area => bezpath_area_centroid_and_area(&bezpath, Some(1e-3), Some(1e-3)).filter(|(_, area)| *area > 0.), CentroidType::Length => bezpath_length_centroid_and_length(&bezpath, None, true), @@ -3871,15 +3998,15 @@ fn element_centroid(element: &Vector, paths: &List, transform: DAffine2, c } if sum > 0. { - return centroid / sum; + return Some(centroid / sum); } // Without a summed denominator, return the average of all positions instead let positions = element.anchor_positions(paths); if positions.is_empty() { - return DVec2::ZERO; + return None; } - positions.iter().map(|&p| transform.transform_point2(p)).sum::() / (positions.len() as f64) + Some(positions.iter().map(|&p| transform.transform_point2(p)).sum::() / (positions.len() as f64)) } #[cfg(test)] @@ -4001,8 +4128,8 @@ mod test { fn offset_path_does_not_duplicate_closing_anchors() { // Offsetting closed triangles must not leave each subpath with a redundant start/end anchor (a Kurbo offset // contour returns to approximately, not exactly, its start; that near-coincident point must close, not duplicate). - let delaunay = super::triangulate((), vector_item_from_points(&SQUARE_WITH_CENTER), item(false)); - let offset = super::offset_path((), delaunay, item(0.5), item(StrokeJoin::Miter), item(4.)); + let delaunay = super::triangulate((), vector_item_from_points(&SQUARE_WITH_CENTER), item(false)).unwrap(); + let offset = super::offset_path((), delaunay, item(0.5), item(StrokeJoin::Miter), item(4.)).unwrap(); let paths = item_paths(&offset); let result = offset.element(); @@ -4019,7 +4146,7 @@ mod test { #[test] fn delaunay_disconnected_cells_make_one_subpath_per_triangle() { - let result = super::triangulate((), vector_item_from_points(&SQUARE_WITH_CENTER), item(false)); + let result = super::triangulate((), vector_item_from_points(&SQUARE_WITH_CENTER), item(false)).unwrap(); let paths = item_paths(&result); let vector = result.element(); // The square plus its center tessellates into four triangles, each its own closed subpath. @@ -4050,8 +4177,8 @@ mod test { assert_eq!(subpath_winding_signs(&ellipse, &ellipse_paths)[0], expected, "Rectangle and Ellipse should agree on winding"); // Delaunay and Voronoi must emit subpaths that wind the same way as those generators. - let delaunay = super::triangulate((), vector_item_from_points(&SQUARE_WITH_CENTER), item(false)); - let voronoi = super::voronoi_cells((), vector_item_from_points(&SQUARE_WITH_CENTER), item(false)); + let delaunay = super::triangulate((), vector_item_from_points(&SQUARE_WITH_CENTER), item(false)).unwrap(); + let voronoi = super::voronoi_cells((), vector_item_from_points(&SQUARE_WITH_CENTER), item(false)).unwrap(); for sign in subpath_winding_signs(delaunay.element(), item_paths(&delaunay)) { assert_eq!(sign, expected, "Delaunay subpath winding should match the Rectangle/Ellipse generators"); } @@ -4062,7 +4189,7 @@ mod test { #[test] fn delaunay_shared_mesh_welds_points_and_shares_edges() { - let result = super::triangulate((), vector_item_from_points(&SQUARE_WITH_CENTER), item(true)); + let result = super::triangulate((), vector_item_from_points(&SQUARE_WITH_CENTER), item(true)).unwrap(); let paths = item_paths(&result); let vector = result.element(); // The connected mesh reuses the five input points and shares edges, so it fills face by face. @@ -4074,7 +4201,7 @@ mod test { #[test] fn voronoi_disconnected_cells_make_a_subpath_per_cell() { - let result = super::voronoi_cells((), vector_item_from_points(&SQUARE_WITH_CENTER), item(false)); + let result = super::voronoi_cells((), vector_item_from_points(&SQUARE_WITH_CENTER), item(false)).unwrap(); let paths = item_paths(&result); let vector = result.element(); let cells = vector.stroke_manipulator_groups(paths).filter(|(_, closed)| *closed).count(); @@ -4091,7 +4218,7 @@ mod test { #[test] fn voronoi_shared_mesh_uses_face_fill() { - let result = super::voronoi_cells((), vector_item_from_points(&SQUARE_WITH_CENTER), item(true)); + let result = super::voronoi_cells((), vector_item_from_points(&SQUARE_WITH_CENTER), item(true)).unwrap(); let paths = item_paths(&result); let vector = result.element(); assert!(vector.use_face_fill(paths)); @@ -4102,7 +4229,7 @@ mod test { fn voronoi_leaves_degenerate_input_untouched() { // Two points cannot form a diagram, so the element passes through unchanged. let points = [DVec2::new(0., 0.), DVec2::new(1., 1.)]; - let result = super::voronoi_cells((), vector_item_from_points(&points), item(false)); + let result = super::voronoi_cells((), vector_item_from_points(&points), item(false)).unwrap(); let paths = item_paths(&result); let vector = result.element(); assert_eq!(vector.anchor_indices(paths).len(), 2); @@ -4120,7 +4247,7 @@ mod test { DVec2::new(3., 4.), DVec2::new(7., 5.), ]; - let result = super::relax_points((), vector_item_from_points(&points), item(2.)); + let result = super::relax_points((), vector_item_from_points(&points), item(2.)).unwrap(); let positions = result.element().anchor_positions(item_paths(&result)); // Relaxation preserves the point count but repositions the interior anchors within the hull. @@ -4332,7 +4459,8 @@ mod test { Item::new_from_element(0.), Item::new_from_element(0.), Item::new_from_element(false), - ); + ) + .unwrap(); let positions = sample_polyline.element().anchor_positions(item_paths(&sample_polyline)); assert_eq!(positions.len(), 4); for (pos, expected) in positions.iter().zip([DVec2::X * 0., DVec2::X * 30., DVec2::X * 60., DVec2::X * 90.]) { @@ -4351,7 +4479,8 @@ mod test { Item::new_from_element(45.), Item::new_from_element(10.), Item::new_from_element(true), - ); + ) + .unwrap(); let positions = sample_polyline.element().anchor_positions(item_paths(&sample_polyline)); assert_eq!(positions.len(), 4); for (pos, expected) in positions.iter().zip([DVec2::X * 45., DVec2::X * 60., DVec2::X * 75., DVec2::X * 90.]) { @@ -4537,7 +4666,8 @@ mod test { Item::new_from_element(false), Item::new_from_element(InterpolationDistribution::default()), Item::default(), - ); + ) + .unwrap(); let morphed = List::new_from_item(morphed); let morphed_positions = morphed.element(0).unwrap().anchor_positions(row_paths(&morphed, 0)); // Geometry stays in local space (original rectangle coordinates) @@ -4566,7 +4696,8 @@ mod test { Item::new_from_element(false), Item::new_from_element(InterpolationDistribution::default()), Item::default(), - ); + ) + .unwrap(); let morphed = List::new_from_item(morphed); let appearance = morphed.attribute::(ATTR_APPEARANCE, 0).expect("Morph should keep the appearance at the midpoint"); @@ -4617,7 +4748,8 @@ mod test { Item::new_from_element(false), Item::new_from_element(InterpolationDistribution::default()), Item::default(), - ); + ) + .unwrap(); let morphed = List::new_from_item(morphed); let appearance = morphed.attribute::(ATTR_APPEARANCE, 0).expect("Morph should keep the appearance at the midpoint"); @@ -4781,7 +4913,8 @@ mod test { Item::new_from_element(false), Item::new_from_element(0), Item::new_from_element(0_i64), - ); + ) + .unwrap(); let Some(Graphic::Vector(item)) = styled.element(0) else { panic!("the leaf should stay a rank-0 vector") @@ -4912,16 +5045,16 @@ mod test { assert_eq!(super::separate_paths((), super::unweld((), diamond.clone(), item(UnweldMethod::All))).len(), 4); // Halfway along the whole contour is the diamond's far tip, while halfway along the first path alone is its left corner - let halfway = super::evaluate_path((), diamond, item(0.5), item(false), item(false)); + let halfway = super::evaluate_path((), diamond, item(0.5), item(false), item(false)).unwrap(); assert!(halfway.position.element().distance(DVec2::new(0., 20.)) < 1e-3, "{:?}", halfway.position); - let halfway = super::evaluate_path((), unwelded, item(0.5), item(false), item(false)); + let halfway = super::evaluate_path((), unwelded, item(0.5), item(false), item(false)).unwrap(); assert!(halfway.position.element().distance(DVec2::new(-10., 10.)) < 1e-3, "{:?}", halfway.position); } // Sampling the welded diamond goes once around its closed contour, and once unwelded, along each open path on its own #[test] fn unwelded_paths_are_sampled_one_at_a_time() { - let sample = |content: Item| super::sample_polyline((), content, item(PointSpacingType::Quantity), item(100.), item(5), item(0.), item(0.), item(false)); + let sample = |content: Item| super::sample_polyline((), content, item(PointSpacingType::Quantity), item(100.), item(5), item(0.), item(0.), item(false)).unwrap(); let polylines = |sampled: Item| sampled.element().stroke_bezpath_iter(item_paths(&sampled)).collect::>(); let welded = polylines(sample(welded_diamond())); @@ -4945,7 +5078,7 @@ mod test { // Halfway along the whole contour is the diamond's far tip, while halfway along the first path alone is its left corner let squares = || embed_vectors([(); 3].map(|_| vector_item_from_bezpath(Rect::new(0., 0., 1., 1.).to_path(DEFAULT_ACCURACY))).into_iter().collect()); - let morph_along = |path: Item| super::morph((), squares(), item(0.5), item(false), item(InterpolationDistribution::Objects), path); + let morph_along = |path: Item| super::morph((), squares(), item(0.5), item(false), item(InterpolationDistribution::Objects), path).unwrap(); let position = |morphed: Item| morphed.attribute_cloned_or_default::(ATTR_TRANSFORM).translation; assert!(position(morph_along(welded_diamond())).distance(DVec2::new(0., 20.)) < 1e-3); assert!(position(morph_along(unwelded())).distance(DVec2::new(-10., 10.)) < 1e-3); @@ -4960,6 +5093,7 @@ mod test { item(InterpolationDistribution::Objects), Item::default(), ) + .unwrap() }; let closed = |morphed: Item| { let subpaths = morphed.element().stroke_bezpath_iter(item_paths(&morphed)); @@ -4968,4 +5102,203 @@ mod test { assert_eq!(closed(morph_between(welded_diamond())), [true]); assert_eq!(closed(morph_between(unwelded())), [false, false]); } + + #[test] + fn point_diagrams_report_non_finite_points() { + let mut points = SQUARE_WITH_CENTER.to_vec(); + points.push(DVec2::new(f64::NAN, 3.)); + let points = || vector_item_from_points(&points); + + assert!(matches!(super::triangulate((), points(), item(false)), Err(PointDiagramError::NonFinitePoints))); + assert!(matches!(super::voronoi_cells((), points(), item(false)), Err(PointDiagramError::NonFinitePoints))); + assert!(matches!(super::relax_points((), points(), item(1.)), Err(PointDiagramError::NonFinitePoints))); + // No relaxation step needs the diagram + assert!(super::relax_points((), points(), item(0.)).is_ok()); + } + + #[test] + fn offset_path_reports_a_shape_too_large_to_clip() { + let star = vector_item_from_bezpath(vector_types::vector::algorithms::shapes::star_polygon_bezpath(DVec2::ZERO, 5, 1e20, 5e19).unwrap()); + let offset = super::offset_path((), star, item(-1e18), item(StrokeJoin::Miter), item(4.)); + assert!(matches!(offset, Err(OffsetPathError::TooLarge))); + } + + // A collapsed transform has no inverse, so these nodes leave its item as it was + #[test] + fn collapsed_items_pass_through() { + let collapsed = create_vector_item(Rect::new(0., 0., 100., 100.).to_path(DEFAULT_ACCURACY), DAffine2::from_scale(DVec2::new(0., 1.))); + + assert_eq!(super::round_corners((), collapsed.clone(), item(10.), item(0.5), item(100.), item(5.)), collapsed); + assert_eq!(super::offset_path((), collapsed.clone(), item(5.), item(StrokeJoin::Miter), item(4.)).unwrap(), collapsed); + assert_eq!(super::simplify((), collapsed.clone(), item(5.)), collapsed); + assert_eq!(super::decimate((), collapsed.clone(), item(5.)), collapsed); + assert_eq!(super::bevel((), collapsed.clone(), item(10.)), collapsed); + } + + // Kurbo drops a subpath that simplifies down to nothing, so one without length passes through + #[test] + fn simplify_keeps_a_subpath_without_length() { + let point = vector_item_from_bezpath(ellipse_bezpath(DVec2::splat(5.), DVec2::splat(5.))); + let simplified = super::simplify((), point, item(5.)); + assert_eq!(simplified.element().stroke_bezpath_iter(item_paths(&simplified)).count(), 1); + } + + #[test] + fn decimate_with_a_tolerance_finer_than_float_precision() { + let circle = vector_item_from_bezpath(ellipse_bezpath(DVec2::splat(-50.), DVec2::splat(50.))); + let decimated = super::decimate((), circle, item(1e-60)); + assert!(decimated.element().point_count() > 4); + } + + #[test] + fn evaluate_path_without_a_path() { + assert!(matches!(super::evaluate_path((), Item::default(), item(0.5), item(false), item(false)), Err(EvaluatePathError::NoPath))); + } + + #[test] + fn progression_must_be_a_number() { + let square = || vector_item_from_bezpath(Rect::new(0., 0., 10., 10.).to_path(DEFAULT_ACCURACY)); + + assert!(matches!(super::cut_path((), square(), item(f64::NAN), item(false), item(true)), Err(ProgressionError::NotANumber))); + assert!(matches!( + super::evaluate_path((), square(), item(f64::NAN), item(false), item(true)), + Err(EvaluatePathError::Progression(_)) + )); + + let squares = embed_vectors([square(), square()].into_iter().collect()); + assert!(matches!( + super::morph((), squares, item(f64::NAN), item(false), item(InterpolationDistribution::Objects), Item::default()), + Err(ProgressionError::NotANumber) + )); + } + + #[test] + fn scatter_points_skips_an_infinite_subpath() { + let mut bezpath = ellipse_bezpath(DVec2::splat(-50.), DVec2::splat(50.)); + bezpath.move_to((0., 0.)); + bezpath.line_to((f64::INFINITY, 0.)); + bezpath.line_to((0., 10.)); + bezpath.close_path(); + + let scattered = super::scatter_points((), vector_item_from_bezpath(bezpath), item(10.), item(0)); + assert!(scattered.element().point_count() > 0); + } + + // The axis a collapsed transform keeps is too long to square, but still gives the perpendicular one a direction + #[test] + fn point_offsets_under_a_collapsed_transform_with_a_huge_axis() { + let transform = DAffine2::from_cols(DVec2::new(1e160, 0.), DVec2::ZERO, DVec2::ZERO); + let content = || create_vector_item(Rect::new(0., 0., 10., 10.).to_path(DEFAULT_ACCURACY), transform); + + let jittered = super::jitter_points((), content(), item(5.), item(0), item(true)); + assert!(jittered.element().positions().all(|position| position.is_finite())); + let offset = super::offset_points((), content(), item(10.)); + assert!(offset.element().positions().all(|position| position.is_finite())); + } + + // The loop's closing segment, from its lone anchor back to itself, is split to match the anchors of the shape it morphs from + #[test] + fn morph_into_a_one_anchor_loop() { + let mut teardrop = BezPath::new(); + teardrop.move_to((0., 0.)); + teardrop.curve_to((50., -50.), (100., 50.), (0., 0.)); + teardrop.close_path(); + let square = vector_item_from_bezpath(Rect::new(0., 0., 10., 10.).to_path(DEFAULT_ACCURACY)); + + for content in [ + [square.clone(), vector_item_from_bezpath(teardrop.clone())], + [vector_item_from_bezpath(teardrop.clone()), square.clone()], + ] { + let morphed = super::morph( + (), + embed_vectors(content.into_iter().collect()), + item(0.5), + item(false), + item(InterpolationDistribution::Objects), + Item::default(), + ) + .unwrap(); + assert_eq!(morphed.element().anchor_positions(item_paths(&morphed)).len(), 4); + } + } + + // The chain of segments from the first one runs into a loop that doesn't come back to it, as Points to Polyline then Weld can leave + #[test] + fn bevel_a_lasso() { + let mut vector = Vector::default(); + for position in [(0., 0.), (100., 0.), (200., 0.), (150., 100.), (0., 200.), (100., 200.), (200., 250.)] { + vector.0.push(Item::new_from_element(DVec2::from(position))); + } + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [0, 1, 2, 3, 1], []); + push_path(&mut paths, CurveType::Linear, [4, 5, 6], []); + + let beveled = super::bevel((), Item::new_from_element(vector).with_attribute(ATTR_PATHS, paths), item(10.)); + assert!(beveled.element().segment_iter(item_paths(&beveled)).count() > 6); + } + + #[test] + fn box_warp_onto_too_few_corners() { + let content = vector_item_from_bezpath(Rect::new(0., 0., 10., 10.).to_path(DEFAULT_ACCURACY)); + + assert_eq!(super::box_warp((), content.clone(), Item::default()).unwrap(), content); + let triangle = vector_item_from_points(&[DVec2::ZERO, DVec2::X, DVec2::Y]); + assert!(matches!(super::box_warp((), content, triangle), Err(BoxWarpError::TooFewCorners))); + } + + #[test] + fn sample_polyline_reports_too_many_points() { + let line = || vector_item_from_bezpath(Line::new((0., 0.), (100., 0.)).to_path(DEFAULT_ACCURACY)); + let sample = |spacing, separation, quantity| super::sample_polyline((), line(), item(spacing), item(separation), item(quantity), item(0.), item(0.), item(false)); + + assert!(matches!(sample(PointSpacingType::Separation, 0., 100), Err(SampleCountError::SeparationTooSmall { limit: 10_000 }))); + assert!(matches!(sample(PointSpacingType::Quantity, 100., 10_001), Err(SampleCountError::QuantityTooLarge { limit: 10_000 }))); + assert_eq!(sample(PointSpacingType::Quantity, 100., 10_000).unwrap().element().point_count(), 10_000); + } + + #[test] + fn assign_colors_from_an_empty_gradient() { + let content = || List::new_from_item(vector_item_from_bezpath(Rect::new(0., 0., 10., 10.).to_path(DEFAULT_ACCURACY))); + let assign = |fill: bool| { + super::assign_colors( + (), + content(), + item(fill), + item(false), + item(Gradient::from(Vec::::new())), + item(false), + item(false), + item(0), + item(0), + ) + }; + + assert!(matches!(assign(true), Err(AssignColorsError::EmptyGradient))); + // With neither the fill nor the stroke styled, the gradient goes unused + assert!(assign(false).is_ok()); + } + + #[test] + fn solidify_stroke_passes_an_item_without_a_stroke_through() { + let fill = Appearance::new_single(Coverage::new_fill(), Graphic::from(List::new_from_element(Color::RED))); + let filled = vector_item_from_bezpath(Rect::new(0., 0., 10., 10.).to_path(DEFAULT_ACCURACY)).with_attribute(ATTR_APPEARANCE, fill); + + let solidified = super::solidify_stroke((), filled.clone()); + assert_eq!(solidified.len(), 1); + assert_eq!(solidified.element(0), Some(filled.element())); + assert_eq!(solidified.attribute::(ATTR_APPEARANCE, 0), filled.attribute::(ATTR_APPEARANCE)); + } + + #[test] + fn copy_to_points_with_a_large_scale_bias() { + let points = vector_node_from_bezpath(Rect::new(-10., -10., 10., 10.).to_path(DEFAULT_ACCURACY)); + let content = vector_node_from_bezpath(Rect::new(-1., -1., 1., 1.).to_path(DEFAULT_ACCURACY)); + + let copies = super::copy_to_points((), points, content, item(1.), item(2.), item(2000.), item(0), item(0.), item(0)); + for index in 0..copies.len() { + let transform: DAffine2 = copies.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let scale = transform.matrix2.x_axis.length(); + assert!((1. ..=2.).contains(&scale), "copy {index} has scale {scale}"); + } + } } diff --git a/node-graph/nodes/vector/src/voronoi.rs b/node-graph/nodes/vector/src/voronoi.rs index 05f6eca9312..54a15b3274f 100644 --- a/node-graph/nodes/vector/src/voronoi.rs +++ b/node-graph/nodes/vector/src/voronoi.rs @@ -9,35 +9,50 @@ //! welded points and segments. Voronoi cells around the convex hull are unbounded, so they are clipped to the convex hull //! of the input sites, which also bounds the whole diagram to a finite region. -use delaunator::{EMPTY, Point, triangulate}; +use delaunator::{EMPTY, Point, Triangulation, triangulate}; use glam::DVec2; +#[derive(Debug, thiserror::Error)] +pub enum PointDiagramError { + #[error("Some points have positions that aren't finite")] + NonFinitePoints, +} + +/// Triangulates `sites` with `delaunator`, which can't take non-finite positions among them. +fn triangulation(sites: &[DVec2]) -> Result { + if !sites.iter().all(|site| site.is_finite()) { + return Err(PointDiagramError::NonFinitePoints); + } + + let points: Vec = sites.iter().map(|p| Point { x: p.x, y: p.y }).collect(); + Ok(triangulate(&points)) +} + /// Computes the Delaunay triangulation of `sites`, returning each triangle as a triple of indices into `sites`. /// -/// Returns an empty vector when there are fewer than three points or they are all colinear (no triangle exists). -pub fn delaunay_triangles(sites: &[DVec2]) -> Vec<[usize; 3]> { - let points: Vec = sites.iter().map(|p| Point { x: p.x, y: p.y }).collect(); - let triangulation = triangulate(&points); - triangulation.triangles.chunks_exact(3).map(|t| [t[0], t[1], t[2]]).collect() +/// Returns an empty vector when there are fewer than three points or they are all colinear (no triangle exists), and fails when a site isn't finite. +pub fn delaunay_triangles(sites: &[DVec2]) -> Result, PointDiagramError> { + let triangulation = triangulation(sites)?; + Ok(triangulation.triangles.as_chunks::<3>().0.iter().map(|t| [t[0], t[1], t[2]]).collect()) } /// Computes the Voronoi cell of every site, each clipped to the convex hull of `sites`. /// /// Returns one closed polygon per site that produces a non-empty cell (degenerate or fully-clipped cells are omitted, -/// so the result may be shorter than `sites`). Returns an empty vector when no triangulation exists (fewer than three points or all colinear). -pub fn voronoi_cells(sites: &[DVec2]) -> Vec> { - voronoi_cells_per_site(sites).0.into_iter().flatten().collect() +/// so the result may be shorter than `sites`). Returns an empty vector when no triangulation exists (fewer than three points or all colinear), and fails when a site isn't finite. +pub fn voronoi_cells(sites: &[DVec2]) -> Result>, PointDiagramError> { + Ok(voronoi_cells_per_site(sites)?.0.into_iter().flatten().collect()) } /// Applies Lloyd's relaxation: each step moves every interior site to the centroid of its Voronoi cell, yielding a more /// even (centroidal) point distribution. A fractional `iterations` runs the whole-number steps and then blends each site /// partway toward the result of one more step, so the relaxation can be animated smoothly. Returns the sites unchanged when -/// `iterations` is 0 or no diagram can be formed. +/// `iterations` is 0 or no diagram can be formed, and fails when `iterations` isn't 0 and a site isn't finite. /// /// The convex-hull (perimeter) sites are pinned so the point cloud's outline is preserved. Otherwise, clipping the /// unbounded perimeter cells would drag those sites around (inward for the convex hull, or outward into the corners of a /// fixed bounding box), distorting the shape over successive iterations. -pub fn relax_sites(sites: &[DVec2], iterations: f64) -> Vec { +pub fn relax_sites(sites: &[DVec2], iterations: f64) -> Result, PointDiagramError> { const MAX_STEPS_FOR_SAFETY: f64 = 1000.; let iterations = iterations.clamp(0., MAX_STEPS_FOR_SAFETY); let whole_steps = iterations.floor(); @@ -45,24 +60,24 @@ pub fn relax_sites(sites: &[DVec2], iterations: f64) -> Vec { let mut current = sites.to_vec(); for _ in 0..whole_steps as u32 { - current = relax_once(¤t); + current = relax_once(¤t)?; } // Blend each site partway toward one further step for the fractional remainder. if fraction > 0. { - let next = relax_once(¤t); + let next = relax_once(¤t)?; for (point, target) in current.iter_mut().zip(next) { *point = point.lerp(target, fraction); } } - current + Ok(current) } /// Performs a single Lloyd relaxation step: moves every interior site to its Voronoi cell centroid, /// leaving the pinned convex-hull (perimeter) sites in place. -fn relax_once(sites: &[DVec2]) -> Vec { - let (cells, is_hull) = voronoi_cells_per_site(sites); +fn relax_once(sites: &[DVec2]) -> Result, PointDiagramError> { + let (cells, is_hull) = voronoi_cells_per_site(sites)?; let mut relaxed = sites.to_vec(); for ((site, cell), on_hull) in relaxed.iter_mut().zip(cells).zip(is_hull) { if on_hull { @@ -72,7 +87,7 @@ fn relax_once(sites: &[DVec2]) -> Vec { *site = centroid; } } - relaxed + Ok(relaxed) } /// The area-weighted centroid of a simple polygon, or `None` if it has fewer than three vertices or zero area. @@ -95,11 +110,11 @@ fn polygon_centroid(polygon: &[DVec2]) -> Option { /// Computes each site's clipped Voronoi cell, aligned with `sites` (index `i` is the cell of `sites[i]`), together with a /// per-site flag marking the convex-hull (perimeter) sites. A cell is `None` when the site has no incident triangle /// (e.g. a coincident duplicate) or its cell vanishes after clipping. -fn voronoi_cells_per_site(sites: &[DVec2]) -> (Vec>>, Vec) { - let points: Vec = sites.iter().map(|p| Point { x: p.x, y: p.y }).collect(); - let triangulation = triangulate(&points); +#[allow(clippy::type_complexity)] +fn voronoi_cells_per_site(sites: &[DVec2]) -> Result<(Vec>>, Vec), PointDiagramError> { + let triangulation = triangulation(sites)?; if triangulation.triangles.is_empty() { - return (vec![None; sites.len()], vec![false; sites.len()]); + return Ok((vec![None; sites.len()], vec![false; sites.len()])); } let triangles = &triangulation.triangles; @@ -168,7 +183,7 @@ fn voronoi_cells_per_site(sites: &[DVec2]) -> (Vec>>, Vec 0., "voronoi cells are expected to be counter-clockwise"); } } #[test] fn delaunay_triangulates_square() { - let triangles = delaunay_triangles(&square_with_center()); + let triangles = delaunay_triangles(&square_with_center()).unwrap(); // Four corner-to-center triangles tessellate the square. assert_eq!(triangles.len(), 4); for triangle in triangles { @@ -329,12 +344,12 @@ mod tests { #[test] fn delaunay_degenerate_inputs_produce_no_triangles() { - assert!(delaunay_triangles(&[]).is_empty()); - assert!(delaunay_triangles(&[DVec2::new(1., 1.)]).is_empty()); - assert!(delaunay_triangles(&[DVec2::new(0., 0.), DVec2::new(1., 1.)]).is_empty()); + assert!(delaunay_triangles(&[]).unwrap().is_empty()); + assert!(delaunay_triangles(&[DVec2::new(1., 1.)]).unwrap().is_empty()); + assert!(delaunay_triangles(&[DVec2::new(0., 0.), DVec2::new(1., 1.)]).unwrap().is_empty()); // Colinear points have no triangulation. let colinear = vec![DVec2::new(0., 0.), DVec2::new(1., 1.), DVec2::new(2., 2.)]; - assert!(delaunay_triangles(&colinear).is_empty()); + assert!(delaunay_triangles(&colinear).unwrap().is_empty()); } #[test] @@ -343,14 +358,14 @@ mod tests { // (100 for the 10x10 square). If the outward projection direction were inverted, the boundary cells would collapse // inward and the total would fall well short of 100. let sites = square_with_center(); - let total: f64 = voronoi_cells(&sites).iter().map(|cell| signed_area(cell)).sum(); + let total: f64 = voronoi_cells(&sites).unwrap().iter().map(|cell| signed_area(cell)).sum(); assert!((total - 100.).abs() < 1e-6, "cells should tile the hull (area 100), got {total}"); } #[test] fn voronoi_cells_stay_within_the_hull() { let sites = square_with_center(); - let cells = voronoi_cells(&sites); + let cells = voronoi_cells(&sites).unwrap(); assert!(!cells.is_empty()); // Clipping to the hull keeps every vertex inside the input bounds (with a small tolerance for float error). for cell in &cells { @@ -365,7 +380,7 @@ mod tests { #[test] fn relaxation_with_zero_iterations_is_identity() { let sites = square_with_center(); - assert_eq!(relax_sites(&sites, 0.), sites); + assert_eq!(relax_sites(&sites, 0.).unwrap(), sites); } #[test] @@ -373,7 +388,7 @@ mod tests { // Add a point clustered near the center; relaxation should redistribute the points without leaving the hull. let mut sites = square_with_center(); sites.push(DVec2::new(5.5, 4.5)); - let relaxed = relax_sites(&sites, 3.); + let relaxed = relax_sites(&sites, 3.).unwrap(); assert_eq!(relaxed.len(), sites.len()); assert_ne!(relaxed, sites, "relaxation should move the points"); @@ -394,7 +409,7 @@ mod tests { DVec2::new(3., 3.), DVec2::new(7., 4.), ]; - let relaxed = relax_sites(&sites, 4.); + let relaxed = relax_sites(&sites, 4.).unwrap(); for i in 0..4 { assert_eq!(relaxed[i], sites[i], "convex hull point {i} should be pinned"); @@ -416,9 +431,9 @@ mod tests { // A fractional count lands exactly midway between the two bracketing whole-step results, exercising both the // pure-fraction path (0.5) and the whole-steps-then-fraction path (2.5). for whole in [0., 2.] { - let lower = relax_sites(&sites, whole); - let upper = relax_sites(&sites, whole + 1.); - let half = relax_sites(&sites, whole + 0.5); + let lower = relax_sites(&sites, whole).unwrap(); + let upper = relax_sites(&sites, whole + 1.).unwrap(); + let half = relax_sites(&sites, whole + 0.5).unwrap(); for i in 0..sites.len() { let expected = (lower[i] + upper[i]) / 2.; assert!((half[i] - expected).length() < 1e-9, "index {i} at {whole}.5: {half:?} vs {expected:?}", half = half[i]); @@ -430,7 +445,7 @@ mod tests { fn relaxation_leaves_degenerate_input_unchanged() { // Fewer than three points cannot form a diagram, so relaxation is a no-op. let sites = vec![DVec2::new(0., 0.), DVec2::new(1., 1.)]; - assert_eq!(relax_sites(&sites, 5.), sites); + assert_eq!(relax_sites(&sites, 5.).unwrap(), sites); } #[test] @@ -444,12 +459,12 @@ mod tests { DVec2::new(7., 6.), ]; // A huge or infinite count must clamp to the converged result rather than hang on a billions-long loop. - let converged = relax_sites(&sites, 1000.); - assert_eq!(relax_sites(&sites, 1e9), converged); - assert_eq!(relax_sites(&sites, f64::INFINITY), converged); + let converged = relax_sites(&sites, 1000.).unwrap(); + assert_eq!(relax_sites(&sites, 1e9).unwrap(), converged); + assert_eq!(relax_sites(&sites, f64::INFINITY).unwrap(), converged); // NaN and negative counts resolve to zero steps, leaving the sites unchanged. - assert_eq!(relax_sites(&sites, f64::NAN), sites); - assert_eq!(relax_sites(&sites, -5.), sites); + assert_eq!(relax_sites(&sites, f64::NAN).unwrap(), sites); + assert_eq!(relax_sites(&sites, -5.).unwrap(), sites); } #[test] @@ -460,7 +475,7 @@ mod tests { let angle = i as f64 / 6. * std::f64::consts::TAU; sites.push(DVec2::new(angle.cos() * 10., angle.sin() * 10.)); } - let cells = voronoi_cells(&sites); + let cells = voronoi_cells(&sites).unwrap(); assert!(!cells.is_empty()); // Every cell is a finite polygon with no runaway coordinates. for cell in &cells { From 198adebb0a76954fe03b20f6e836b1677b4d82f9 Mon Sep 17 00:00:00 2001 From: Timon Date: Tue, 27 Oct 2026 03:02:53 +0000 Subject: [PATCH 8/8] Report errors from graphic and context nodes --- Cargo.lock | 2 + node-graph/nodes/gcore/Cargo.toml | 1 + node-graph/nodes/gcore/src/context.rs | 111 ++++++++++------ node-graph/nodes/graphic/Cargo.toml | 1 + node-graph/nodes/graphic/src/graphic.rs | 165 +++++++++++++++++++----- 5 files changed, 208 insertions(+), 72 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3d1e4c05744..b01fa81b6b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2176,6 +2176,7 @@ dependencies = [ "node-macro", "raster-types", "serde", + "thiserror 2.0.18", "tsify", "wasm-bindgen", ] @@ -2262,6 +2263,7 @@ dependencies = [ "rand", "raster-types", "serde", + "thiserror 2.0.18", "vector-types", ] diff --git a/node-graph/nodes/gcore/Cargo.toml b/node-graph/nodes/gcore/Cargo.toml index 27c6199052b..bd8111f5381 100644 --- a/node-graph/nodes/gcore/Cargo.toml +++ b/node-graph/nodes/gcore/Cargo.toml @@ -38,6 +38,7 @@ graphene-animation = { workspace = true } dyn-any = { workspace = true } glam = { workspace = true } log = { workspace = true } +thiserror = { workspace = true } # Optional workspace dependencies serde = { workspace = true, optional = true } diff --git a/node-graph/nodes/gcore/src/context.rs b/node-graph/nodes/gcore/src/context.rs index ee71f15cda1..6076cc79575 100644 --- a/node-graph/nodes/gcore/src/context.rs +++ b/node-graph/nodes/gcore/src/context.rs @@ -6,63 +6,59 @@ use graphic_types::vector_types::Gradient; use graphic_types::{Graphic, Vector}; use raster_types::{CPU, Raster}; -#[node_macro::node(category("Data: Context"), icon("NodeReadContext"), path(graphene_core::vector))] -fn read_graphic(ctx: impl Ctx + ExtractVarArgs) -> Item { - let Ok(var_arg) = ctx.vararg(0) else { return Default::default() }; - let var_arg = var_arg as &dyn std::any::Any; - - var_arg.downcast_ref().cloned().unwrap_or_default() +#[derive(Debug, thiserror::Error)] +pub enum ReadContextError { + #[error("No {0} to read: this node only works inside a loop over {0} items, like in a Map node")] + NoLoopItem(&'static str), + #[error("No loop index to read: this node only works inside a loop, like in a Repeat or Map node")] + NoLoopIndex, + #[error("No position to read: this node only works inside a loop over points, like in a Repeat on Points node")] + NoLoopPosition, + #[error("Loop Level {level} is too high: the outermost loop around this node is level {outermost}")] + LoopLevelTooHigh { level: i64, outermost: usize }, } #[node_macro::node(category("Data: Context"), icon("NodeReadContext"), path(graphene_core::vector))] -fn read_vector(ctx: impl Ctx + ExtractVarArgs) -> Item { - let Ok(var_arg) = ctx.vararg(0) else { return Default::default() }; - let var_arg = var_arg as &dyn std::any::Any; - - var_arg.downcast_ref().cloned().unwrap_or_default() +fn read_graphic(ctx: impl Ctx + ExtractVarArgs) -> Result, ReadContextError> { + ctx.vararg(0).ok().and_then(|var_arg| var_arg.downcast_ref().cloned()).ok_or(ReadContextError::NoLoopItem("graphic")) } #[node_macro::node(category("Data: Context"), icon("NodeReadContext"), path(graphene_core::vector))] -fn read_raster(ctx: impl Ctx + ExtractVarArgs) -> Item> { - let Ok(var_arg) = ctx.vararg(0) else { return Default::default() }; - let var_arg = var_arg as &dyn std::any::Any; - - var_arg.downcast_ref().cloned().unwrap_or_default() +fn read_vector(ctx: impl Ctx + ExtractVarArgs) -> Result, ReadContextError> { + ctx.vararg(0).ok().and_then(|var_arg| var_arg.downcast_ref().cloned()).ok_or(ReadContextError::NoLoopItem("vector")) } #[node_macro::node(category("Data: Context"), icon("NodeReadContext"), path(graphene_core::vector))] -fn read_color(ctx: impl Ctx + ExtractVarArgs) -> Item { - let Ok(var_arg) = ctx.vararg(0) else { return Default::default() }; - let var_arg = var_arg as &dyn std::any::Any; - - var_arg.downcast_ref().cloned().unwrap_or_default() +fn read_raster(ctx: impl Ctx + ExtractVarArgs) -> Result>, ReadContextError> { + ctx.vararg(0).ok().and_then(|var_arg| var_arg.downcast_ref().cloned()).ok_or(ReadContextError::NoLoopItem("raster")) } #[node_macro::node(category("Data: Context"), icon("NodeReadContext"), path(graphene_core::vector))] -fn read_gradient(ctx: impl Ctx + ExtractVarArgs) -> Item { - let Ok(var_arg) = ctx.vararg(0) else { return Default::default() }; - let var_arg = var_arg as &dyn std::any::Any; +fn read_color(ctx: impl Ctx + ExtractVarArgs) -> Result, ReadContextError> { + ctx.vararg(0).ok().and_then(|var_arg| var_arg.downcast_ref().cloned()).ok_or(ReadContextError::NoLoopItem("color")) +} - var_arg.downcast_ref().cloned().unwrap_or_default() +#[node_macro::node(category("Data: Context"), icon("NodeReadContext"), path(graphene_core::vector))] +fn read_gradient(ctx: impl Ctx + ExtractVarArgs) -> Result, ReadContextError> { + ctx.vararg(0).ok().and_then(|var_arg| var_arg.downcast_ref().cloned()).ok_or(ReadContextError::NoLoopItem("gradient")) } /// Reads the current number from within a **Map** node's loop. #[node_macro::node(category("Data: Context"), icon("NodeReadContext"))] -fn read_number(ctx: impl Ctx + ExtractVarArgs) -> Item { - let Ok(var_arg) = ctx.vararg(0) else { return Default::default() }; - let var_arg = var_arg as &dyn std::any::Any; +fn read_number(ctx: impl Ctx + ExtractVarArgs) -> Result, ReadContextError> { + let var_arg = ctx.vararg(0).ok(); - if let Some(item) = var_arg.downcast_ref::>() { - return item.clone(); + if let Some(item) = var_arg.and_then(|var_arg| var_arg.downcast_ref::>()) { + return Ok(item.clone()); } // An integer list widens to f64, keeping the item's attributes - if let Some(item) = var_arg.downcast_ref::>() { + if let Some(item) = var_arg.and_then(|var_arg| var_arg.downcast_ref::>()) { let (element, attributes) = item.clone().into_parts(); - return Item::from_parts(element as f64, attributes); + return Ok(Item::from_parts(element as f64, attributes)); } - Default::default() + Err(ReadContextError::NoLoopItem("number")) } #[node_macro::node(category("Data: Context"), icon("NodeReadPosition"), path(core_types::vector))] @@ -74,9 +70,9 @@ fn read_position( /// In programming terms: inside the double loop `i { j { ... } }`, *Loop Level* 0 = `j` and 1 = `i`. After inserting a third loop `k { ... }`, inside it, levels would be 0 = `k`, 1 = `j`, and 2 = `i`. #[hard(0..)] loop_level: Item, -) -> Item { - let loop_level = *loop_level.element(); - Item::new_from_element(ctx.try_position().and_then(|mut iter| iter.nth(loop_level as usize).or_else(|| iter.last())).unwrap_or(DVec2::ZERO)) +) -> Result, ReadContextError> { + let positions = ctx.try_position().ok_or(ReadContextError::NoLoopPosition)?; + Ok(Item::new_from_element(at_loop_level(positions, *loop_level.element())?)) } // TODO: Return i64 instead of f64 once automatic type conversion is implemented for nodes with generic type inputs, so an integer output doesn't wall this index off from the generic math nodes. @@ -93,7 +89,44 @@ fn read_index( /// In programming terms: inside the double loop `i { j { ... } }`, *Loop Level* 0 = `j` and 1 = `i`. After inserting a third loop `k { ... }`, inside it, levels would be 0 = `k`, 1 = `j`, and 2 = `i`. #[hard(0..)] loop_level: Item, -) -> Item { - let loop_level = *loop_level.element(); - Item::new_from_element(ctx.try_index().and_then(|mut iter| iter.nth(loop_level as usize).or_else(|| iter.last())).unwrap_or(0) as f64) +) -> Result, ReadContextError> { + let indices = ctx.try_index().ok_or(ReadContextError::NoLoopIndex)?; + Ok(Item::new_from_element(at_loop_level(indices, *loop_level.element())? as f64)) +} + +/// The value of the loop `level` loops out from the innermost one, given the loops' values from the innermost outwards. +fn at_loop_level(levels: impl Iterator, level: i64) -> Result { + let levels: Vec = levels.collect(); + let outermost = levels.len().saturating_sub(1); + + usize::try_from(level) + .ok() + .and_then(|level| levels.into_iter().nth(level)) + .ok_or(ReadContextError::LoopLevelTooHigh { level, outermost }) +} + +#[cfg(test)] +mod tests { + use super::*; + use core_types::OwnedContextImpl; + + #[test] + fn read_index_reads_each_loop_level_and_fails_past_the_outermost() { + let nested = OwnedContextImpl::default().with_index(4).with_index(7).into_context(); + let level = |level: i64| read_index(nested.clone(), (), Item::new_from_element(level)).map(|index| *index.element()); + + assert_eq!(level(0).unwrap(), 7.); + assert_eq!(level(1).unwrap(), 4.); + assert_eq!(level(2).unwrap_err().to_string(), "Loop Level 2 is too high: the outermost loop around this node is level 1"); + assert!(matches!(level(i64::MAX), Err(ReadContextError::LoopLevelTooHigh { .. }))); + + let outside = read_index(OwnedContextImpl::default().into_context(), (), Item::new_from_element(0)); + assert!(matches!(outside, Err(ReadContextError::NoLoopIndex))); + } + + #[test] + fn read_number_outside_a_loop_fails() { + let error = read_number(OwnedContextImpl::default().into_context()).unwrap_err(); + assert_eq!(error.to_string(), "No number to read: this node only works inside a loop over number items, like in a Map node"); + } } diff --git a/node-graph/nodes/graphic/Cargo.toml b/node-graph/nodes/graphic/Cargo.toml index a558ef61d41..59cfdfbe25a 100644 --- a/node-graph/nodes/graphic/Cargo.toml +++ b/node-graph/nodes/graphic/Cargo.toml @@ -27,3 +27,4 @@ glam = { workspace = true } serde = { workspace = true } node-macro = { workspace = true } rand = { workspace = true } +thiserror = { workspace = true } diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index 1075adac172..ca0fb57e4d0 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -298,31 +298,31 @@ fn slice( list.into_iter().skip(start).take(end - start).collect() } -/// Pairwise ordering used by the Sort node for element values. Types without a natural -/// order compare as equal, so the stable sort leaves their items in their original relative positions. +/// Pairwise ordering used by the Sort node for element values. Types without a natural order give `None`, +/// so their items can only be sorted by a *Sort Order* list. pub trait ElementOrder { - fn element_order(&self, _other: &Self) -> Ordering { - Ordering::Equal + fn element_order(&self, _other: &Self) -> Option { + None } } impl ElementOrder for String { - fn element_order(&self, other: &Self) -> Ordering { - self.cmp(other) + fn element_order(&self, other: &Self) -> Option { + Some(self.cmp(other)) } } impl ElementOrder for bool { - fn element_order(&self, other: &Self) -> Ordering { - self.cmp(other) + fn element_order(&self, other: &Self) -> Option { + Some(self.cmp(other)) } } impl ElementOrder for f64 { - fn element_order(&self, other: &Self) -> Ordering { - self.total_cmp(other) + fn element_order(&self, other: &Self) -> Option { + Some(self.total_cmp(other)) } } impl ElementOrder for i64 { - fn element_order(&self, other: &Self) -> Ordering { - self.cmp(other) + fn element_order(&self, other: &Self) -> Option { + Some(self.cmp(other)) } } impl ElementOrder for DVec2 {} @@ -335,6 +335,12 @@ impl ElementOrder for Color {} impl ElementOrder for Gradient {} impl ElementOrder for Artboard {} +#[derive(Debug, thiserror::Error)] +pub enum SortError { + #[error("These items have no natural order, so connect a Sort Order list to sort them")] + NoNaturalOrder, +} + /// Reorders a list's items from smallest to largest, either by each item's own value or by a parallel list of sortable values in the *Sort Order* input. The sort is stable, so items with the same sort order retain their relative positions. #[node_macro::node(category("Data: List"), icon("NodeSort"))] fn sort( @@ -356,21 +362,29 @@ fn sort, /// Reverses the sorted list order, following descending order instead of ascending (numbers largest-to-smallest, strings Z-to-A, etc.). reverse: Item, -) -> List { +) -> Result, SortError> { let reverse = reverse.into_element(); // Order by the parallel keys when provided (repeating the last if there are fewer keys than items), otherwise by the element values themselves let keys = sort_order.iter_element_values().as_slice(); let elements: Vec<&T> = list.iter_element_values().collect(); + let mut unordered = false; let mut order: Vec = (0..list.len()).collect(); order.sort_by(|&a, &b| { let ordering = match keys { [] => elements[a].element_order(elements[b]), keys => keys[a.min(keys.len() - 1)].element_order(&keys[b.min(keys.len() - 1)]), }; + let ordering = ordering.unwrap_or_else(|| { + unordered = true; + Ordering::Equal + }); if reverse { ordering.reverse() } else { ordering } }); + if unordered { + return Err(SortError::NoNaturalOrder); + } let mut result = List::new(); for index in order { @@ -379,7 +393,7 @@ fn sort( Ok(rows) } +#[derive(Debug, thiserror::Error)] +pub enum MirrorError { + #[error("Text and fills have no bounds to mirror relative to, so set Relative to Bounds to None")] + NoBounds, +} + +/// Whether the content holds text, which has no bounds in the graph. +pub trait HasText { + fn has_text(&self) -> bool { + false + } +} +impl HasText for String { + fn has_text(&self) -> bool { + !self.is_empty() + } +} +impl HasText for Graphic { + fn has_text(&self) -> bool { + match self { + Graphic::Text(item) => item.element().has_text(), + Graphic::TextList(list) => list.iter_element_values().any(HasText::has_text), + Graphic::Graphic(item) => item.element().has_text(), + Graphic::GraphicList(list) => list.iter_element_values().any(HasText::has_text), + _ => false, + } + } +} +impl HasText for Vector {} +impl HasText for Raster {} +impl HasText for Raster {} +impl HasText for Color {} +impl HasText for Gradient {} + #[node_macro::node(category("Graphic"), icon("NodeMirror"))] -fn mirror( +fn mirror( _: impl Ctx, #[implementations( Graphic, @@ -451,7 +499,7 @@ fn mirror( #[soft(-90..90)] angle: Item, #[default(true)] keep_original: Item, -) -> List { +) -> Result, MirrorError> { let (relative_to_bounds, offset, angle, keep_original) = (relative_to_bounds.into_element(), offset.into_element(), angle.into_element(), keep_original.into_element()); // Normalize the direction vector @@ -459,13 +507,16 @@ fn mirror( // The mirror reference may be based on the bounding box if an explicit reference point is chosen let item_transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); - let RenderBoundingBox::Rectangle(bounding_box) = content.bounding_box(DAffine2::IDENTITY, false) else { - return List::new_from_item(content); + let mirror_reference_point = match relative_to_bounds { + ReferencePoint::None => None, + reference_point => match content.bounding_box(DAffine2::IDENTITY, false) { + RenderBoundingBox::Rectangle(bounding_box) => reference_point.point_in_bounding_box((bounding_box[0], bounding_box[1]).into()).map(|point| point + normal * offset), + // Empty content looks the same mirrored + RenderBoundingBox::None if !content.element().has_text() => return Ok(std::iter::repeat_n(content, if keep_original { 2 } else { 1 }).collect()), + _ => return Err(MirrorError::NoBounds), + }, }; - let reference_point_location = relative_to_bounds.point_in_bounding_box((bounding_box[0], bounding_box[1]).into()); - let mirror_reference_point = reference_point_location.map(|point| point + normal * offset); - // Create the reflection matrix let reflection = DAffine2::from_mat2_translation( glam::DMat2::from_cols( @@ -493,7 +544,7 @@ fn mirror( mirrored.set_attribute(ATTR_TRANSFORM, reflected_transform * item_transform); result_list.push(mirrored); - result_list + Ok(result_list) } /// Returns the path identifying the subgraph (network) that contains this proto node — i.e. the input `node_path` @@ -1009,10 +1060,33 @@ pub fn flatten_vector(_: impl Ctx, content: List) -> List { output } +#[derive(Debug, thiserror::Error)] +pub enum FlattenRasterError { + #[error("GPU rasters such as brush strokes can't be flattened yet, so they were left out")] + GpuRasters, +} + +/// Whether the content holds GPU rasters, which flattening to CPU rasters leaves out. +fn has_gpu_raster(graphic: &Graphic) -> bool { + match graphic { + Graphic::RasterGPU(_) => true, + Graphic::RasterGPUList(list) => !list.is_empty(), + Graphic::Graphic(item) => has_gpu_raster(item.element()), + Graphic::GraphicList(list) => list.iter_element_values().any(has_gpu_raster), + _ => false, + } +} + /// Converts a `Graphic[]` into a `Raster[]` by deeply flattening any raster content it contains, and discarding any non-raster content. #[node_macro::node(category("Graphic"), icon("NodeFlattenRaster"))] -fn flatten_raster(_: impl Ctx, content: List) -> List> { - content.into_flattened_list() +fn flatten_raster(_: impl Ctx, content: List) -> Result>, (List>, FlattenRasterError)> { + let has_gpu_rasters = content.iter_element_values().any(has_gpu_raster); + let rasters = content.into_flattened_list(); + + if has_gpu_rasters { + return Err((rasters, FlattenRasterError::GpuRasters)); + } + Ok(rasters) } /// Converts a `Graphic[]` into a `Color[]` by deeply flattening any color content it contains, and discarding any non-color content. @@ -1059,50 +1133,75 @@ mod test { #[test] fn sorts_elements_by_their_natural_order() { let list = list_of(["banana".to_string(), "apple".to_string(), "cherry".to_string()]); - let sorted = sort((), list, List::::new(), Item::new_from_element(false)); + let sorted = sort((), list, List::::new(), Item::new_from_element(false)).unwrap(); assert_eq!(elements(&sorted), ["apple", "banana", "cherry"]); } #[test] fn sorts_elements_in_reverse() { let list = list_of([3., 1., 2.]); - let sorted = sort((), list, List::::new(), Item::new_from_element(true)); + let sorted = sort((), list, List::::new(), Item::new_from_element(true)).unwrap(); assert_eq!(elements(&sorted), [3., 2., 1.]); } #[test] fn sort_order_keys_override_element_order() { let list = list_of(["apple".to_string(), "banana".to_string(), "cherry".to_string()]); - let sorted = sort((), list, list_of([2., 0., 1.]), Item::new_from_element(false)); + let sorted = sort((), list, list_of([2., 0., 1.]), Item::new_from_element(false)).unwrap(); assert_eq!(elements(&sorted), ["banana", "cherry", "apple"]); } #[test] fn short_sort_order_repeats_its_last_key() { let list = list_of(["a".to_string(), "b".to_string(), "c".to_string()]); - let sorted = sort((), list, list_of([2., 1.]), Item::new_from_element(false)); + let sorted = sort((), list, list_of([2., 1.]), Item::new_from_element(false)).unwrap(); assert_eq!(elements(&sorted), ["b", "c", "a"]); } #[test] fn long_sort_order_ignores_its_extra_keys() { let list = list_of([1., 2.]); - let sorted = sort((), list, list_of([3., 1., 0., 5.]), Item::new_from_element(false)); + let sorted = sort((), list, list_of([3., 1., 0., 5.]), Item::new_from_element(false)).unwrap(); assert_eq!(elements(&sorted), [2., 1.]); } #[test] fn text_sort_order_keys_order_items_alphabetically() { let list = list_of([1., 2., 3.]); - let sorted = sort((), list, list_of(["c".to_string(), "a".to_string(), "b".to_string()]), Item::new_from_element(false)); + let sorted = sort((), list, list_of(["c".to_string(), "a".to_string(), "b".to_string()]), Item::new_from_element(false)).unwrap(); assert_eq!(elements(&sorted), [2., 3., 1.]); } #[test] - fn unsortable_elements_keep_their_original_order() { + fn unsortable_elements_need_sort_order_keys() { let list = list_of([DVec2::new(3., 3.), DVec2::new(1., 1.), DVec2::new(2., 2.)]); - let sorted = sort((), list, List::::new(), Item::new_from_element(false)); - assert_eq!(elements(&sorted), [DVec2::new(3., 3.), DVec2::new(1., 1.), DVec2::new(2., 2.)]); + assert!(matches!(sort((), list.clone(), List::::new(), Item::new_from_element(false)), Err(SortError::NoNaturalOrder))); + + let sorted = sort((), list, list_of([2., 0., 1.]), Item::new_from_element(false)).unwrap(); + assert_eq!(elements(&sorted), [DVec2::new(1., 1.), DVec2::new(2., 2.), DVec2::new(3., 3.)]); + + let single = sort((), list_of([DVec2::ONE]), List::::new(), Item::new_from_element(false)).unwrap(); + assert_eq!(elements(&single), [DVec2::ONE], "a single item is already in order"); + } + + fn mirrored(content: T, relative_to_bounds: ReferencePoint) -> Result, MirrorError> { + mirror((), content.into(), relative_to_bounds.into(), 0.0.into(), 0.0.into(), true.into()) + } + + #[test] + fn mirror_without_bounds_reflects_across_the_origin() { + let text = mirrored("text".to_string(), ReferencePoint::None).unwrap(); + assert_eq!(text.len(), 2); + assert_eq!(text.attribute_cloned_or_default::(ATTR_TRANSFORM, 1), DAffine2::from_scale(DVec2::new(-1., 1.))); + + assert!(matches!(mirrored("text".to_string(), ReferencePoint::Center), Err(MirrorError::NoBounds))); + assert!(matches!(mirrored(Color::RED, ReferencePoint::Center), Err(MirrorError::NoBounds))); + } + + #[test] + fn mirroring_empty_content_keeps_the_original_and_the_copy() { + assert_eq!(mirrored(Vector::default(), ReferencePoint::Center).unwrap().len(), 2); + assert_eq!(mirrored(String::new(), ReferencePoint::Center).unwrap().len(), 2); } #[test]