Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use graph_craft::document::NodeId;
use graphene_std::animation::RealTimeMode;
use graphene_std::blending::BlendMode;
use graphene_std::color::SRGBA8;
use graphene_std::core_types::misc::format_f64;
use graphene_std::extract_xy::XY;
use graphene_std::gradient::Gradient;
use graphene_std::list::{Item, List, NodeIdPath};
Expand Down Expand Up @@ -859,20 +860,32 @@ macro_rules! impl_table_item_layout_for_number {
}
}
impl_table_item_layout_for_number!(
f32 => "Number (f32)",
u32 => "Number (u32)",
i32 => "Number (i32)",
u64 => "Number (u64)",
i64 => "Number (i64)",
);

impl TableItemLayout for f32 {
fn type_name() -> &'static str {
"Number (f32)"
}
fn identifier(&self) -> String {
// Only infinity widens to f64 without gaining digits, so finite values keep the f32 spelling
if self.is_infinite() { format_f64(*self as f64) } else { format!("{self}") }
}
fn value_widgets(&self, _target: PathStep, _data: &LayoutData) -> Vec<WidgetInstance> {
vec![TextLabel::new(self.identifier()).selectable(true).narrow(true).widget_instance()]
}
}

// Denoised so 0.1 + 0.2 reads as 0.3 rather than 0.30000000000000004. We don't do this for f32 because it lacks precision to reliably distinguish between intentional digits and noise.
impl TableItemLayout for f64 {
fn type_name() -> &'static str {
"Number"
}
fn identifier(&self) -> String {
format!("{}", round_away_float_noise(*self))
format_f64(round_away_float_noise(*self))
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
fn value_widgets(&self, _target: PathStep, _data: &LayoutData) -> Vec<WidgetInstance> {
vec![TextLabel::new(self.identifier()).selectable(true).narrow(true).widget_instance()]
Expand Down Expand Up @@ -964,7 +977,7 @@ impl TableItemLayout for Option<f64> {
}
fn value_widgets(&self, _target: PathStep, _data: &LayoutData) -> Vec<WidgetInstance> {
let text = match self {
Some(value) => format!("Some({})", round_away_float_noise(*value)),
Some(value) => format!("Some({})", format_f64(round_away_float_noise(*value))),
None => "None".to_string(),
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -844,9 +844,8 @@ pub fn array_of_number_widget(parameter_widgets_info: ParameterWidgetsInfo, text
string
.split(&[',', ' '])
.filter(|x| !x.is_empty())
.map(str::parse::<f64>)
.collect::<Result<Vec<_>, _>>()
.ok()
.map(graphene_std::core_types::misc::parse_f64)
.collect::<Option<Vec<_>>>()
.map(TaggedValue::F64Array)
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use crate::messages::tool::utility_types::*;
use glam::{DAffine2, DMat2, DVec2};
use graph_craft::document::NodeInput;
use graph_craft::document::value::TaggedValue;
use graphene_std::core_types::misc::format_f64;
use graphene_std::math::float_noise::round_away_float_noise;
use graphene_std::vector::algorithms::shapes::{arc_bezpath, regular_polygon_bezpath, star_polygon_bezpath};
use graphene_std::vector::click_target::ClickTargetType;
Expand Down Expand Up @@ -558,6 +559,10 @@ pub fn wrap_to_tau(angle: f64) -> f64 {
}

pub fn format_rounded(value: f64, precision: usize) -> String {
if value.is_infinite() {
return format_f64(value);
}

// Denoised values within floating point noise of zero (including -0) display as unsigned zero, unless the precision is fine enough to display them
let value = round_away_float_noise(value);
let value = if value.abs() < f64::min(1e-12, 0.5 * 10_f64.powi(-(precision as i32))) { 0. } else { value };
Expand Down Expand Up @@ -638,6 +643,12 @@ mod tests {
assert_eq!(format_rounded(45.00000000000001, 2), "45");
}

#[test]
fn format_rounded_spells_infinity_as_the_symbol() {
assert_eq!(format_rounded(f64::INFINITY, 2), "∞");
assert_eq!(format_rounded(f64::NEG_INFINITY, 2), "-∞");
}

#[test]
fn format_rounded_shows_true_and_noise_zeros_plainly() {
assert_eq!(format_rounded(0., 2), "0");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use crate::messages::tool::common_functionality::color_selector::{DrawingToolSta
use crate::messages::tool::common_functionality::graph_modification_utils;
use graph_craft::document::value::TaggedValue;
use graphene_std::choice_type::ChoiceTypeStatic;
use graphene_std::core_types::misc::parse_f64;
use graphene_std::vector::style::{PaintOrder, StrokeAlign, StrokeCap, StrokeJoin};

/// All non-color stroke-related options surfaced in the control bar popover.
Expand Down Expand Up @@ -151,7 +152,7 @@ where
.tooltip_label("Dash Pattern")
.tooltip_description("Comma-separated dash and gap lengths.")
.on_update(move |input: &TextInput| {
let parsed = input.value.split(&[',', ' ']).filter(|piece| !piece.is_empty()).map(str::parse::<f64>).collect::<Result<Vec<_>, _>>();
let parsed = input.value.split(&[',', ' ']).filter(|piece| !piece.is_empty()).map(parse_f64).collect::<Option<Vec<_>>>();
parsed.map_or(Message::NoOp, |lengths| to_message(StrokeOptionsUpdate::DashLengths(lengths)))
})
.on_commit(|_| DocumentMessage::StartTransaction.into())
Expand Down
14 changes: 10 additions & 4 deletions frontend/src/components/widgets/inputs/NumberInput.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,13 @@
return `${sign}${unitlessDisplayValue.toFixed(decimalPlaces)}${unPluralize(unit, displayValue)}`;
}

return `${unitlessDisplayValue}${unPluralize(unit, displayValue)}`;
return `${numberText(unitlessDisplayValue)}${unPluralize(unit, displayValue)}`;
}

// Infinity is written as the math parser reads it, so a field showing it can be edited and committed unchanged
function numberText(number: number): string {
if (Math.abs(number) === Infinity) return number < 0 ? "-∞" : "∞";
return `${number}`;
}

// Removes the trailing "s" from a unit if the quantity is 1.
Expand All @@ -242,11 +248,11 @@

function onTextFocused() {
// The number shown when editing the field, with floating point imprecision noise removed
const noFloatingImprecisionValue = value === undefined ? undefined : roundAwayFloatNoise(value);
const noFloatingImprecisionText = value === undefined ? undefined : numberText(roundAwayFloatNoise(value));

if (value === undefined) text = "";
else if (unitIsHiddenWhenEditing) text = `${noFloatingImprecisionValue}`;
else text = `${noFloatingImprecisionValue}${unPluralize(unit, value)}`;
else if (unitIsHiddenWhenEditing) text = `${noFloatingImprecisionText}`;
else text = `${noFloatingImprecisionText}${unPluralize(unit, value)}`;

editing = true;

Expand Down
1 change: 1 addition & 0 deletions libraries/math-parser/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ publish.workspace = true
[dependencies]
thiserror = "2.0"
num-complex = "0.4"
unicode-ident = "1.0"
chumsky = { version = "0.10", default-features = false, features = ["std"] }

[dev-dependencies]
Expand Down
20 changes: 14 additions & 6 deletions libraries/math-parser/src/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,10 @@ impl<'a> Lexer<'a> {
preceding = rest.trim_end();
}

preceding.chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '.' || c == ')' || c == '∞')
preceding
.chars()
.next_back()
.is_some_and(|c| c.is_alphanumeric() || unicode_ident::is_xid_continue(c) || c == '.' || c == ')' || c == '∞')
}

fn lex_number(&mut self) -> Option<f64> {
Expand Down Expand Up @@ -235,14 +238,17 @@ impl<'a> Lexer<'a> {
self.input[start_pos..self.pos].parse::<f64>().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.
/// Consumes identifier continuation characters: Unicode's `XID_Continue`, which covers letters, digits,
/// underscores, and the combining marks that complete a cluster like a decomposed `é`, plus 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) {
// The middle dot and its Greek twin are identifier characters in Unicode, but they would pass for the `⋅` operator mid-name
let middle_dot = matches!(c as u32, 0xB7 | 0x387);
if !((unicode_ident::is_xid_continue(c) && !middle_dot) || dot_between_digits) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
break;
}
previous = c;
Expand Down Expand Up @@ -365,10 +371,12 @@ impl<'a> Lexer<'a> {
If
} else if let Some(lit) = Constant::from_name(ident) {
Const(lit)
} else if ch.is_alphanumeric() {
} else if unicode_ident::is_xid_start(ch) {
// A name is a Unicode identifier, as in Rust, so any script's letters may spell one
Ident(ident)
} else {
// Punctuation never begins a name, which leaves `#`, `$`, `~`, and `@` free to become namespace prefixes once a host scope needs them
// Digits, combining marks, invisible formatting characters, and symbols never begin a name, which also
// leaves `#`, `$`, `~`, and `@` free to become namespace prefixes once a host scope needs them
Error
}
}
Expand Down
45 changes: 30 additions & 15 deletions libraries/math-parser/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ 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)"] {
for input in ["log.5(8)", "log.5", "root.5(9)", "log_.5(8)"] {
assert!(evaluate(input).is_err(), "expected `{input}` to be a parse error");
}
}
Expand All @@ -78,20 +78,6 @@ mod tests {
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<Value> {
(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));
Expand Down Expand Up @@ -439,6 +425,35 @@ mod tests {
log_complex_suffixed_base: "log3(i)" => Complex::new(0., std::f64::consts::FRAC_PI_2 / 3f64.ln()),
}

#[test]
fn names_are_unicode_identifiers() {
// Any script's letters begin a name, and combining marks extend one, so a decomposed `é` is a single name
let decomposed_e_acute = format!("e{}", char::from_u32(0x301).unwrap());
for input in ["λ + 1", "あ", "א", "x_2", decomposed_e_acute.as_str()] {
assert!(ast::Node::try_parse_from_str(input).is_ok(), "expected `{input}` to parse");
}

// Symbols, emoji, digits of any script, lone marks, and invisible formatting characters cannot begin one,
// and neither can an underscore, whose leading position Rust allows by a special case that we do not
let lone_acute_mark = char::from_u32(0x301).unwrap().to_string();
let right_to_left_override = format!("{}foo", char::from_u32(0x202E).unwrap());
let flag = format!("{}{}", char::from_u32(0x1F1FA).unwrap(), char::from_u32(0x1F1F8).unwrap());
for input in ["👍", "2👍", "٣", "²", "_foo", lone_acute_mark.as_str(), right_to_left_override.as_str(), flag.as_str()] {
assert!(ast::Node::try_parse_from_str(input).is_err(), "expected `{input}` to be a parse error");
}

// Neither middle dot continues a name, so `a·b` is an error rather than one variable of that name
for middle_dot in [0xB7, 0x387] {
let input = format!("a{}b", char::from_u32(middle_dot).unwrap());
assert!(ast::Node::try_parse_from_str(&input).is_err(), "expected `{input}` to be a parse error");
}

// A name ending in a combining mark is still an operand, so a spaced number after it doesn't silently multiply
for input in ["x 2".to_string(), format!("{decomposed_e_acute} 2")] {
assert!(ast::Node::try_parse_from_str(&input).is_err(), "expected `{input}` to be a parse error");
}
}

#[test]
fn sigils_do_not_begin_names() {
// Punctuation never begins a name, so these stay available as future namespace prefixes
Expand Down
50 changes: 49 additions & 1 deletion node-graph/libraries/core-types/src/misc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,30 @@ pub fn migrate_to_f64_array<'de, D: serde::Deserializer<'de>>(deserializer: D) -
})
}

/// Parses a number from text, reading infinity as `∞` (optionally signed) as well as the `inf` and `infinity` spellings.
pub fn parse_f64(text: &str) -> Option<f64> {
let (negative, unsigned) = match text.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, text.strip_prefix('+').unwrap_or(text)),
};
if unsigned == "∞" {
return Some(if negative { f64::NEG_INFINITY } else { f64::INFINITY });
}

text.parse().ok()
}

