diff --git a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs index b32603a925..618c90cc6d 100644 --- a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs @@ -233,6 +233,24 @@ pub struct ColorInput { pub on_commit: WidgetCallback<()>, } +/// Shortens a breadcrumb label to fit the trail, replacing the tail with an ellipsis. +/// Quotes delimit a string value, so a quoted label budgets only the text between them. +pub fn truncate_breadcrumb_label(label: &str) -> String { + const MAX_CHARACTERS: usize = 40; + + let quoted = label.len() >= 2 && label.starts_with('"') && label.ends_with('"'); + let content = if quoted { &label[1..label.len() - 1] } else { label }; + + if content.chars().count() <= MAX_CHARACTERS { + return label.to_string(); + } + + let mut truncated: String = content.chars().take(MAX_CHARACTERS - 1).collect(); + truncated.push('…'); + + if quoted { format!("\"{truncated}\"") } else { truncated } +} + #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Clone, serde::Serialize, serde::Deserialize, Derivative, Default, WidgetBuilder)] #[derivative(Debug, PartialEq)] diff --git a/editor/src/messages/layout/utility_types/widgets/label_widgets.rs b/editor/src/messages/layout/utility_types/widgets/label_widgets.rs index a1975b9793..871dd9752e 100644 --- a/editor/src/messages/layout/utility_types/widgets/label_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/label_widgets.rs @@ -64,6 +64,8 @@ pub struct TextLabel { pub italic: bool, pub monospace: bool, pub multiline: bool, + pub enquote: bool, + pub selectable: bool, #[serde(rename = "centerAlign")] pub center_align: bool, #[serde(rename = "tableAlign")] diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index 9220ef5028..2b03b15d73 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -13,6 +13,7 @@ use graphene_std::color::SRGBA8; use graphene_std::extract_xy::XY; use graphene_std::gradient::Gradient; use graphene_std::list::{Item, List, NodeIdPath}; +use graphene_std::math::float_noise::round_away_float_noise; use graphene_std::memo::IORecord; use graphene_std::raster::{ CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, @@ -150,7 +151,8 @@ impl DataPanelMessageHandler { // Element path breadcrumbs if !layout_data.breadcrumbs.is_empty() { - let breadcrumb = BreadcrumbTrailButtons::new(layout_data.breadcrumbs) + let labels = layout_data.breadcrumbs.iter().map(|label| truncate_breadcrumb_label(label)).collect(); + let breadcrumb = BreadcrumbTrailButtons::new(labels) .on_update(|&len| DataPanelMessage::TruncateElementPath { len: len as usize }.into()) .widget_instance(); widgets.push(breadcrumb); @@ -796,108 +798,53 @@ impl TableItemLayout for Gradient { } } -impl TableItemLayout for f64 { - fn type_name() -> &'static str { - "Number (f64)" - } - fn identifier(&self) -> String { - format!("{self}") - } - // Values fall back to the default drill-in button (labeled via `identifier`); the value page shows the rich `NumberInput`. - fn value_page(&self, _data: &mut LayoutData) -> Vec { - vec![LayoutGroup::row(vec![ - NumberInput::new(Some(*self)).disabled(true).max_width(220).display_decimal_places(20).widget_instance(), - ])] - } -} - -impl TableItemLayout for u8 { - fn type_name() -> &'static str { - "Byte" - } - fn identifier(&self) -> String { - format!("{self:02X}") - } - // Values fall back to the default drill-in button (labeled with the hex string via `identifier`); the value page shows the same hex value as a label. - fn value_page(&self, _data: &mut LayoutData) -> Vec { - vec![LayoutGroup::row(vec![TextLabel::new(self.identifier()).widget_instance()])] - } -} - -impl TableItemLayout for f32 { - fn type_name() -> &'static str { - "Number (f32)" - } - fn identifier(&self) -> String { - format!("{self}") - } - // Values fall back to the default drill-in button (labeled via `identifier`); the value page shows the rich `NumberInput`. - fn value_page(&self, _data: &mut LayoutData) -> Vec { - vec![LayoutGroup::row(vec![ - NumberInput::new(Some(*self as f64)).disabled(true).max_width(220).display_decimal_places(20).widget_instance(), - ])] - } -} - -impl TableItemLayout for u32 { - fn type_name() -> &'static str { - "Number (u32)" - } - fn identifier(&self) -> String { - format!("{self}") - } - // Values fall back to the default drill-in button (labeled via `identifier`); the value page shows the rich `NumberInput`. - fn value_page(&self, _data: &mut LayoutData) -> Vec { - vec![LayoutGroup::row(vec![ - NumberInput::new(Some(*self as f64)).disabled(true).max_width(220).display_decimal_places(20).widget_instance(), - ])] - } -} - -impl TableItemLayout for i32 { - fn type_name() -> &'static str { - "Number (i32)" - } - fn identifier(&self) -> String { - format!("{self}") - } - // Values fall back to the default drill-in button (labeled via `identifier`); the value page shows the rich `NumberInput`. - fn value_page(&self, _data: &mut LayoutData) -> Vec { - vec![LayoutGroup::row(vec![ - NumberInput::new(Some(*self as f64)).disabled(true).max_width(220).display_decimal_places(20).widget_instance(), - ])] +macro_rules! impl_table_item_layout_for_number { + ($($ty:ty => $type_name:literal),* $(,)?) => { + $( + impl TableItemLayout for $ty { + fn type_name() -> &'static str { + $type_name + } + fn identifier(&self) -> String { + format!("{self}") + } + fn value_widgets(&self, _target: PathStep, _data: &LayoutData) -> Vec { + vec![TextLabel::new(self.identifier()).selectable(true).narrow(true).widget_instance()] + } + } + )* } } +impl_table_item_layout_for_number!( + f32 => "Number (f32)", + u32 => "Number (u32)", + i32 => "Number (i32)", + u64 => "Number (u64)", + i64 => "Number (i64)", +); -impl TableItemLayout for i64 { +// 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 (i64)" + "Number" } fn identifier(&self) -> String { - format!("{self}") + format!("{}", round_away_float_noise(*self)) } - // Values fall back to the default drill-in button (labeled via `identifier`); the value page shows the rich `NumberInput`. - // TODO: Make this robust for large i64 values that don't fit in f64 (beyond roughly 2^53), as with u64. - fn value_page(&self, _data: &mut LayoutData) -> Vec { - vec![LayoutGroup::row(vec![ - NumberInput::new(Some(*self as f64)).disabled(true).max_width(220).display_decimal_places(20).widget_instance(), - ])] + fn value_widgets(&self, _target: PathStep, _data: &LayoutData) -> Vec { + vec![TextLabel::new(self.identifier()).selectable(true).narrow(true).widget_instance()] } } -impl TableItemLayout for u64 { +impl TableItemLayout for u8 { fn type_name() -> &'static str { - "Number (u64)" + "Byte" } fn identifier(&self) -> String { - format!("{self}") + format!("0x{self:02X} ({self})") } - // Values fall back to the default drill-in button (labeled via `identifier`); the value page shows the rich `NumberInput`. - // TODO: Make this robust for large u64 values that don't fit in f64 (above roughly 2^53). Perhaps using a bigint kind of approach through the widget's data flow. - fn value_page(&self, _data: &mut LayoutData) -> Vec { - vec![LayoutGroup::row(vec![ - NumberInput::new(Some(*self as f64)).disabled(true).max_width(220).display_decimal_places(20).widget_instance(), - ])] + fn value_widgets(&self, _target: PathStep, _data: &LayoutData) -> Vec { + vec![TextLabel::new(self.identifier()).selectable(true).narrow(true).widget_instance()] } } @@ -921,20 +868,50 @@ impl TableItemLayout for String { "String" } fn identifier(&self) -> String { - // Show the first line, and if there are more, indicate that with an ellipsis - let first_line = self.lines().next().unwrap_or(""); - if self.lines().count() > 1 { - format!("\"{} …\"", first_line) - } else { - format!("\"{}\"", first_line) - } + format!("\"{}\"", string_preview(self)) + } + fn layout_with_breadcrumb(&self, data: &mut LayoutData) -> Vec { + data.breadcrumbs.push(character_count_label(self)); + self.value_page(data) + } + // The preview truncates for length and line breaks, so a button beside it reaches the full text, labeled by length + fn value_widgets(&self, target: PathStep, _data: &LayoutData) -> Vec { + vec![ + TextLabel::new(string_preview(self)).enquote(true).selectable(true).monospace(true).narrow(true).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + TextButton::new(character_count_label(self)) + .on_update(move |_| DataPanelMessage::PushToElementPath { step: target.clone() }.into()) + .narrow(true) + .widget_instance(), + ] } - // Values fall back to the default drill-in button (labeled with the truncated quoted preview via `identifier`); the value page shows the full multi-line text in a `TextAreaInput`. fn value_page(&self, _data: &mut LayoutData) -> Vec { vec![LayoutGroup::row(vec![TextAreaInput::new(self.to_string()).monospace(true).disabled(true).widget_instance()])] } } +fn character_count_label(value: &str) -> String { + let character_count = value.chars().count(); + + format!("{character_count} Char{}", if character_count == 1 { "" } else { "s" }) +} + +/// Shortens a string to fit a table cell, cutting at the first line break or 40 characters with an ellipsis. +/// The 40 matches `truncate_breadcrumb_label`, so a preview reaching the trail isn't cut twice. +fn string_preview(value: &str) -> String { + const MAX_CHARACTERS: usize = 40; + + let first_line = value.lines().next().unwrap_or_default(); + let cut_by_line_break = value.contains('\n'); + let cut_by_length = first_line.chars().count() > MAX_CHARACTERS; + + if !cut_by_line_break && !cut_by_length { + return value.to_string(); + } + + first_line.chars().take(MAX_CHARACTERS - 1).chain(['…']).collect() +} + impl TableItemLayout for Option { fn type_name() -> &'static str { "Option" @@ -943,7 +920,12 @@ impl TableItemLayout for Option { "Option".to_string() } fn value_widgets(&self, _target: PathStep, _data: &LayoutData) -> Vec { - vec![TextLabel::new(format!("{self:?}")).narrow(true).widget_instance()] + let text = match self { + Some(value) => format!("Some({})", round_away_float_noise(*value)), + None => "None".to_string(), + }; + + vec![TextLabel::new(text).selectable(true).narrow(true).widget_instance()] } fn value_page(&self, _data: &mut LayoutData) -> Vec { vec![LayoutGroup::row(self.value_widgets(PathStep::Element(0), _data))] @@ -988,7 +970,7 @@ impl TableItemLayout for DAffine2 { "Transform".to_string() } fn value_widgets(&self, _target: PathStep, _data: &LayoutData) -> Vec { - vec![TextLabel::new(format_transform_matrix(*self)).narrow(true).widget_instance()] + transform_widgets(*self) } fn value_page(&self, _data: &mut LayoutData) -> Vec { vec![LayoutGroup::row(self.value_widgets(PathStep::Element(0), _data))] @@ -1003,8 +985,7 @@ impl TableItemLayout for Affine2 { "Transform".to_string() } fn value_widgets(&self, _target: PathStep, _data: &LayoutData) -> Vec { - let matrix = DAffine2::from_cols_array(&self.to_cols_array().map(|x| x as f64)); - vec![TextLabel::new(format_transform_matrix(matrix)).narrow(true).widget_instance()] + transform_widgets(DAffine2::from_cols_array(&self.to_cols_array().map(|x| x as f64))) } fn value_page(&self, _data: &mut LayoutData) -> Vec { vec![LayoutGroup::row(self.value_widgets(PathStep::Element(0), _data))] @@ -1385,7 +1366,8 @@ fn drilldown_attribute_layout(any: &dyn Any, data: &mut LayoutData) -> Option String { +/// Decomposes a transform into location, rotation, and scale, each headed by the icon of its Layer menu action. +fn transform_widgets(transform: DAffine2) -> Vec { let (scale, angle, translation) = if transform.matrix2.determinant().abs() <= f64::EPSILON { let [col_0, col_1] = transform.matrix2.to_cols_array_2d().map(|[x, y]| DVec2::new(x, y)); @@ -1403,15 +1385,24 @@ fn format_transform_matrix(transform: DAffine2) -> String { } else { transform.to_scale_angle_translation() }; - let rotation = format_rounded(angle.to_degrees(), 3); - - format!( - "Location: ({} px, {} px) — Rotation: {rotation}° — Scale: ({}x, {}x)", - format_rounded(translation.x, 3), - format_rounded(translation.y, 3), - format_rounded(scale.x, 3), - format_rounded(scale.y, 3) - ) + + let location_text = format!("({} px, {} px)", format_rounded(translation.x, 3), format_rounded(translation.y, 3)); + let rotation_text = format!("{}°", format_rounded(angle.to_degrees(), 3)); + let scale_text = format!("({}x, {}x)", format_rounded(scale.x, 3), format_rounded(scale.y, 3)); + + vec![ + IconLabel::new("TransformationGrab").tooltip_label("Location").widget_instance(), + Separator::new(SeparatorStyle::Related).widget_instance(), + TextLabel::new(location_text).narrow(true).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + IconLabel::new("TransformationRotate").tooltip_label("Rotation").widget_instance(), + Separator::new(SeparatorStyle::Related).widget_instance(), + TextLabel::new(rotation_text).narrow(true).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + IconLabel::new("TransformationScale").tooltip_label("Scale").widget_instance(), + Separator::new(SeparatorStyle::Related).widget_instance(), + TextLabel::new(scale_text).narrow(true).widget_instance(), + ] } fn format_dvec2(value: DVec2) -> String { diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs index 8748b1257f..5914c311ed 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs @@ -2429,7 +2429,7 @@ impl NodeGraphMessageHandler { if subgraph_path_names_length >= 2 { widgets.extend([ Separator::new(SeparatorStyle::Unrelated).widget_instance(), - BreadcrumbTrailButtons::new(subgraph_path_names) + BreadcrumbTrailButtons::new(subgraph_path_names.iter().map(|name| truncate_breadcrumb_label(name)).collect()) .on_update(move |index| { DocumentMessage::ExitNestedNetwork { steps_back: subgraph_path_names_length - (*index as usize) - 1, 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 57a6591caf..0558adcab9 100644 --- a/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs +++ b/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs @@ -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::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; use graphene_std::vector::misc::{ArcType, GridType, SpiralType, dvec2_to_point}; @@ -570,26 +571,6 @@ pub fn format_rounded(value: f64, precision: usize) -> String { } } -/// Recovers the intended number from floating point imprecision noise when that can be done reliably, e.g. 0.30000000000000004 -> 0.3. -/// Rounding to each significant digit count from 1 to 12, the first candidate within a relative 1e-13 of the original is accepted. -/// Actual high-precision values (like 0.3333333333333333) never pass the tolerance and are returned unchanged. -pub fn round_away_float_noise(value: f64) -> f64 { - if value == 0. || !value.is_finite() { - return if value == 0. { 0. } else { value }; - } - - let exponent = value.abs().log10().floor() as i32; - for significant_digits in 1..=12 { - let scale = 10_f64.powi(significant_digits - 1 - exponent); - let rounded = (value * scale).round() / scale; - if ((rounded - value) / value).abs() < 1e-13 { - return rounded; - } - } - - value -} - /// Gives the approximated angle to display in degrees, given an angle in degrees. pub fn calculate_display_angle(angle: f64) -> f64 { if angle.is_sign_positive() { @@ -634,37 +615,6 @@ pub fn extract_grid_parameters(layer: LayerNodeIdentifier, document: &DocumentMe mod tests { use super::*; - #[test] - fn round_away_float_noise_snaps_noisy_values() { - assert_eq!(round_away_float_noise(0.1 + 0.2), 0.3); - assert_eq!(round_away_float_noise(0.3000000000000012), 0.3); - assert_eq!(round_away_float_noise(2.99999999999993), 3.); - assert_eq!(round_away_float_noise(45.00000000000001), 45.); - } - - #[test] - fn round_away_float_noise_keeps_honest_values() { - assert_eq!(round_away_float_noise(1. / 3.), 1. / 3.); - assert_eq!(round_away_float_noise(0.2394023940209349), 0.2394023940209349); - assert_eq!(round_away_float_noise(0.25), 0.25); - assert_eq!(round_away_float_noise(-17.5), -17.5); - } - - #[test] - fn round_away_float_noise_keeps_deliberate_values_with_zero_runs() { - assert_eq!(round_away_float_noise(0.30000005), 0.30000005); - assert_eq!(round_away_float_noise(0.3000000000001), 0.3000000000001); - assert_eq!(round_away_float_noise(1.00000001), 1.00000001); - assert_eq!(round_away_float_noise(2.9999993), 2.9999993); - } - - #[test] - fn round_away_float_noise_normalizes_zero() { - let result = round_away_float_noise(-0.); - assert_eq!(result, 0.); - assert!(result.is_sign_positive()); - } - #[test] fn format_rounded_trims_trailing_zeros_when_exact() { assert_eq!(format_rounded(0.25, 2), "0.25"); diff --git a/frontend/src/components/widgets/buttons/BreadcrumbTrailButtons.svelte b/frontend/src/components/widgets/buttons/BreadcrumbTrailButtons.svelte index a6db70ea28..8d4de70086 100644 --- a/frontend/src/components/widgets/buttons/BreadcrumbTrailButtons.svelte +++ b/frontend/src/components/widgets/buttons/BreadcrumbTrailButtons.svelte @@ -12,31 +12,11 @@ export let tooltipShortcut: ActionShortcut | undefined = undefined; // Callbacks export let action: (index: number) => void; - - function truncate(label: string): string { - let maxLength = 40; - - if (label.length <= maxLength) return label; - - let truncated = label; - const hasQuotes = label.startsWith(`"`) && label.endsWith(`"`); - - if (hasQuotes) { - truncated = label.slice(1, -1); - maxLength -= 2; - } - - truncated = truncated.slice(0, maxLength - 1) + "…"; - - if (hasQuotes) truncated = `"${truncated}"`; - - return truncated; - } {#each labels as label, index} - !disabled && index !== labels.length - 1 && action(index)} /> + !disabled && index !== labels.length - 1 && action(index)} /> {/each} diff --git a/frontend/src/components/widgets/inputs/FieldInput.svelte b/frontend/src/components/widgets/inputs/FieldInput.svelte index 2be753e261..a823b3a2e7 100644 --- a/frontend/src/components/widgets/inputs/FieldInput.svelte +++ b/frontend/src/components/widgets/inputs/FieldInput.svelte @@ -205,6 +205,10 @@ textarea { color: var(--color-8-uppergray); } + + input { + pointer-events: none; + } } } diff --git a/frontend/src/components/widgets/labels/TextLabel.svelte b/frontend/src/components/widgets/labels/TextLabel.svelte index 36da1795a8..fc7f51cf48 100644 --- a/frontend/src/components/widgets/labels/TextLabel.svelte +++ b/frontend/src/components/widgets/labels/TextLabel.svelte @@ -19,6 +19,8 @@ export let italic = false; export let monospace = false; export let multiline = false; + export let enquote = false; + export let selectable = false; export let centerAlign = false; export let tableAlign = false; // Sizing @@ -76,6 +78,8 @@ class:italic class:monospace class:multiline + class:enquote + class:selectable class:center-align={centerAlign} class:table-align={tableAlign} style:min-width={minWidthCharacters ? `${minWidthCharacters}ch` : minWidth > 0 ? `${minWidth}px` : undefined} @@ -125,6 +129,17 @@ margin: 4px 0; } + &.enquote::before, + &.enquote::after { + content: '"'; + user-select: none; + } + + &.selectable { + user-select: text; + cursor: text; + } + &.center-align { text-align: center; } diff --git a/frontend/src/utility-functions/input.ts b/frontend/src/utility-functions/input.ts index 222ea4f364..0af86e2913 100644 --- a/frontend/src/utility-functions/input.ts +++ b/frontend/src/utility-functions/input.ts @@ -128,6 +128,7 @@ export function onPointerMove(e: PointerEvent, editor: EditorWrapper, documentSt export function onPointerDown(e: PointerEvent, editor: EditorWrapper, dialogStore: DialogStore) { potentiallyRestoreCanvasFocus(e); + potentiallyClearTextSelection(e); const inFloatingMenu = e.target instanceof Element && e.target.closest("[data-floating-menu-content]"); const isTargetingCanvas = !inFloatingMenu && e.target instanceof Element && e.target.closest("[data-viewport], [data-viewport-container], [data-node-graph]"); @@ -351,6 +352,20 @@ function targetIsTextField(target: EventTarget | HTMLElement | undefined): boole ); } +function potentiallyClearTextSelection(e: PointerEvent) { + const target = e.target instanceof Element ? e.target : undefined; + if (target && (targetIsTextField(target) || window.getComputedStyle(target).userSelect !== "none")) return; + + // A text control's selection lives in its shadow tree, which the document's `Selection` reports as collapsed and cannot clear, so each control holding one is collapsed through its own API + const controls = window.document.querySelectorAll("textarea, input[type='text']"); + controls.forEach((control) => { + const caret = control.selectionStart; + if (typeof caret === "number" && caret !== control.selectionEnd) control.setSelectionRange(caret, caret); + }); + + window.getSelection()?.removeAllRanges(); +} + function potentiallyRestoreCanvasFocus(e: Event) { const appElement = window.document.querySelector("[data-app-container]"); const app = appElement instanceof HTMLElement ? appElement : undefined; diff --git a/node-graph/libraries/core-types/src/math/float_noise.rs b/node-graph/libraries/core-types/src/math/float_noise.rs new file mode 100644 index 0000000000..36e275aa53 --- /dev/null +++ b/node-graph/libraries/core-types/src/math/float_noise.rs @@ -0,0 +1,71 @@ +use std::fmt::Write; + +/// Recovers the intended number from floating point imprecision noise when that can be done reliably, e.g. 0.30000000000000004 -> 0.3. +/// Rounding to each significant digit count from 1 to 12, the first candidate within a relative 1e-13 of the original is accepted. +/// Actual high-precision values (like 0.3333333333333333) never pass the tolerance and are returned unchanged. +/// f64 only, as f32 lacks precision to reliably distinguish between intentional digits and noise. +pub fn round_away_float_noise(value: f64) -> f64 { + if value == 0. || !value.is_finite() { + return if value == 0. { 0. } else { value }; + } + + // Candidates come from decimal formatting rather than scaling by a power of ten, which is inexact enough to invent + // noise of its own: it turns 1e300 into 9.999999999999999e299 and 999999.9999999 into 999999.9999999999. + // One buffer serves every candidate, since the digit counts are tried in turn. + let mut buffer = String::with_capacity(32); + for significant_digits in 1..=12 { + buffer.clear(); + let _ = write!(buffer, "{value:.*e}", significant_digits - 1); + + let Ok(rounded) = buffer.parse::() else { continue }; + if ((rounded - value) / value).abs() < 1e-13 { + return rounded; + } + } + + value +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_away_float_noise_snaps_noisy_values() { + assert_eq!(round_away_float_noise(0.1 + 0.2), 0.3); + assert_eq!(round_away_float_noise(0.3000000000000012), 0.3); + assert_eq!(round_away_float_noise(2.99999999999993), 3.); + assert_eq!(round_away_float_noise(45.00000000000001), 45.); + } + + #[test] + fn round_away_float_noise_keeps_honest_values() { + assert_eq!(round_away_float_noise(1. / 3.), 1. / 3.); + assert_eq!(round_away_float_noise(0.2394023940209349), 0.2394023940209349); + assert_eq!(round_away_float_noise(0.25), 0.25); + assert_eq!(round_away_float_noise(-17.5), -17.5); + } + + #[test] + fn round_away_float_noise_keeps_deliberate_values_with_zero_runs() { + assert_eq!(round_away_float_noise(0.30000005), 0.30000005); + assert_eq!(round_away_float_noise(0.3000000000001), 0.3000000000001); + assert_eq!(round_away_float_noise(1.00000001), 1.00000001); + assert_eq!(round_away_float_noise(2.9999993), 2.9999993); + } + + #[test] + fn round_away_float_noise_normalizes_zero() { + let result = round_away_float_noise(-0.); + assert_eq!(result, 0.); + assert!(result.is_sign_positive()); + } + + #[test] + fn round_away_float_noise_keeps_extreme_magnitudes_exact() { + assert_eq!(round_away_float_noise(1e300), 1e300); + assert_eq!(round_away_float_noise(1.5e300), 1.5e300); + assert_eq!(round_away_float_noise(1e-300), 1e-300); + assert_eq!(round_away_float_noise(f64::MIN_POSITIVE), f64::MIN_POSITIVE); + } +} diff --git a/node-graph/libraries/core-types/src/math/mod.rs b/node-graph/libraries/core-types/src/math/mod.rs index 90a2b6d0e3..bf8af5a78a 100644 --- a/node-graph/libraries/core-types/src/math/mod.rs +++ b/node-graph/libraries/core-types/src/math/mod.rs @@ -1,4 +1,5 @@ pub mod bbox; +pub mod float_noise; pub mod polynomial; pub mod quad; pub mod rect; diff --git a/node-graph/libraries/core-types/src/ops.rs b/node-graph/libraries/core-types/src/ops.rs index 507a8df260..9314c1a107 100644 --- a/node-graph/libraries/core-types/src/ops.rs +++ b/node-graph/libraries/core-types/src/ops.rs @@ -1,6 +1,7 @@ use crate::Node; +use crate::math::float_noise::round_away_float_noise; use crate::transform::Footprint; -use glam::DVec2; +use glam::{DAffine2, DVec2}; use std::future::Future; use std::marker::PhantomData; @@ -45,11 +46,26 @@ pub trait Convert: Sized { fn convert(self, footprint: Footprint, converter: C) -> impl Future + Send; } -impl Convert for T { - /// Converts this type into a `String` using its `ToString` implementation. +/// Implements the [`Convert`] trait for formatting a type into a `String` via [`ToString`]. +macro_rules! impl_convert_to_string { + ($($from:ty),* $(,)?) => { + $( + impl Convert for $from { + #[inline] + async fn convert(self, _: Footprint, _converter: ()) -> String { + self.to_string() + } + } + )* + }; +} +impl_convert_to_string!(f32, u32, u64, i32, i64, bool, DVec2, DAffine2); + +// Denoised so 0.1 + 0.2 reaches the string as "0.3" rather than "0.30000000000000004" +impl Convert for f64 { #[inline] async fn convert(self, _: Footprint, _converter: ()) -> String { - self.to_string() + round_away_float_noise(self).to_string() } } diff --git a/node-graph/nodes/gstd/src/lib.rs b/node-graph/nodes/gstd/src/lib.rs index 158110a7cd..4090049bd1 100644 --- a/node-graph/nodes/gstd/src/lib.rs +++ b/node-graph/nodes/gstd/src/lib.rs @@ -67,6 +67,7 @@ pub mod repeat { } pub mod math { + pub use core_types::math::float_noise; pub use core_types::math::quad; pub mod math_ext { diff --git a/node-graph/nodes/text/src/lib.rs b/node-graph/nodes/text/src/lib.rs index 0c7d03df7b..9faa704064 100644 --- a/node-graph/nodes/text/src/lib.rs +++ b/node-graph/nodes/text/src/lib.rs @@ -9,6 +9,7 @@ mod to_path; 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::registry::types::{SignedInteger, TextArea}; use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, ExtractVarArgs, OwnedContextImpl}; use dyn_any::DynAny; @@ -299,6 +300,7 @@ fn format_number( start_at_10000: Item, ) -> Item { let (number, attributes) = number.into_parts(); + let number = round_away_float_noise(number); 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();