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:
committed by
Dennis Kobert
parent
31239f77c6
commit
f4609e0e3b
@@ -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::data_panel::{DataPanelMessage, PathStep};
|
||||||
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
|
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
|
||||||
use crate::messages::prelude::*;
|
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 crate::messages::tool::tool_messages::tool_prelude::*;
|
||||||
use glam::{Affine2, DAffine2, Vec2};
|
use glam::{Affine2, DAffine2, Vec2};
|
||||||
use graph_craft::document::NodeId;
|
use graph_craft::document::NodeId;
|
||||||
@@ -629,6 +630,7 @@ impl TableItemLayout for Vector {
|
|||||||
VectorTableTab::Points => {
|
VectorTableTab::Points => {
|
||||||
table_rows.push(column_headings(&["", "position"]));
|
table_rows.push(column_headings(&["", "position"]));
|
||||||
table_rows.extend(self.point_domain.iter().map(|(id, 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![
|
vec![
|
||||||
TextLabel::new(format!("{}", id.inner())).narrow(true).widget_instance(),
|
TextLabel::new(format!("{}", id.inner())).narrow(true).widget_instance(),
|
||||||
TextLabel::new(format!("{position}")).narrow(true).widget_instance(),
|
TextLabel::new(format!("{position}")).narrow(true).widget_instance(),
|
||||||
@@ -1346,19 +1348,17 @@ fn format_transform_matrix(transform: DAffine2) -> String {
|
|||||||
} else {
|
} else {
|
||||||
transform.to_scale_angle_translation()
|
transform.to_scale_angle_translation()
|
||||||
};
|
};
|
||||||
let rotation = if angle == -0. { 0. } else { angle.to_degrees() };
|
let rotation = format_rounded(angle.to_degrees(), 3);
|
||||||
let round = |x: f64| (x * 1e3).round() / 1e3;
|
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
"Location: ({} px, {} px) — Rotation: {rotation:2}° — Scale: ({}x, {}x)",
|
"Location: ({} px, {} px) — Rotation: {rotation}° — Scale: ({}x, {}x)",
|
||||||
round(translation.x),
|
format_rounded(translation.x, 3),
|
||||||
round(translation.y),
|
format_rounded(translation.y, 3),
|
||||||
round(scale.x),
|
format_rounded(scale.x, 3),
|
||||||
round(scale.y)
|
format_rounded(scale.y, 3)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_dvec2(value: DVec2) -> String {
|
fn format_dvec2(value: DVec2) -> String {
|
||||||
let round = |x: f64| (x * 1e3).round() / 1e3;
|
format!("({} px, {} px)", format_rounded(value.x, 3), format_rounded(value.y, 3))
|
||||||
format!("({} px, {} px)", round(value.x), round(value.y))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::portfolio::fonts::FALLBACK_FONT_RESOURCE;
|
||||||
use crate::messages::prelude::Message;
|
use crate::messages::prelude::Message;
|
||||||
use crate::messages::prelude::ViewportMessageHandler;
|
use crate::messages::prelude::ViewportMessageHandler;
|
||||||
|
use crate::messages::tool::common_functionality::shapes::shape_utility::format_rounded;
|
||||||
use core::borrow::Borrow;
|
use core::borrow::Borrow;
|
||||||
use core::f64::consts::{FRAC_PI_2, PI, TAU};
|
use core::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||||
use glam::{DAffine2, DVec2};
|
use glam::{DAffine2, DVec2};
|
||||||
@@ -1189,7 +1190,7 @@ impl OverlayContextInternal {
|
|||||||
|
|
||||||
let width = match typed_string {
|
let width = match typed_string {
|
||||||
Some(ref typed_string) => 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.);
|
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]);
|
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 {
|
let height = match typed_string {
|
||||||
Some(ref typed_string) => 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 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 };
|
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::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
|
||||||
use crate::messages::prelude::Message;
|
use crate::messages::prelude::Message;
|
||||||
|
use crate::messages::tool::common_functionality::shapes::shape_utility::format_rounded;
|
||||||
use crate::messages::viewport::ViewportMessageHandler;
|
use crate::messages::viewport::ViewportMessageHandler;
|
||||||
use core::borrow::Borrow;
|
use core::borrow::Borrow;
|
||||||
use core::f64::consts::{FRAC_PI_2, PI, TAU};
|
use core::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||||
@@ -1090,7 +1091,7 @@ impl OverlayContext {
|
|||||||
|
|
||||||
let width = match typed_string {
|
let width = match typed_string {
|
||||||
Some(ref typed_string) => 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.);
|
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]);
|
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 {
|
let height = match typed_string {
|
||||||
Some(ref typed_string) => 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 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 };
|
let height_pivot = if translation.x > -1e-3 { Pivot::Start } else { Pivot::End };
|
||||||
|
|||||||
@@ -568,7 +568,37 @@ pub fn wrap_to_tau(angle: f64) -> f64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn format_rounded(value: f64, precision: usize) -> String {
|
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.
|
/// Gives the approximated angle to display in degrees, given an angle in degrees.
|
||||||
@@ -610,3 +640,78 @@ pub fn extract_grid_parameters(layer: LayerNodeIdentifier, document: &DocumentMe
|
|||||||
|
|
||||||
Some((grid_type, spacing, columns, rows, angles))
|
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::prelude::*;
|
||||||
use crate::messages::tool::common_functionality::pivot::{PivotGizmo, PivotGizmoType};
|
use crate::messages::tool::common_functionality::pivot::{PivotGizmo, PivotGizmoType};
|
||||||
use crate::messages::tool::common_functionality::shape_editor::ShapeState;
|
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::select_tool;
|
||||||
use crate::messages::tool::tool_messages::tool_prelude::Key;
|
use crate::messages::tool::tool_messages::tool_prelude::Key;
|
||||||
use crate::messages::tool::utility_types::{ToolData, ToolType};
|
use crate::messages::tool::utility_types::{ToolData, ToolType};
|
||||||
@@ -217,7 +218,7 @@ impl MessageHandler<TransformLayerMessage, TransformLayerMessageContext<'_>> for
|
|||||||
|
|
||||||
let format_rounded = |value: f64, precision: usize| {
|
let format_rounded = |value: f64, precision: usize| {
|
||||||
if self.typing.digits.is_empty() || !self.transform_operation.can_begin_typing() {
|
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 {
|
} else {
|
||||||
self.typing.string.clone()
|
self.typing.string.clone()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { preventEscapeClosingParentFloatingMenu } from "/src/components/layout/FloatingMenu.svelte";
|
import { preventEscapeClosingParentFloatingMenu } from "/src/components/layout/FloatingMenu.svelte";
|
||||||
import FieldInput from "/src/components/widgets/inputs/FieldInput.svelte";
|
import FieldInput from "/src/components/widgets/inputs/FieldInput.svelte";
|
||||||
import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS } from "/src/managers/input";
|
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 { browserVersion } from "/src/utility-functions/platform";
|
||||||
import type { ActionShortcut, EditorWrapper, NumberInputIncrementBehavior, NumberInputMode } from "/wrapper/pkg/graphite_wasm_wrapper";
|
import type { ActionShortcut, EditorWrapper, NumberInputIncrementBehavior, NumberInputMode } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||||
|
|
||||||
@@ -213,9 +214,19 @@
|
|||||||
function displayText(displayValue: number | undefined, unit: string): string {
|
function displayText(displayValue: number | undefined, unit: string): string {
|
||||||
if (displayValue === undefined) return "-";
|
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)}`;
|
return `${unitlessDisplayValue}${unPluralize(unit, displayValue)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,9 +241,8 @@
|
|||||||
// ===========================
|
// ===========================
|
||||||
|
|
||||||
function onTextFocused() {
|
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.
|
// The number shown when editing the field, with floating point imprecision noise removed
|
||||||
const MAX_PRECISION = 12;
|
const noFloatingImprecisionValue = value === undefined ? undefined : roundAwayFloatNoise(value);
|
||||||
const noFloatingImprecisionValue = value === undefined ? undefined : Number(value.toPrecision(MAX_PRECISION));
|
|
||||||
|
|
||||||
if (value === undefined) text = "";
|
if (value === undefined) text = "";
|
||||||
else if (unitIsHiddenWhenEditing) text = `${noFloatingImprecisionValue}`;
|
else if (unitIsHiddenWhenEditing) text = `${noFloatingImprecisionValue}`;
|
||||||
|
|||||||
15
frontend/src/utility-functions/numbers.ts
Normal file
15
frontend/src/utility-functions/numbers.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user