From 5c81d33027dcffd34e21aae3c5d2ed2c74a8b914 Mon Sep 17 00:00:00 2001 From: NgoQuocViet2001 Date: Wed, 19 Aug 2026 21:41:14 +0700 Subject: [PATCH 1/2] fix(units): carry a length that rounds up to the next unit format_length rounded the fraction after splitting the whole part off, so a value that rounded up to a full unit had nowhere to go: fractional 5.995 -> 5 (64/64 reduced to 1/1, then dropped) fractional 0.995 -> 0 architectural 11.999 -> 0'-11" architectural 23.999 -> 1'-11" engineering 11.99 -> 0'-12.0" (twelve inches is a foot) engineering 23.99 -> 1'-12.0" Round once, into the smallest unit that will be shown, then split feet and whole inches out of the rounded total. The carry reaches every column, so the same values now read 6, 1, 1'-0", 2'-0", 1'-0.0" and 2'-0.0". --- src/entities/common.rs | 105 +++++++++++++++++++++++++++++++++++------ 1 file changed, 90 insertions(+), 15 deletions(-) diff --git a/src/entities/common.rs b/src/entities/common.rs index 09a416c46..6a7c53d99 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -104,11 +104,16 @@ pub fn format_length(value: f64) -> String { match ctx.lunits { 1 => format!("{:.*e}", prec, value), 3 => { - // Engineering: ft-inches, decimal inches. + // Engineering: ft-inches, decimal inches. Round to the shown + // precision BEFORE splitting off the feet, so an inch count that + // rounds up to twelve carries instead of printing 0'-12.0". 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 => { @@ -117,17 +122,21 @@ pub fn format_length(value: f64) -> String { // result reads like 1/64". 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) - } else { - (None, abs) - }; - 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; + // Round once, into the smallest unit that will be shown, and + // split the feet and whole inches out of the rounded total. A + // fraction that rounds up to 64/64 then carries, instead of + // reducing to 1/1 and being dropped as "no fraction" — 5.999 + // read as 5, and 11.999 as 0'-11". + let sixtyfourths = (abs * denom as f64).round() as u64; + let (feet, rest) = if ctx.lunits == 4 { + let per_foot = 12 * denom; + (Some((sixtyfourths / per_foot) as i64), sixtyfourths % per_foot) + } else { + (None, sixtyfourths) + }; + let whole = rest / denom; + let mut n = rest % denom; let mut d = denom; while d > 1 && n % 2 == 0 && d % 2 == 0 { n /= 2; @@ -140,8 +149,8 @@ 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), - None => format!("{}{:.0}{}", sign, whole, frac_str), + Some(f) => format!("{}{}'-{}{}{}", sign, f, whole, frac_str, unit_suffix), + None => format!("{}{}{}", sign, whole, frac_str), } } _ => format!("{:.*}", prec, value), @@ -791,3 +800,69 @@ 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) + } + + /// A fraction that rounds up to a whole unit has to carry. It used to + /// reduce 64/64 to 1/1, which reads as "no fraction", so the fractional + /// part was dropped and the length shown a whole unit short. + #[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"); + // Values that do not round up are unchanged. + assert_eq!(with_units(5, 4, 0.5), "0 1/2"); + assert_eq!(with_units(5, 4, 9.25), "9 1/4"); + } + + /// The same carry has to reach the feet, or a foot's worth of inches + /// stays in the inches column. + #[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\""); + } + + /// Engineering rounds to the shown precision, so twelve inches after + /// rounding is a foot. It printed the impossible 0'-12.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\""); + } + + /// Whatever the drawing writes, it must read back the same. + #[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}" + ); + } + } + } +} From 2a26c8cad1d1f8c595d62341d69ce83165d00d77 Mon Sep 17 00:00:00 2001 From: NgoQuocViet2001 Date: Wed, 19 Aug 2026 21:46:44 +0700 Subject: [PATCH 2/2] fix(units): carry a rounded-up angle the same way dms() rounded the seconds after splitting the degrees and minutes off, so seconds that rounded up had nowhere to go: 45.9999 deg at AUPREC 0 -> 45d59'60" 89.99999 deg -> 89d59'60" Round once, into the smallest unit shown, and split degrees and minutes out of the rounded total, as format_length now does. surveyor() then needed the same treatment one level up: it chose its pole and quadrant from the unrounded direction, so a bearing that rounded to the quarter turn came out as the contradictory `S 90d0'0" E` the function's own doc comment sets out to avoid. Snap the direction to the shown resolution before deciding, so 359.99999 deg at AUPREC 0 is `E` rather than a bearing of ninety degrees. --- src/entities/common.rs | 90 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 5 deletions(-) diff --git a/src/entities/common.rs b/src/entities/common.rs index 6a7c53d99..67a7202b9 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -178,10 +178,17 @@ 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 once, into the smallest unit that will be shown, so seconds that + // round up carry into the minutes and degrees. Rounding the seconds after + // the split wrote the impossible 45d59'60". + 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) } @@ -192,7 +199,12 @@ 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); + // Snap to the resolution that will be shown before deciding anything, so + // a direction that rounds onto a cardinal is written as one -- and so the + // bearing handed to `dms` can no longer round up to the quarter turn that + // would read as the contradictory `N 90d0'0\" E`. + let scale = 3600.0 * 10f64.powi(prec.min(15) as i32); + let deg = ((value_rad.to_degrees().rem_euclid(360.0) * scale).round() / scale).rem_euclid(360.0); let near = |target: f64| (deg - target).abs() < 1e-9; if near(0.0) { return "E".into(); @@ -866,3 +878,71 @@ mod length_format_tests { } } } + +#[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()) + } + + /// Seconds that round up are a minute, and sixty minutes are a degree. + /// Rounding after the split wrote 45d59'60\", which no drawing produces. + #[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\""); + // Values that do not round up are unchanged. + 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\""); + } + + /// A bearing that rounds onto a cardinal is that cardinal — the quarter + /// turn must never be written as `S 90d0'0\" E`. + #[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"); + 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" + ); + } + } + } + + /// Whatever the readout shows must still read straight back. + #[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() + ); + } + } + } + } +}