/// Writes a number as text, spelling infinity `∞` as the math expression language does rather than Rust's `inf`.
pub fn format_f64(value: f64) -> String {
if value.is_infinite() {
return if value < 0. { "-∞" } else { "∞" }.to_string();
}
value.to_string()
}

/// Parses a comma or space separated list of numbers, skipping any pieces that fail to parse.
pub fn parse_f64_list(text: &str) -> Vec<f64> {
text.split([',', ' ']).filter(|piece| !piece.is_empty()).filter_map(|piece| piece.parse::<f64>().ok()).collect()
text.split([',', ' ']).filter(|piece| !piece.is_empty()).filter_map(parse_f64).collect()
}

/// Parse a CSS color string (named color, hex, `rgb(...)`, `hsl(...)`, etc.) into a linear-light [`Color`] using the `color` crate's CSS Color 4 parser.
Expand Down Expand Up @@ -162,3 +183,30 @@ pub fn parse_css_color(input: &str) -> Option<crate::Color> {
let in_gamut = alpha <= 1. && ![red, green, blue, alpha].iter().any(|c| c.is_sign_negative() || !c.is_finite());
in_gamut.then(|| crate::Color::from_gamma_srgb_channels(red, green, blue, alpha))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parse_f64_reads_every_infinity_spelling() {
for text in ["∞", "+∞", "inf", "infinity"] {
assert_eq!(parse_f64(text), Some(f64::INFINITY), "`{text}`");
}
for text in ["-∞", "-inf", "-infinity"] {
assert_eq!(parse_f64(text), Some(f64::NEG_INFINITY), "`{text}`");
}
assert_eq!(parse_f64("-2.5"), Some(-2.5));
assert_eq!(parse_f64("∞∞"), None);
}

#[test]
fn format_f64_spells_infinity_as_the_symbol_and_round_trips() {
assert_eq!(format_f64(f64::INFINITY), "∞");
assert_eq!(format_f64(f64::NEG_INFINITY), "-∞");
assert_eq!(format_f64(2.5), "2.5");
for value in [f64::INFINITY, f64::NEG_INFINITY, 2.5, -0.1] {
assert_eq!(parse_f64(&format_f64(value)), Some(value));
}
}
}
12 changes: 8 additions & 4 deletions node-graph/nodes/text/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use convert_case::{Boundary, Converter, pattern};
use core_types::graphene_hash::CacheHash;
use core_types::list::{Item, List};
use core_types::math::float_noise::round_away_float_noise;
use core_types::misc::{format_f64, parse_f64};
use core_types::registry::types::{SeedValue, SignedInteger, TextArea};
use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, ExtractVarArgs, OwnedContextImpl};
use dyn_any::DynAny;
Expand Down Expand Up @@ -364,6 +365,9 @@ fn format_number(
) -> Item<String> {
let (number, attributes) = number.into_parts();
let number = round_away_float_noise(number);
if number.is_infinite() {
return Item::from_parts(format_f64(number), attributes);
}
let (decimal_places, fixed_decimals, use_thousands_separator, start_at_10000) =
(*decimal_places.element(), *fixed_decimals.element(), *use_thousands_separator.element(), *start_at_10000.element());
let decimal_separator = decimal_separator.element().clone();
Expand Down Expand Up @@ -447,14 +451,14 @@ fn format_number(
#[node_macro::node(category("Text"), name("String to Number"))]
fn string_to_number(
_: impl Ctx,
/// The string containing a number. Surrounding whitespace is ignored, a decimal point (.) may be included, sign prefixes (+/-) are respected, and scientific notation (e.g. "1e-3") is supported.
/// The string containing a number. Surrounding whitespace is ignored, a decimal point (.) may be included, sign prefixes (+/-) are respected, scientific notation (e.g. "1e-3") is supported, and infinity may be written "inf", "infinity", or "∞".
string: Item<String>,
/// The value of the result if the string cannot be parsed as a valid number.
fallback: Item<f64>,
) -> Item<f64> {
let (string, attributes) = string.into_parts();

Item::from_parts(string.trim().parse::<f64>().unwrap_or(*fallback.element()), attributes)
Item::from_parts(parse_f64(string.trim()).unwrap_or(*fallback.element()), attributes)
}

/// Parses a string like `"3, 4.5"` into a Vec2, using a comma and/or whitespace as separators. Falls back to the chosen value if the string is not a valid pair of numbers.
Expand All @@ -474,9 +478,9 @@ fn string_to_vec2(
.unwrap_or(trimmed);

// Exactly two numbers, so a longer list is not quietly truncated into a pair
let mut numbers = unwrapped.split(|c: char| c == ',' || c.is_whitespace()).filter(|piece| !piece.is_empty()).map(str::parse::<f64>);
let mut numbers = unwrapped.split(|c: char| c == ',' || c.is_whitespace()).filter(|piece| !piece.is_empty()).map(parse_f64);
let parsed = match (numbers.next(), numbers.next(), numbers.next()) {
(Some(Ok(x)), Some(Ok(y)), None) => DVec2::new(x, y),
(Some(Some(x)), Some(Some(y)), None) => DVec2::new(x, y),
_ => *fallback.element(),
};

Expand Down
Loading