mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
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
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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::<f64>() == 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TransformLayerMessage, TransformLayerMessageContext<'_>> 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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user