diff --git a/src/entities/common.rs b/src/entities/common.rs index f67bc2baf..617bf97fe 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -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() + ); + } + } + } + } +}