fix(units): carry rounded values

Round before splitting display units while preserving large lengths and high-precision cardinal bearings.
This commit is contained in:
Hakan Seven committed 2026-08-20 19:01:53 +03:00
commit 9d60c2a2ef
1 file changed
+159 -27
+159 -27
View File
@@ -104,31 +104,31 @@ pub fn format_length(value: f64) -> String {
match ctx.lunits {
1 => format!("{:.*e}", prec, value),
3 => {
// Engineering: ft-inches, decimal inches.
// Round before splitting so twelve inches carries into feet.
let sign = if value < 0.0 { "-" } else { "" };
let abs = value.abs();
let feet = (abs / 12.0).trunc();
let rem = abs - feet * 12.0;
let scale = 10f64.powi(prec.min(15) as i32);
let total = (abs * scale).round();
let per_foot = 12.0 * scale;
let feet = (total / per_foot).trunc();
let rem = (total - feet * per_foot) / scale;
format!("{}{:.0}'-{:.*}\"", sign, feet, prec, rem)
}
4 | 5 => {
// Architectural / Fractional — n + fraction with 1/2^p denom (1
// unit = 1 inch). Use 6 as a moderate denominator power so the
// result reads like 1/64".
// Architectural and fractional formats use 1/64-inch resolution.
let sign = if value < 0.0 { "-" } else { "" };
let abs = value.abs();
let (feet, in_rem) = if ctx.lunits == 4 {
let f = (abs / 12.0).trunc();
(Some(f as i64), abs - f * 12.0)
let denom = 64.0;
let ticks = (abs * denom).round();
let (feet, rest) = if ctx.lunits == 4 {
let per_foot = 12.0 * denom;
(Some((ticks / per_foot).floor()), ticks.rem_euclid(per_foot))
} else {
(None, abs)
(None, ticks)
};
let whole = in_rem.trunc();
let frac = in_rem - whole;
let denom = 64u64;
let numer = (frac * denom as f64).round() as i64;
let mut n = numer as u64;
let mut d = denom;
let whole = (rest / denom).floor();
let mut n = (rest - whole * denom).round() as u64;
let mut d = denom as u64;
while d > 1 && n % 2 == 0 && d % 2 == 0 {
n /= 2;
d /= 2;
@@ -140,7 +140,7 @@ pub fn format_length(value: f64) -> String {
};
let unit_suffix = if ctx.lunits == 4 { "\"" } else { "" };
match feet {
Some(f) => format!("{}{}'-{:.0}{}{}", sign, f, whole, frac_str, unit_suffix),
Some(f) => format!("{}{:.0}'-{:.0}{}{}", sign, f, whole, frac_str, unit_suffix),
None => format!("{}{:.0}{}", sign, whole, frac_str),
}
}
@@ -169,10 +169,15 @@ pub fn format_angle(value_rad: f64) -> String {
fn dms(degrees: f64, prec: usize) -> String {
let sign = if degrees < 0.0 { "-" } else { "" };
let a = degrees.abs();
let d = a.floor();
let m_full = (a - d) * 60.0;
let m = m_full.floor();
let s = (m_full - m) * 60.0;
// Round before splitting so seconds carry into minutes and degrees.
let scale = 10f64.powi(prec.min(15) as i32);
let per_minute = 60.0 * scale;
let per_degree = 60.0 * per_minute;
let ticks = (a * per_degree).round();
let d = (ticks / per_degree).trunc();
let rest = ticks - d * per_degree;
let m = (rest / per_minute).trunc();
let s = (rest - m * per_minute) / scale;
format!("{}{:.0}d{:.0}'{:.*}\"", sign, d, m, prec, s)
}
@@ -183,20 +188,26 @@ fn dms(degrees: f64, prec: usize) -> String {
/// the single letter, which is also what keeps a 90° angle from reading as the
/// contradictory `N 90d0'0" E`.
fn surveyor(value_rad: f64, prec: usize) -> String {
let deg = value_rad.to_degrees().rem_euclid(360.0);
let near = |target: f64| (deg - target).abs() < 1e-9;
if near(0.0) {
// Quantize before choosing a cardinal or quadrant.
let scale = 3600.0 * 10f64.powi(prec.min(15) as i32);
let turn = 360.0 * scale;
let ticks = (value_rad.to_degrees().rem_euclid(360.0) * scale)
.round()
.rem_euclid(turn);
let at = |degrees: f64| (ticks - degrees * scale).abs() < 0.5;
if at(0.0) {
return "E".into();
}
if near(90.0) {
if at(90.0) {
return "N".into();
}
if near(180.0) {
if at(180.0) {
return "W".into();
}
if near(270.0) {
if at(270.0) {
return "S".into();
}
let deg = ticks / scale;
// Measured from the nearer pole, toward the side the angle falls on.
let (pole, bearing, side) = if deg < 90.0 {
("N", 90.0 - deg, "E")
@@ -842,3 +853,124 @@ pub(crate) fn polyline_segment_fill(
Some(boundary)
}
}
#[cfg(test)]
mod length_format_tests {
use super::*;
fn with_units(lunits: i16, luprec: i16, value: f64) -> String {
let mut ctx = unit_context();
ctx.lunits = lunits;
ctx.luprec = luprec;
set_unit_context(ctx);
format_length(value)
}
#[test]
fn fractional_carries_a_rounded_up_fraction() {
assert_eq!(with_units(5, 4, 5.99), "5 63/64");
assert_eq!(with_units(5, 4, 5.995), "6");
assert_eq!(with_units(5, 4, 0.995), "1");
assert_eq!(with_units(5, 4, 11.999), "12");
assert_eq!(with_units(5, 4, -5.995), "-6");
assert_eq!(with_units(5, 4, 0.5), "0 1/2");
assert_eq!(with_units(5, 4, 9.25), "9 1/4");
assert_eq!(with_units(5, 4, 3.0e17), "300000000000000000");
}
#[test]
fn architectural_carries_into_feet() {
assert_eq!(with_units(4, 4, 11.99), "0'-11 63/64\"");
assert_eq!(with_units(4, 4, 11.999), "1'-0\"");
assert_eq!(with_units(4, 4, 23.999), "2'-0\"");
assert_eq!(with_units(4, 4, 66.5), "5'-6 1/2\"");
assert_eq!(with_units(4, 4, -11.999), "-1'-0\"");
}
#[test]
fn engineering_carries_into_feet() {
assert_eq!(with_units(3, 1, 11.94), "0'-11.9\"");
assert_eq!(with_units(3, 1, 11.99), "1'-0.0\"");
assert_eq!(with_units(3, 1, 23.99), "2'-0.0\"");
assert_eq!(with_units(3, 2, 11.999), "1'-0.00\"");
assert_eq!(with_units(3, 1, 66.5), "5'-6.5\"");
}
#[test]
fn formatted_lengths_read_back() {
for lunits in [3, 4, 5] {
for value in [0.995f64, 5.995, 11.999, 23.999, 66.5, 9.25] {
let shown = with_units(lunits, if lunits == 3 { 4 } else { 4 }, value);
let read = parse_length(&shown)
.unwrap_or_else(|| panic!("lunits {lunits} wrote {shown:?}, which does not read back"));
assert!(
(read - value).abs() < 0.02,
"lunits {lunits}: {value} wrote {shown:?}, read back as {read}"
);
}
}
}
}
#[cfg(test)]
mod angle_format_tests {
use super::*;
fn shown(aunits: i16, auprec: i16, degrees: f64) -> String {
let mut ctx = unit_context();
ctx.aunits = aunits;
ctx.auprec = auprec;
set_unit_context(ctx);
format_angle(degrees.to_radians())
}
#[test]
fn degrees_minutes_seconds_carry() {
assert_eq!(shown(1, 0, 45.9999), "46d0'0\"");
assert_eq!(shown(1, 0, 89.99999), "90d0'0\"");
assert_eq!(shown(1, 0, 0.99999), "1d0'0\"");
assert_eq!(shown(1, 0, 45.5), "45d30'0\"");
assert_eq!(shown(1, 2, 45.9999), "45d59'59.64\"");
assert_eq!(shown(1, 2, 45.5), "45d30'0.00\"");
}
#[test]
fn surveyor_bearings_stay_inside_a_quadrant() {
assert_eq!(shown(4, 0, 359.99999), "E");
assert_eq!(shown(4, 0, 89.99999), "N");
assert_eq!(shown(4, 0, 45.0), "N 45d0'0\" E");
assert_eq!(shown(4, 0, 180.0), "W");
assert_ne!(shown(4, 8, 0.0000000005), "E");
for prec in [0, 2] {
for deg in [0.99999f64, 45.9999, 89.99999, 179.99999, 269.99999, 359.99999] {
let text = shown(4, prec, deg);
assert!(
!text.contains("90d"),
"prec {prec}: {deg} wrote {text:?}, a quarter turn inside a quadrant"
);
}
}
}
#[test]
fn formatted_angles_read_back() {
for aunits in [0, 1, 4] {
for prec in [0, 2] {
for deg in [0.99999f64, 45.5, 45.9999, 89.99999, 200.25, 359.99999] {
let text = shown(aunits, prec, deg);
let read = parse_angle(&text).unwrap_or_else(|| {
panic!("aunits {aunits} prec {prec} wrote {text:?}, which does not read back")
});
let delta = (read.to_degrees().rem_euclid(360.0) - deg.rem_euclid(360.0))
.rem_euclid(360.0);
let delta = delta.min(360.0 - delta);
assert!(
delta < 1.0,
"aunits {aunits} prec {prec}: {deg} wrote {text:?}, read back as {}",
read.to_degrees()
);
}
}
}
}
}