diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 0672a0c4c3e..2520d760396 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -813,7 +813,7 @@ impl MessageHandler> for DocumentMes } else { // Clipboard paste or drag-drop: center at cursor or viewport center. // Convert the document-space cursor to the parent's local coordinate space so that - // an artboard at a non-zero position does not offset the placement. + // an artboard at a nonzero position does not offset the placement. let parent_to_document = { let metadata = self.metadata(); metadata.document_to_viewport.inverse() * metadata.transform_to_viewport(layer_parent) @@ -876,7 +876,7 @@ impl MessageHandler> for DocumentMes } else { // Clipboard paste or drag-drop: center at cursor or viewport center. // Convert the document-space cursor to the parent's local coordinate space so that - // an artboard at a non-zero position does not offset the placement. + // an artboard at a nonzero position does not offset the placement. let parent_to_document = { let metadata = self.metadata(); metadata.document_to_viewport.inverse() * metadata.transform_to_viewport(layer_parent) @@ -2778,7 +2778,7 @@ impl DocumentMessageHandler { let appearance = self.network_interface.document_metadata().layer_appearance_attributes.get(&layer); let has_fill = appearance.is_some_and(|appearance| appearance.has_painted_cover(Cover::Fill)); - // A visible stroke needs both renderable geometry (non-zero weight) and paint that draws something + // A visible stroke needs both renderable geometry (nonzero weight) and paint that draws something let has_stroke = appearance.is_some_and(|appearance| { appearance.first_coverage_of(Cover::Stroke).is_some_and(|coverage| coverage.stroke_params().has_renderable_stroke()) && appearance.first_paint_of(Cover::Stroke).is_some_and(|paint| !paint.is_guaranteed_fully_transparent()) @@ -3751,7 +3751,7 @@ impl DocumentMessageHandler { let first_or_last_selected_layer = match relative_index_offset.signum() { -1 => selected_layers.next(), 1 => selected_layers.last(), - _ => panic!("selected_layers_reorder() must be given a non-zero value"), + _ => panic!("selected_layers_reorder() must be given a nonzero value"), }; let Some(pivot_layer) = first_or_last_selected_layer else { diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index b7b79062a15..8b9c11ead6b 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -58,7 +58,7 @@ pub struct PortfolioMessageHandler { pub reset_node_definitions_on_open: bool, pub workspace: WorkspaceMessageHandler, working_copy_root: Option, - /// Number of document not fully loaded. While non-zero, resource GC is skipped. + /// Number of documents not fully loaded. While nonzero, resource GC is skipped. pending_opens: usize, } diff --git a/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs b/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs index bc9dbd6adb5..c803140775d 100644 --- a/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs +++ b/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs @@ -1184,8 +1184,8 @@ mod test_transform_layer { let new_scale_x = final_transform.matrix2.x_axis.length(); let new_scale_y = final_transform.matrix2.y_axis.length(); - assert!(new_scale_x > 0., "After rescaling, scale factor X should be non-zero"); - assert!(new_scale_y > 0., "After rescaling, scale factor Y should be non-zero"); + assert!(new_scale_x > 0., "After rescaling, scale factor X should be nonzero"); + assert!(new_scale_y > 0., "After rescaling, scale factor Y should be nonzero"); } #[tokio::test] diff --git a/libraries/math-parser/src/ast.rs b/libraries/math-parser/src/ast.rs index b2fe436cabf..3b3c928989f 100644 --- a/libraries/math-parser/src/ast.rs +++ b/libraries/math-parser/src/ast.rs @@ -1,42 +1,5 @@ use crate::value::Complex; -#[derive(Debug, PartialEq, Eq)] -pub struct Unit { - // Exponent of length unit (meters) - pub length: i32, - // Exponent of mass unit (kilograms) - pub mass: i32, - // Exponent of time unit (seconds) - pub time: i32, -} - -impl Default for Unit { - fn default() -> Self { - Self::BASE_UNIT - } -} - -impl Unit { - pub const BASE_UNIT: Unit = Unit { length: 0, mass: 0, time: 0 }; - - pub const LENGTH: Unit = Unit { length: 1, mass: 0, time: 0 }; - pub const MASS: Unit = Unit { length: 0, mass: 1, time: 0 }; - pub const TIME: Unit = Unit { length: 0, mass: 0, time: 1 }; - - pub const VELOCITY: Unit = Unit { length: 1, mass: 0, time: -1 }; - pub const ACCELERATION: Unit = Unit { length: 1, mass: 0, time: -2 }; - - pub const FORCE: Unit = Unit { length: 1, mass: 1, time: -2 }; - - pub fn base_unit() -> Self { - Self::BASE_UNIT - } - - pub fn is_base(&self) -> bool { - *self == Self::BASE_UNIT - } -} - #[derive(Debug, Clone, PartialEq)] pub enum Literal { Float(f64), @@ -71,8 +34,8 @@ pub enum BinaryOp { #[derive(Debug, PartialEq, Clone, Copy)] pub enum UnaryOp { + Pos, Neg, - Sqrt, Fac, Not, } diff --git a/libraries/math-parser/src/constants.rs b/libraries/math-parser/src/constants.rs index 16577b8bc42..2031f52debc 100644 --- a/libraries/math-parser/src/constants.rs +++ b/libraries/math-parser/src/constants.rs @@ -4,23 +4,65 @@ use std::f64::consts::{LN_2, PI}; pub type BuiltinFunction = fn(&[Value]) -> Option; -/// Truncates both operands to nonnegative integers for `gcd`/`lcm`, or `None` when either is non-finite or beyond f64's exactly-representable integer range. -fn integer_operands(a: f64, b: f64) -> Option<(u64, u64)> { - // The largest magnitude below which every integer is exactly representable in f64 - const EXACT_INTEGER_LIMIT: f64 = (1_u64 << f64::MANTISSA_DIGITS) as f64; +/// The largest magnitude below which every integer is exactly representable in f64. +const EXACT_INTEGER_LIMIT: f64 = (1_u64 << f64::MANTISSA_DIGITS) as f64; - let (a, b) = (a.trunc(), b.trunc()); - if !a.is_finite() || !b.is_finite() || a.abs() > EXACT_INTEGER_LIMIT || b.abs() > EXACT_INTEGER_LIMIT { - return None; +/// Truncates an operand to a nonnegative integer for `gcd`/`lcm`, or `None` when it is non-finite or beyond f64's exactly-representable integer range. +fn integer_operand(value: f64) -> Option { + let value = value.trunc(); + (value.is_finite() && value.abs() <= EXACT_INTEGER_LIMIT).then(|| (value as i64).unsigned_abs() as u128) +} + +/// Rounds a combinatorics operand to the nearest whole number, or `None` when it is negative, non-finite, or beyond f64's exactly-representable integer range. +fn whole_operand(value: f64) -> Option { + let value = value.round(); + (0. ..=EXACT_INTEGER_LIMIT).contains(&value).then_some(value as u64) +} + +/// Accumulates one multiplicative `step` per iteration, stopping once the running product reaches infinity, since it stays there. +/// That bounds the work to a few thousand steps for operands whose true result no f64 can hold. +fn bounded_product(steps: impl Iterator, step: impl Fn(f64, u64) -> f64) -> f64 { + let mut product = 1.; + for index in steps { + if !product.is_finite() { + break; + } + product = step(product, index); + } + product +} + +/// Computes the greatest common divisor of two nonnegative integers by the Euclidean algorithm. +pub fn gcd(a: u128, b: u128) -> u128 { + let (mut a, mut b) = (a, b); + // O(log min(a, b)) iterations, worst case 184 loops with the largest consecutive u128 Fibonacci numbers + while b != 0 { + (a, b) = (b, a % b); + } + a +} + +/// Computes the least common multiple of two nonnegative integers. Operands within f64's exact integer range cannot overflow it. +pub fn lcm(a: u128, b: u128) -> u128 { + if a == 0 || b == 0 { + return 0; } - Some(((a as i64).unsigned_abs(), (b as i64).unsigned_abs())) + (a / gcd(a, b)) * b } -fn euclidean_gcd(mut x: u64, mut y: u64) -> u64 { - while y != 0 { - (x, y) = (y, x % y); +/// Resolves a base-suffixed function name like `log2` or `root3.25` into the corresponding two-argument +/// function and the baked-in second argument parsed from the suffix. +pub fn suffixed_function(name: &str) -> Option<(BuiltinFunction, f64)> { + let (function, suffix) = ["log", "root"].into_iter().find_map(|prefix| Some((prefix, name.strip_prefix(prefix)?)))?; + let suffix = suffix.strip_prefix('_').unwrap_or(suffix); + + // A base is written in plain decimal, leaving anything else, like the keyword-valued `loginf` or the scientific `log2e5`, to resolve as a variable or custom function + if !suffix.starts_with(|c: char| c.is_ascii_digit()) || !suffix.chars().all(|c| c.is_ascii_digit() || c == '.') { + return None; } - x + let base = suffix.parse::().ok().filter(|base| base.is_finite())?; + + Some((builtin_function(function)?, base)) } /// Looks up a built-in math function by name, returning a plain function pointer so dispatch avoids hashing and dynamic allocation. @@ -62,67 +104,37 @@ pub fn builtin_function(name: &str) -> Option { _ => None, }, - // Inverse trig with legacy names and standard aliases - "invsin" => |values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.asin()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.asin()))), - _ => None, - }, + // TODO: Offer the `arc-`/`ar-` spellings (`arcsin`, `artanh`) and the legacy `inv-` names as autocomplete aliases in the expression widget, resolving to these canonical names "asin" => |values| match values { [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.asin()))), [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.asin()))), _ => None, }, - "invcos" => |values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.acos()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.acos()))), - _ => None, - }, "acos" => |values| match values { [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.acos()))), [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.acos()))), _ => None, }, - "invtan" => |values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.atan()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.atan()))), - _ => None, - }, "atan" => |values| match values { [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.atan()))), [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.atan()))), _ => None, }, - "invcsc" => |values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().asin()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().asin()))), - _ => None, - }, "acsc" => |values| match values { [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().asin()))), [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().asin()))), _ => None, }, - "invsec" => |values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().acos()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().acos()))), - _ => None, - }, "asec" => |values| match values { [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().acos()))), [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().acos()))), _ => None, }, - "invcot" => |values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().atan()))), - [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().atan()))), - _ => None, - }, "acot" => |values| match values { [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.recip().atan()))), [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.recip().atan()))), @@ -218,13 +230,6 @@ pub fn builtin_function(name: &str) -> Option { _ => None, }, - "pow" => |values| match values { - [Value::Number(Number::Real(x)), Value::Number(Number::Real(n))] => Some(Value::Number(Number::Real(x.powf(*n)))), - [Value::Number(Number::Complex(x)), Value::Number(Number::Real(n))] => Some(Value::Number(Number::Complex(x.powf(*n)))), - [Value::Number(Number::Complex(x)), Value::Number(Number::Complex(n))] => Some(Value::Number(Number::Complex(x.powc(*n)))), - _ => None, - }, - "root" => |values| match values { [Value::Number(Number::Real(x)), Value::Number(Number::Real(n))] => { // Odd integer roots of negative reals are real, which powf alone would report as NaN @@ -238,14 +243,9 @@ pub fn builtin_function(name: &str) -> Option { "log" => |values| match values { [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.log10()))), [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.log10()))), - [Value::Number(n), Value::Number(base)] => { - // Custom base logarithm using change of base formula - let compute_log = |x: f64, b: f64| -> f64 { x.ln() / b.ln() }; - match (n, base) { - (Number::Real(x), Number::Real(b)) => Some(Value::Number(Number::Real(compute_log(*x, *b)))), - _ => None, - } - } + // Change of base, staying real when both operands are, and widening into the complex plane when either is not + [Value::Number(Number::Real(x)), Value::Number(Number::Real(base))] => Some(Value::Number(Number::Real(x.ln() / base.ln()))), + [Value::Number(x), Value::Number(base)] => Some(Value::Number(Number::Complex(x.as_complex().ln() / base.as_complex().ln()))), _ => None, }, @@ -306,6 +306,28 @@ pub fn builtin_function(name: &str) -> Option { _ => None, }, + // Variadic across one or more real arguments, ignoring NaN like Rust's own f64::min/f64::max + "min" => |values| { + // Seeded from the first argument so that arguments which are all NaN give back NaN rather than an infinity of their own, even though a NaN input should represent a bug + let [Value::Number(Number::Real(first)), rest @ ..] = values else { return None }; + let mut min = *first; + for value in rest { + let Value::Number(Number::Real(real)) = value else { return None }; + min = min.min(*real); + } + Some(Value::Number(Number::Real(min))) + }, + + "max" => |values| { + let [Value::Number(Number::Real(first)), rest @ ..] = values else { return None }; + let mut max = *first; + for value in rest { + let Value::Number(Number::Real(real)) = value else { return None }; + max = max.max(*real); + } + Some(Value::Number(Number::Real(max))) + }, + "lerp" => |values| match values { [Value::Number(Number::Real(a)), Value::Number(Number::Real(b)), Value::Number(Number::Real(t))] => Some(Value::Number(Number::Real(a + (b - a) * t))), _ => None, @@ -351,7 +373,7 @@ pub fn builtin_function(name: &str) -> Option { "gcd" => |values| match values { [Value::Number(Number::Real(a)), Value::Number(Number::Real(b))] => { - let gcd = integer_operands(*a, *b).map_or(f64::NAN, |(x, y)| euclidean_gcd(x, y) as f64); + let gcd = integer_operand(*a).zip(integer_operand(*b)).map_or(f64::NAN, |(a, b)| gcd(a, b) as f64); Some(Value::Number(Number::Real(gcd))) } _ => None, @@ -359,16 +381,37 @@ pub fn builtin_function(name: &str) -> Option { "lcm" => |values| match values { [Value::Number(Number::Real(a)), Value::Number(Number::Real(b))] => { - let Some((x, y)) = integer_operands(*a, *b) else { - return Some(Value::Number(Number::Real(f64::NAN))); - }; - if x == 0 || y == 0 { - return Some(Value::Number(Number::Real(0.))); + let lcm = integer_operand(*a).zip(integer_operand(*b)).map_or(f64::NAN, |(a, b)| lcm(a, b) as f64); + Some(Value::Number(Number::Real(lcm))) + } + _ => None, + }, + + // Combinatorics over whole numbers: `choose(n, r)` is the binomial coefficient and `pick(n, r)` the falling factorial + "choose" => |values| match values { + [Value::Number(Number::Real(n)), Value::Number(Number::Real(r))] => { + let (n, r) = (whole_operand(*n)?, whole_operand(*r)?); + if r > n { + return Some(Value::from_f64(0.)); } - // Multiply in f64 so huge results can't overflow the integer range - let lcm = (x / euclidean_gcd(x, y)) as f64 * y as f64; - Some(Value::Number(Number::Real(lcm))) + // Multiplying then dividing at each step keeps every intermediate whole, and the smaller of `r` and `n - r` halves the steps + let r = r.min(n - r); + let binomial = bounded_product(1..=r, |accumulated, k| accumulated * (n - r + k) as f64 / k as f64); + Some(Value::from_f64(binomial)) + } + _ => None, + }, + + "pick" => |values| match values { + [Value::Number(Number::Real(n)), Value::Number(Number::Real(r))] => { + let (n, r) = (whole_operand(*n)?, whole_operand(*r)?); + if r > n { + return Some(Value::from_f64(0.)); + } + + let falling_factorial = bounded_product(0..r, |accumulated, k| accumulated * (n - k) as f64); + Some(Value::from_f64(falling_factorial)) } _ => None, }, @@ -401,21 +444,6 @@ pub fn builtin_function(name: &str) -> Option { _ => None, }, - // Logical Functions - "isnan" => |values| match values { - [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(if real.is_nan() { 1. } else { 0. }))), - _ => None, - }, - - "eq" => |values| match values { - [Value::Number(a), Value::Number(b)] => Some(Value::Number(Number::Real(if a == b { 1. } else { 0. }))), - _ => None, - }, - - "greater" => |values| match values { - [Value::Number(Number::Real(a)), Value::Number(Number::Real(b))] => Some(Value::Number(Number::Real(if a > b { 1. } else { 0. }))), - _ => None, - }, _ => return None, }) } diff --git a/libraries/math-parser/src/context.rs b/libraries/math-parser/src/context.rs index d6e4fe6285c..6804b7468e4 100644 --- a/libraries/math-parser/src/context.rs +++ b/libraries/math-parser/src/context.rs @@ -11,13 +11,14 @@ pub trait FunctionProvider { fn run_function(&self, name: &str, args: &[Value]) -> Option; } -pub struct ValueMap(HashMap); +#[derive(Default)] +pub struct ValueMap(pub HashMap); pub struct NothingMap; -impl ValueProvider for &ValueMap { +impl ValueProvider for &V { fn get_value(&self, name: &str) -> Option { - self.0.get(name).cloned() + (**self).get_value(name) } } diff --git a/libraries/math-parser/src/executer.rs b/libraries/math-parser/src/executer.rs index 9dcd6f7639f..867ebeaefab 100644 --- a/libraries/math-parser/src/executer.rs +++ b/libraries/math-parser/src/executer.rs @@ -1,5 +1,5 @@ use crate::ast::{BinaryOp, Literal, Node}; -use crate::constants::builtin_function; +use crate::constants::{builtin_function, suffixed_function}; use crate::context::{EvalContext, FunctionProvider, ValueProvider}; use crate::value::{Number, Value}; use thiserror::Error; @@ -50,6 +50,10 @@ impl Node { if let Some(function) = builtin_function(name) { function(values).ok_or(EvalError::TypeError) + } else if let Some((function, base)) = suffixed_function(name) { + // A base-suffixed call like `log10(x)` runs the two-argument form with the suffix baked in as its second argument + let [value] = values else { return Err(EvalError::TypeError) }; + function(&[*value, Value::from_f64(base)]).ok_or(EvalError::TypeError) } else if let Some(val) = context.run_function(name, values) { Ok(val) } else if let Some(Value::Number(value)) = context.get_value(name) @@ -143,9 +147,9 @@ mod tests { expr: Box::new(Node::Lit(Literal::Float(3.))), op: UnaryOp::Neg, }, - test_sqrt: Value::from_f64(2.) => Node::UnaryOp { - expr: Box::new(Node::Lit(Literal::Float(4.))), - op: UnaryOp::Sqrt, + test_sqrt: Value::from_f64(2.) => Node::FnCall { + name: "sqrt".to_string(), + expr: vec![Node::Lit(Literal::Float(4.))], }, test_power: Value::from_f64(8.) => Node::BinOp { lhs: Box::new(Node::Lit(Literal::Float(2.))), diff --git a/libraries/math-parser/src/lexer.rs b/libraries/math-parser/src/lexer.rs index 07ed912c786..7270ff28dca 100644 --- a/libraries/math-parser/src/lexer.rs +++ b/libraries/math-parser/src/lexer.rs @@ -16,6 +16,7 @@ pub enum Token<'src> { AndAnd, OrOr, Bang, + Not, LParen, RParen, @@ -50,6 +51,7 @@ impl<'src> fmt::Display for Token<'src> { Token::AndAnd => f.write_str("&&"), Token::OrOr => f.write_str("||"), Token::Bang => f.write_str("!"), + Token::Not => f.write_str("¬"), Token::LParen => f.write_str("("), Token::RParen => f.write_str(")"), @@ -83,7 +85,8 @@ pub enum Constant { Phi, Inf, I, - G, + True, + False, } impl Constant { @@ -94,25 +97,34 @@ impl Constant { Pi => Literal::Float(consts::PI), Tau => Literal::Float(consts::TAU), E => Literal::Float(consts::E), - Phi => Literal::Float(1.618_033_988_75), + // TODO: Replace with f64::GOLDEN_RATIO when we bump MSRV to 1.94 + Phi => Literal::Float(1.618033988749895), Inf => Literal::Float(f64::INFINITY), I => Literal::Complex(Complex64::new(0., 1.)), - G => Literal::Float(9.80665), + True => Literal::Float(1.), + False => Literal::Float(0.), } } + /// The word and typeset spellings, matched exactly: constants are lowercase-only, since uppercase-initial names are reserved for matrices. pub fn from_name(name: &str) -> Option { use Constant::*; - Some(match name { - "pi" | "π" => Pi, - "tau" | "τ" => Tau, - "e" => E, - "phi" | "φ" => Phi, - "inf" | "∞" => Inf, - "i" => I, - "G" => G, - _ => return None, - }) + let spellings = [ + ("e", E), + ("i", I), + ("pi", Pi), + ("π", Pi), + ("tau", Tau), + ("τ", Tau), + ("phi", Phi), + ("φ", Phi), + ("inf", Inf), + ("infinity", Inf), + ("∞", Inf), + ("true", True), + ("false", False), + ]; + spellings.into_iter().find_map(|(spelling, constant)| (name == spelling).then_some(constant)) } } @@ -126,7 +138,8 @@ impl fmt::Display for Constant { Phi => "phi", Inf => "inf", I => "i", - G => "G", + True => "true", + False => "false", }) } } @@ -222,6 +235,22 @@ impl<'a> Lexer<'a> { self.input[start_pos..self.pos].parse::().ok() } + /// Consumes identifier continuation characters: alphanumerics, underscores, and a decimal point sandwiched + /// between digits so that base-suffixed function names like `log3.25` lex as a single identifier. + fn consume_identifier_body(&mut self, first: char) -> &'a str { + let start = self.pos; + let mut previous = first; + while let Some(c) = self.peek() { + let dot_between_digits = c == '.' && previous.is_ascii_digit() && self.input[self.pos + 1..].chars().next().is_some_and(|next| next.is_ascii_digit()); + if !(c.is_alphanumeric() || c == '_' || dot_between_digits) { + break; + } + previous = c; + self.bump(); + } + &self.input[start..self.pos] + } + fn skip_ws(&mut self) { self.consume_while(char::is_whitespace); } @@ -261,6 +290,15 @@ impl<'a> Lexer<'a> { '^' => Caret, '≠' => Neq, + // Typeset math symbol aliases + '−' => Minus, + '×' | '⋅' => Star, + '÷' => Slash, + '∧' => AndAnd, + '∨' => OrOr, + // Its own token rather than a `Bang` alias, since `!` is also the postfix factorial and `5¬` is not one + '¬' => Not, + '!' => { if self.peek() == Some('=') { self.bump(); @@ -320,7 +358,7 @@ impl<'a> Lexer<'a> { } _ => { - self.consume_while(|c| c.is_alphanumeric() || c == '_'); + self.consume_identifier_body(ch); let ident = &self.input[start..self.pos]; if ident == "if" { @@ -330,6 +368,7 @@ impl<'a> Lexer<'a> { } else if ch.is_alphanumeric() { Ident(ident) } else { + // Punctuation never begins a name, which leaves `#`, `$`, `~`, and `@` free to become namespace prefixes once a host scope needs them Error } } diff --git a/libraries/math-parser/src/lib.rs b/libraries/math-parser/src/lib.rs index e45c6734dae..a1497bfc9ce 100644 --- a/libraries/math-parser/src/lib.rs +++ b/libraries/math-parser/src/lib.rs @@ -1,5 +1,5 @@ pub mod ast; -mod constants; +pub mod constants; pub mod context; pub mod executer; pub mod lexer; @@ -20,7 +20,7 @@ pub fn evaluate(expression: &str) -> Result, ParseError #[cfg(test)] mod tests { use super::*; - use value::Number; + use value::{Complex, Number}; const EPSILON: f64 = 1e-10_f64; @@ -40,6 +40,14 @@ mod tests { } } + #[test] + fn not_sign_is_prefix_only() { + // `¬` spells only the prefix logical not, so it must not stand in for `!` in its postfix factorial role + for input in ["5¬", "5¬3", "(2 + 3)¬", "3¬¬"] { + assert!(evaluate(input).is_err(), "expected `{input}` to be a parse error"); + } + } + #[test] fn juxtaposed_numbers_fail_to_parse() { // Adjacent number literals like digit-grouped `10 000` must not silently multiply @@ -48,6 +56,42 @@ mod tests { } } + #[test] + fn dot_led_function_suffixes_fail_to_parse() { + // A `.`-led base suffix must stay an error, keeping dot-after-identifier free for possible future accessor syntax (the supported spelling is `log0.5`) + for input in ["log.5(8)", "log.5", "root.5(9)"] { + assert!(evaluate(input).is_err(), "expected `{input}` to be a parse error"); + } + } + + #[test] + fn scientific_function_suffixes_are_not_bases() { + struct ScientificName; + impl context::ValueProvider for ScientificName { + fn get_value(&self, name: &str) -> Option { + (name == "log2e5").then(|| Value::from_f64(10.)) + } + } + + // A base is plain decimal, so `log2e5(8)` reads as the variable `log2e5` times 8, never a base-200000 log + let result = ast::Node::try_parse_from_str("log2e5(8)").unwrap().eval(&EvalContext::new(ScientificName, context::NothingMap)); + assert_eq!(result.unwrap().as_real(), Some(80.)); + } + + #[test] + fn underscore_dot_suffix_stays_implicit_multiplication() { + struct LogUnderscore; + impl context::ValueProvider for LogUnderscore { + fn get_value(&self, name: &str) -> Option { + (name == "log_").then(|| Value::from_f64(10.)) + } + } + + // `log_.5(8)` is not a suffixed function call: it reads as the variable `log_` times 0.5 times 8 + let result = ast::Node::try_parse_from_str("log_.5(8)").unwrap().eval(&EvalContext::new(LogUnderscore, context::NothingMap)); + assert_eq!(result.unwrap().as_real(), Some(40.)); + } + #[test] fn extremely_long_fraction_parses() { let input = format!("0.{}", "1".repeat(320)); @@ -255,18 +299,12 @@ mod tests { logical_not_nonzero: "!5" => 0., logical_not_expression: "!(2 - 2)" => 1., - // Logical helpers as functions - logical_isnan: "isnan(0/0)" => 1., - logical_eq: "eq(2, 2)" => 1., - logical_greater: "greater(3, 2)" => 1., - // Log / exp / pow / root log_ln: "ln(e)" => 1., log_log10: "log(100)" => 2., log_log2: "log2(8)" => 3., log_change_of_base: "log(8, 2)" => 3., exp_function: "exp(1)" => std::f64::consts::E, - pow_real: "pow(2, 3)" => 8., root_square: "root(9, 2)" => 3., root_cube: "root(8, 3)" => 2., @@ -286,11 +324,46 @@ mod tests { // Geometry / mapping extras geometry_hypot: "hypot(3, 4)" => 5., + + // Minimum and maximum accept two or more arguments + mapping_min: "min(3, 7)" => 3., + mapping_max: "max(3, 7)" => 7., + mapping_min_variadic: "min(5, 2, 8, 4)" => 2., + mapping_max_variadic: "max(5, 2, 8, 4)" => 8., + mapping_min_skips_nan: "min(sqrt(-1), 5)" => 5., + mapping_max_skips_nan: "max(sqrt(-1), 5)" => 5., + mapping_min_all_nan: "min(sqrt(-1))" => f64::NAN, + mapping_max_all_nan: "max(sqrt(-1))" => f64::NAN, + + // Typeset math symbol aliases + alias_minus_sign: "5 − 3" => 2., + alias_unary_minus_sign: "−5 + 6" => 1., + alias_multiplication_sign: "3 × 4" => 12., + alias_dot_operator: "3 ⋅ 4" => 12., + alias_division_sign: "8 ÷ 2" => 4., + alias_logical_and: "if(1 ∧ 1, 2, 3)" => 2., + alias_logical_or: "if(0 ∨ 1, 2, 3)" => 2., + alias_logical_not: "¬0" => 1., mapping_remap: "remap(5, 0, 10, 0, 100)" => 50., // GCD / LCM gcd_simple: "gcd(24, 18)" => 6., lcm_simple: "lcm(4, 6)" => 12., + gcd_negative_operand: "gcd(-24, 18)" => 6., + lcm_negative_operand: "lcm(-4, 6)" => 12., + + // Combinatorics over whole numbers + combinatorics_choose: "choose(5, 2)" => 10., + combinatorics_choose_symmetric: "choose(30, 28)" => 435., + combinatorics_choose_beyond_n: "choose(3, 5)" => 0., + combinatorics_pick: "pick(5, 2)" => 20., + combinatorics_pick_all: "pick(4, 4)" => 24., + // A result beyond f64 stops at infinity instead of stepping through quadrillions of terms + combinatorics_choose_overflows: "choose(9007199254740992, 4503599627370496)" => f64::INFINITY, + combinatorics_pick_overflows: "pick(9007199254740992, 9007199254740992)" => f64::INFINITY, + + // Truth values are the numbers 1 and 0 + constant_truth_values: "true + true - false" => 2., // atan2 trig_atan2_axis: "atan2(1, 0)" => std::f64::consts::FRAC_PI_2, @@ -343,5 +416,50 @@ mod tests { // Integer functions reject inputs beyond f64's exact integer range gcd_beyond_exact_integers: "gcd(10000000000000000000, 2)" => f64::NAN, + + // Implicit multiplication with parenthesized and negative-coefficient operands + implicit_multiplication_parenthesized_negative: "2 (-3)" => -6., + implicit_multiplication_glued_parenthesized_negative: "2(-3)" => -6., + implicit_multiplication_negative_coefficient: "-3(2)" => -6., + + // Unary plus + unary_plus: "+5" => 5., + unary_plus_spaced_addition: "1 +2" => 3., + unary_plus_exponent: "2^+3" => 8., + + // Base-suffixed logarithm and root function names + log_suffixed: "log10(100)" => 2., + log_suffixed_underscore: "log_10(100)" => 2., + log_suffixed_fractional_base: "log3.25(5)" => 5f64.ln() / 3.25f64.ln(), + root_suffixed: "root2(9)" => 3., + root_suffixed_underscore: "root_3(8)" => 2., + + // Change of base widens into the complex plane, including through the base-suffixed spellings + log_complex_change_of_base: "log(i, 2)" => Complex::new(0., std::f64::consts::FRAC_PI_2 / std::f64::consts::LN_2), + log_complex_suffixed_base: "log3(i)" => Complex::new(0., std::f64::consts::FRAC_PI_2 / 3f64.ln()), + } + + #[test] + fn sigils_do_not_begin_names() { + // Punctuation never begins a name, so these stay available as future namespace prefixes + for input in ["# + 1", "#foo", "$", "$foo", "~foo * 2", "@foo", "2 ~ 3"] { + assert!(ast::Node::try_parse_from_str(input).is_err(), "expected `{input}` to be a parse error"); + } + } + + #[test] + fn value_accessors_read_reals_only() { + let value = evaluate("2.6").unwrap().unwrap(); + assert_eq!(value.as_f32(), Some(2.6_f32)); + assert_eq!(value.as_u8(), Some(3)); + assert_eq!(value.as_i32(), Some(3)); + + let negative = evaluate("-2.6").unwrap().unwrap(); + assert_eq!(negative.as_i8(), Some(-3)); + assert_eq!(negative.as_u8(), None); + + assert_eq!(evaluate("300").unwrap().unwrap().as_u8(), None); + assert_eq!(evaluate("i").unwrap().unwrap().as_i64(), None); + assert_eq!(evaluate("inf").unwrap().unwrap().as_u64(), None); } } diff --git a/libraries/math-parser/src/parser.rs b/libraries/math-parser/src/parser.rs index 0fc64bd8868..825e2742b80 100644 --- a/libraries/math-parser/src/parser.rs +++ b/libraries/math-parser/src/parser.rs @@ -76,7 +76,12 @@ where let add_op = choice((just(Token::Plus).to(BinaryOp::Add), just(Token::Minus).to(BinaryOp::Sub))); let mul_op = choice((just(Token::Star).to(BinaryOp::Mul), just(Token::Slash).to(BinaryOp::Div), just(Token::Modulo).to(BinaryOp::Modulo))); let pow_op = just(Token::Caret).to(BinaryOp::Pow); - let unary_op = choice((just(Token::Minus).to(UnaryOp::Neg), just(Token::Bang).to(UnaryOp::Not))); + let unary_op = choice(( + just(Token::Minus).to(UnaryOp::Neg), + just(Token::Plus).to(UnaryOp::Pos), + just(Token::Bang).to(UnaryOp::Not), + just(Token::Not).to(UnaryOp::Not), + )); let and_op = just(Token::AndAnd).to(BinaryOp::And); let or_op = just(Token::OrOr).to(BinaryOp::Or); let cmp_op = choice(( @@ -188,7 +193,7 @@ mod tests { op: BinaryOp::Pow, rhs: Box::new(Node::Lit(Literal::Float(3.))), }, - test_parse_unary_sqrt: "sqrt(16)" => Node::FnCall { + test_parse_sqrt_call: "sqrt(16)" => Node::FnCall { name: "sqrt".to_string(), expr: vec![Node::Lit(Literal::Float(16.))], }, diff --git a/libraries/math-parser/src/value.rs b/libraries/math-parser/src/value.rs index 39f67508010..f250b26e794 100644 --- a/libraries/math-parser/src/value.rs +++ b/libraries/math-parser/src/value.rs @@ -7,6 +7,20 @@ pub enum Value { Number(Number), } +/// Generates accessors reading the value rounded to the nearest whole number of the target integer type. +macro_rules! integer_accessors { + ($($fn_name:ident: $int:ty),* $(,)?) => { + $( + #[doc = concat!("Reads the value rounded to the nearest whole `", stringify!($int), "`, or `None` if it isn't a real number, isn't finite, or lies outside the type's range.")] + pub fn $fn_name(&self) -> Option<$int> { + let rounded = self.as_real()?.round(); + // The MAX comparison is one float rounding step generous for the widest types, where the cast saturates + (rounded.is_finite() && rounded >= <$int>::MIN as f64 && rounded <= <$int>::MAX as f64).then_some(rounded as $int) + } + )* + }; +} + impl Value { pub fn from_f64(x: f64) -> Self { Self::Number(Number::Real(x)) @@ -18,6 +32,24 @@ impl Value { _ => None, } } + + /// Reads the value as a single-precision float, or `None` if it isn't a real number. + pub fn as_f32(&self) -> Option { + self.as_real().map(|real| real as f32) + } + + integer_accessors! { + as_u8: u8, + as_u16: u16, + as_u32: u32, + as_u64: u64, + as_u128: u128, + as_i8: i8, + as_i16: i16, + as_i32: i32, + as_i64: i64, + as_i128: i128, + } } impl From for Value { @@ -26,6 +58,12 @@ impl From for Value { } } +impl From for Value { + fn from(complex: Complex) -> Self { + Self::Number(Number::Complex(complex)) + } +} + impl core::fmt::Display for Value { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -50,6 +88,14 @@ impl std::fmt::Display for Number { } impl Number { + /// Widens the number into the complex plane, since every real number is a complex number without an imaginary part. + pub fn as_complex(self) -> Complex { + match self { + Number::Real(real) => Complex::new(real, 0.), + Number::Complex(complex) => complex, + } + } + /// The value's truthiness for conditions and logic operators, or `None` for NaN values, which poison the result rather than acting as a boolean. pub fn as_bool(self) -> Option { match self { @@ -152,8 +198,8 @@ impl Number { match self { Number::Real(real) => match op { + UnaryOp::Pos => Number::Real(real), UnaryOp::Neg => Number::Real(-real), - UnaryOp::Sqrt => Number::Real(real.sqrt()), UnaryOp::Fac => { // n! for real n: use integer semantics when n is a // non-negative integer, otherwise return NaN. @@ -180,8 +226,8 @@ impl Number { }, Number::Complex(complex) => match op { + UnaryOp::Pos => Number::Complex(complex), UnaryOp::Neg => Number::Complex(-complex), - UnaryOp::Sqrt => Number::Complex(complex.sqrt()), UnaryOp::Fac => Number::Complex(Complex::new(f64::NAN, f64::NAN)), UnaryOp::Not => unreachable!("handled above"), }, diff --git a/libraries/rawkit/src/postprocessing/gamma_correction.rs b/libraries/rawkit/src/postprocessing/gamma_correction.rs index 86f5d672528..7643a16dd9d 100644 --- a/libraries/rawkit/src/postprocessing/gamma_correction.rs +++ b/libraries/rawkit/src/postprocessing/gamma_correction.rs @@ -24,7 +24,7 @@ impl Image { } } -/// `max_intensity` must be non-zero. +/// `max_intensity` must be nonzero. fn generate_gamma_curve(power: f64, threshold: f64, max_intensity: f64) -> Vec { debug_assert!(max_intensity != 0.); diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index e17a68c241c..759353bacc0 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1455,7 +1455,7 @@ fn render_vector_shape_svg(item: ItemRef<'_, Vector>, vector: &Vector, render: & stroke_below: wants_stroke_below, } = appearance.map(Appearance::fill_and_stroke).unwrap_or_default(); - // Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform + // Only consider strokes with nonzero weight, since default strokes with zero weight would prevent assigning the correct stroke transform let has_real_stroke = stroke_params.as_ref().filter(|stroke| stroke.weight() > 0.); // A cascaded coverage records its stroke space in the ancestor's coordinates, so this item authors its own let set_stroke_transform = has_real_stroke @@ -2207,7 +2207,7 @@ impl Render for List { } } -/// Build one multi-contour `Path` (non-zero fill rule, so holes like the inside of an "O" work +/// Build one multi-contour `Path` (nonzero fill rule, so holes like the inside of an "O" work /// correctly) plus one `FreePoint` per disconnected anchor, apply the transform, and append. fn extend_targets_from_vector(targets: &mut Vec, appearance: Option<&Appearance>, geometry: &Vector, transform: DAffine2) { // A coverage whose paint is `Graphic::None` exists but paints nothing, so it does not close subpaths for hit testing diff --git a/node-graph/libraries/vector-types/src/vector/click_target.rs b/node-graph/libraries/vector-types/src/vector/click_target.rs index ddb1d8b1579..a561eade2c1 100644 --- a/node-graph/libraries/vector-types/src/vector/click_target.rs +++ b/node-graph/libraries/vector-types/src/vector/click_target.rs @@ -59,7 +59,7 @@ impl FreePoint { #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum ClickTargetType { - /// One or more contours tested as one compound shape using the non-zero fill rule, so holes + /// One or more contours tested as one compound shape using the nonzero fill rule, so holes /// (e.g. the inside of an "O") correctly count as outside the fill. Path(BezPath), FreePoint(FreePoint), @@ -276,7 +276,7 @@ impl ClickTarget { return true; } - // Selection point inside the fill (non-zero rule). + // Selection point inside the fill (nonzero rule). // Only closed contours contribute to the fill region; open segments would otherwise produce spurious winding on one side of the segment. let fill_region = closed_contours(path); if !fill_region.is_empty() && bezier_iter().next().is_some_and(|segment| fill_region.contains(segment.start())) { diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 4c3b65917e3..671ce806bf1 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -980,7 +980,7 @@ where /// The greatest common divisor (GCD) calculates the largest positive integer that divides both of the two input numbers without leaving a remainder. #[node_macro::node(category("Math: Numeric"))] -fn greatest_common_divisor + std::ops::SubAssign>( +fn greatest_common_divisor( _: impl Ctx, /// One of the two numbers for which the GCD is calculated. #[implementations(u32, u64, i32)] @@ -992,20 +992,15 @@ fn greatest_common_divisor( +fn least_common_multiple( _: impl Ctx, /// One of the two numbers for which the LCM is calculated. #[implementations(u32, u64, i32)] @@ -1015,48 +1010,17 @@ fn least_common_multiple, ) -> Item { let (value, attributes) = value.into_parts(); + let other_value = *other_value.element(); - let value = value.to_i128().unwrap(); - let other_value = other_value.element().to_i128().unwrap(); + let lcm = math_parser::constants::lcm(integer_magnitude(value), integer_magnitude(other_value)); - if value == 0 || other_value == 0 { - return Item::from_parts(T::zero(), attributes); - } - let gcd = binary_gcd(value, other_value); - - Item::from_parts(T::from_i128((value * other_value).abs() / gcd).unwrap(), attributes) + // A result too large for the output type saturates at the type's maximum rather than overflowing + Item::from_parts(T::from(lcm).unwrap_or_else(T::max_value), attributes) } -fn binary_gcd + std::ops::SubAssign>(mut a: T, mut b: T) -> T { - if a == T::zero() { - return b; - } - if b == T::zero() { - return a; - } - - let mut shift = 0; - while (a | b) & T::one() == T::zero() { - a >>= 1; - b >>= 1; - shift += 1; - } - - while a & T::one() == T::zero() { - a >>= 1; - } - - while b != T::zero() { - while b & T::one() == T::zero() { - b >>= 1; - } - if a > b { - std::mem::swap(&mut a, &mut b); - } - b -= a; - } - - a << shift +/// Reads an integer's magnitude as a `u128`, which every implemented input type fits within. +fn integer_magnitude(value: T) -> u128 { + value.to_i128().map_or(0, i128::unsigned_abs) } /// Adds together all the numbers in the input list, producing their total. diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 912ba87b254..ca9f2d13166 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -1098,7 +1098,7 @@ async fn auto_tangents( #[range] #[soft(0..1)] spread: Item, - /// If active, existing non-zero handles won't be affected. + /// If active, existing nonzero handles won't be affected. #[default(true)] preserve_existing: Item, ) -> Item { @@ -2935,7 +2935,7 @@ async fn morph( // the item transform (which will be group_transform * lerped_transform after the // pipeline's Transform node runs), the lerped_transform cancels out and children // get the correct footprint: parent * group_transform * child_transform. - // Only pre-compensate if the lerped transform is invertible (non-zero determinant). + // Only pre-compensate if the lerped transform is invertible (nonzero determinant). // A zero determinant can occur when interpolated scale passes through zero (e.g., flipped axes), // in which case we skip pre-compensation to avoid propagating NaN through merged_layers transforms. if lerped_transform.matrix2.determinant().abs() > f64::EPSILON { @@ -3746,7 +3746,7 @@ mod test { assert_eq!(&manipulator_groups_anchors[..4], &[DVec2::NEG_ONE, DVec2::new(1., -1.), DVec2::ONE, DVec2::new(-1., 1.),]); - // Test a rectangular path with non-zero rotation + // Test a rectangular path with nonzero rotation let square = Vector::from_bezpath(Rect::new(-1., -1., 1., 1.).to_path(DEFAULT_ACCURACY)); let mut square = List::new_from_element(square); square.with_attribute_mut_or_default(ATTR_TRANSFORM, 0, |t: &mut DAffine2| *t *= DAffine2::from_angle(std::f64::consts::FRAC_PI_4));