From 3d7e85c054f4b1af7846e12338729cd5a61a64b5 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Thu, 23 Jul 2026 00:22:13 -0700 Subject: [PATCH] Improve number fields/labels in the UI to filter out floating point noise and truncate at correct precision (#4363) * Improve the displayed precision of float number labels * Show actual tiny values in high-precision number fields --- .../data_panel/data_panel_message_handler.rs | 18 +-- .../document/overlays/utility_types_native.rs | 5 +- .../document/overlays/utility_types_web.rs | 5 +- .../shapes/shape_utility.rs | 107 +++++++++++++++++- .../transform_layer_message_handler.rs | 3 +- .../widgets/inputs/NumberInput.svelte | 20 +++- frontend/src/utility-functions/numbers.ts | 15 +++ 7 files changed, 153 insertions(+), 20 deletions(-) create mode 100644 frontend/src/utility-functions/numbers.ts 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 908e883892..1bea3a4d1e 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 @@ -3,6 +3,7 @@ use crate::messages::layout::utility_types::layout_widget::{Layout, LayoutGroup, use crate::messages::portfolio::document::data_panel::{DataPanelMessage, PathStep}; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; use crate::messages::prelude::*; +use crate::messages::tool::common_functionality::shapes::shape_utility::{format_rounded, round_away_float_noise}; use crate::messages::tool::tool_messages::tool_prelude::*; use glam::{Affine2, DAffine2, Vec2}; use graph_craft::document::NodeId; @@ -623,6 +624,7 @@ impl TableItemLayout for Vector { VectorTableTab::Points => { table_rows.push(column_headings(&["", "position"])); table_rows.extend(self.point_domain.iter().map(|(id, position)| { + let position = DVec2::new(round_away_float_noise(position.x), round_away_float_noise(position.y)); vec![ TextLabel::new(format!("{}", id.inner())).narrow(true).widget_instance(), TextLabel::new(format!("{position}")).narrow(true).widget_instance(), @@ -1339,19 +1341,17 @@ fn format_transform_matrix(transform: DAffine2) -> String { } else { transform.to_scale_angle_translation() }; - let rotation = if angle == -0. { 0. } else { angle.to_degrees() }; - let round = |x: f64| (x * 1e3).round() / 1e3; + let rotation = format_rounded(angle.to_degrees(), 3); format!( - "Location: ({} px, {} px) — Rotation: {rotation:2}° — Scale: ({}x, {}x)", - round(translation.x), - round(translation.y), - round(scale.x), - round(scale.y) + "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) ) } fn format_dvec2(value: DVec2) -> String { - let round = |x: f64| (x * 1e3).round() / 1e3; - format!("({} px, {} px)", round(value.x), round(value.y)) + format!("({} px, {} px)", format_rounded(value.x, 3), format_rounded(value.y, 3)) } 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 743953d439..1cf6d0ac4a 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs @@ -8,6 +8,7 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye use crate::messages::portfolio::fonts::FALLBACK_FONT_RESOURCE; use crate::messages::prelude::Message; use crate::messages::prelude::ViewportMessageHandler; +use crate::messages::tool::common_functionality::shapes::shape_utility::format_rounded; use core::borrow::Borrow; use core::f64::consts::{FRAC_PI_2, PI, TAU}; use glam::{DAffine2, DVec2}; @@ -1189,7 +1190,7 @@ impl OverlayContextInternal { let width = match typed_string { Some(ref typed_string) => typed_string, - None => &format!("{:.2}", translation.x).trim_end_matches('0').trim_end_matches('.').to_string(), + None => &format_rounded(translation.x, 2), }; let x_transform = DAffine2::from_translation((quad.top_left() + quad.top_right()) / 2.); self.text(width, COLOR_OVERLAY_BLUE, None, x_transform, 4., [Pivot::Middle, Pivot::End]); @@ -1200,7 +1201,7 @@ impl OverlayContextInternal { let height = match typed_string { Some(ref typed_string) => typed_string, - None => &format!("{:.2}", translation.y).trim_end_matches('0').trim_end_matches('.').to_string(), + None => &format_rounded(translation.y, 2), }; let y_transform = DAffine2::from_translation((quad.top_left() + quad.bottom_left()) / 2.); let height_pivot = if translation.x > -1e-3 { Pivot::Start } else { Pivot::End }; diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs index 072c5be668..4abfd842a8 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs @@ -7,6 +7,7 @@ use crate::consts::{ }; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::prelude::Message; +use crate::messages::tool::common_functionality::shapes::shape_utility::format_rounded; use crate::messages::viewport::ViewportMessageHandler; use core::borrow::Borrow; use core::f64::consts::{FRAC_PI_2, PI, TAU}; @@ -1090,7 +1091,7 @@ impl OverlayContext { let width = match typed_string { Some(ref typed_string) => typed_string, - None => &format!("{:.2}", translation.x).trim_end_matches('0').trim_end_matches('.').to_string(), + None => &format_rounded(translation.x, 2), }; let x_transform = DAffine2::from_translation((quad.top_left() + quad.top_right()) / 2.); self.text(width, COLOR_OVERLAY_BLUE, None, x_transform, 4., [Pivot::Middle, Pivot::End]); @@ -1101,7 +1102,7 @@ impl OverlayContext { let height = match typed_string { Some(ref typed_string) => typed_string, - None => &format!("{:.2}", translation.y).trim_end_matches('0').trim_end_matches('.').to_string(), + None => &format_rounded(translation.y, 2), }; let y_transform = DAffine2::from_translation((quad.top_left() + quad.bottom_left()) / 2.); let height_pivot = if translation.x > -1e-3 { Pivot::Start } else { Pivot::End }; 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 5f0d6da016..acc48ff37a 100644 --- a/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs +++ b/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs @@ -571,7 +571,37 @@ pub fn wrap_to_tau(angle: f64) -> f64 { } pub fn format_rounded(value: f64, precision: usize) -> String { - format!("{value:.precision$}").trim_end_matches('0').trim_end_matches('.').to_string() + // 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 }; + let formatted = format!("{value:.precision$}"); + + // Trailing zeros are trimmed only when the display is exact, so a truncated value keeps its decimal places (like "0.00" or "3.10") to indicate the truncation + if formatted.parse::() == Ok(value) { + formatted.trim_end_matches('0').trim_end_matches('.').to_string() + } else { + formatted + } +} + +/// 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. @@ -613,3 +643,78 @@ pub fn extract_grid_parameters(layer: LayerNodeIdentifier, document: &DocumentMe Some((grid_type, spacing, columns, rows, angles)) } + +#[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 format_rounded_trims_trailing_zeros_when_exact() { + assert_eq!(format_rounded(0.25, 2), "0.25"); + assert_eq!(format_rounded(3.1, 2), "3.1"); + assert_eq!(format_rounded(45., 2), "45"); + } + + #[test] + fn format_rounded_keeps_decimal_places_when_truncated() { + assert_eq!(format_rounded(0.0003, 2), "0.00"); + assert_eq!(format_rounded(-0.0003, 2), "-0.00"); + assert_eq!(format_rounded(0.0003, 3), "0.000"); + assert_eq!(format_rounded(3.10001, 2), "3.10"); + assert_eq!(format_rounded(45.001, 2), "45.00"); + assert_eq!(format_rounded(std::f64::consts::PI, 2), "3.14"); + } + + #[test] + fn format_rounded_denoises_before_judging_exactness() { + assert_eq!(format_rounded(29.999999999999996, 2), "30"); + assert_eq!(format_rounded(45.00000000000001, 2), "45"); + } + + #[test] + fn format_rounded_shows_true_and_noise_zeros_plainly() { + assert_eq!(format_rounded(0., 2), "0"); + assert_eq!(format_rounded(-0., 2), "0"); + assert_eq!(format_rounded(1e-15, 2), "0"); + assert_eq!(format_rounded(-1e-15, 2), "0"); + } + + #[test] + fn format_rounded_keeps_tiny_values_at_displayable_precision() { + assert_eq!(format_rounded(5e-13, 20), "0.0000000000005"); + assert_eq!(format_rounded(-5e-13, 20), "-0.0000000000005"); + assert_eq!(format_rounded(0., 20), "0"); + assert_eq!(format_rounded(-0., 20), "0"); + } +} 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 d489c95377..33d1508300 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 @@ -8,6 +8,7 @@ use crate::messages::portfolio::document::utility_types::transformation::{Axis, use crate::messages::prelude::*; use crate::messages::tool::common_functionality::pivot::{PivotGizmo, PivotGizmoType}; use crate::messages::tool::common_functionality::shape_editor::ShapeState; +use crate::messages::tool::common_functionality::shapes::shape_utility::format_rounded; use crate::messages::tool::tool_messages::select_tool; use crate::messages::tool::tool_messages::tool_prelude::Key; use crate::messages::tool::utility_types::{ToolData, ToolType}; @@ -217,7 +218,7 @@ impl MessageHandler> for let format_rounded = |value: f64, precision: usize| { if self.typing.digits.is_empty() || !self.transform_operation.can_begin_typing() { - format!("{value:.precision$}").trim_end_matches('0').trim_end_matches('.').to_string() + format_rounded(value, precision) } else { self.typing.string.clone() } diff --git a/frontend/src/components/widgets/inputs/NumberInput.svelte b/frontend/src/components/widgets/inputs/NumberInput.svelte index ffc14f0f2a..2678e26fee 100644 --- a/frontend/src/components/widgets/inputs/NumberInput.svelte +++ b/frontend/src/components/widgets/inputs/NumberInput.svelte @@ -3,6 +3,7 @@ import { preventEscapeClosingParentFloatingMenu } from "/src/components/layout/FloatingMenu.svelte"; import FieldInput from "/src/components/widgets/inputs/FieldInput.svelte"; import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS } from "/src/managers/input"; + import { roundAwayFloatNoise } from "/src/utility-functions/numbers"; import { browserVersion } from "/src/utility-functions/platform"; import type { ActionShortcut, EditorWrapper, NumberInputIncrementBehavior, NumberInputMode } from "/wrapper/pkg/graphite_wasm_wrapper"; @@ -213,9 +214,19 @@ function displayText(displayValue: number | undefined, unit: string): string { if (displayValue === undefined) return "-"; - const roundingPower = 10 ** Math.max(displayDecimalPlaces, 0); + const decimalPlaces = Math.max(displayDecimalPlaces, 0); + const roundingPower = 10 ** decimalPlaces; + + // Values within floating point noise of zero (including -0) display as unsigned zero, unless the field's decimal precision is fine enough to display them + const effectiveValue = Math.abs(displayValue) < Math.min(1e-12, 0.5 / roundingPower) ? 0 : roundAwayFloatNoise(displayValue); + const unitlessDisplayValue = Math.round(effectiveValue * roundingPower) / roundingPower; + + // Trailing zeros are trimmed only when the display is exact, so a truncated value keeps its decimal places (like "0.00" or "3.10") to indicate the truncation + if (unitlessDisplayValue !== effectiveValue) { + const sign = unitlessDisplayValue === 0 && effectiveValue < 0 ? "-" : ""; + return `${sign}${unitlessDisplayValue.toFixed(decimalPlaces)}${unPluralize(unit, displayValue)}`; + } - const unitlessDisplayValue = Math.round(displayValue * roundingPower) / roundingPower; return `${unitlessDisplayValue}${unPluralize(unit, displayValue)}`; } @@ -230,9 +241,8 @@ // =========================== function onTextFocused() { - // The degree of precision allowed in the number that's shown when editing the number field, where additional precision is removed to round out floating point errors. - const MAX_PRECISION = 12; - const noFloatingImprecisionValue = value === undefined ? undefined : Number(value.toPrecision(MAX_PRECISION)); + // The number shown when editing the field, with floating point imprecision noise removed + const noFloatingImprecisionValue = value === undefined ? undefined : roundAwayFloatNoise(value); if (value === undefined) text = ""; else if (unitIsHiddenWhenEditing) text = `${noFloatingImprecisionValue}`; diff --git a/frontend/src/utility-functions/numbers.ts b/frontend/src/utility-functions/numbers.ts new file mode 100644 index 0000000000..525b9e2933 --- /dev/null +++ b/frontend/src/utility-functions/numbers.ts @@ -0,0 +1,15 @@ +// 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. +export function roundAwayFloatNoise(value: number): number { + if (value === 0 || !Number.isFinite(value)) return value === 0 ? 0 : value; + + const exponent = Math.floor(Math.log10(Math.abs(value))); + for (let significantDigits = 1; significantDigits <= 12; significantDigits += 1) { + const scale = 10 ** (significantDigits - 1 - exponent); + const rounded = Math.round(value * scale) / scale; + if (Math.abs((rounded - value) / value) < 1e-13) return rounded; + } + + return value; +}