mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-26 03:46:53 -06:00
Found while testing crates/render/src/units.rs:139-160 (format_linear, unit 3 and 4 arms) and feet_inches at :101 Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
363 lines
14 KiB
Rust
363 lines
14 KiB
Rust
//! Dimension number formatting: DIMLUNIT (scientific, decimal, engineering, architectural,
|
|
//! fractional), DIMDEC precision, DIMZIN zero suppression, DIMRND rounding, DIMDSEP and
|
|
//! DIMFRAC fraction stacking; DIMAUNIT angles. Each formatter returns the MTEXT form (stacked
|
|
//! fractions as `\S1/2;`) that the dimension text is laid out from.
|
|
|
|
use cadcraft_doc::DimStyle;
|
|
|
|
/// Linear number format settings.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct NumFormat {
|
|
/// DIMLUNIT: 1 scientific, 2 decimal, 3 engineering, 4 architectural, 5 fractional, 6 decimal.
|
|
pub unit: u8,
|
|
/// DIMDEC: decimal places, or the fraction precision 1/2^n for architectural/fractional.
|
|
pub decimals: u8,
|
|
/// DIMZIN bits: low two bits feet/inch zeros, 4 leading zeros, 8 trailing zeros.
|
|
pub zin: u8,
|
|
/// DIMRND (0 = none).
|
|
pub round: f64,
|
|
pub separator: String,
|
|
/// DIMFRAC: 0 horizontal stack, 1 diagonal, 2 not stacked.
|
|
pub frac: u8,
|
|
}
|
|
|
|
impl NumFormat {
|
|
pub fn decimal(decimals: u8) -> Self {
|
|
NumFormat { unit: 2, decimals, zin: 0, round: 0.0, separator: ".".into(), frac: 0 }
|
|
}
|
|
pub fn from_style(st: &DimStyle) -> Self {
|
|
NumFormat {
|
|
unit: st.linear_unit,
|
|
decimals: st.decimals,
|
|
zin: st.zero_suppression,
|
|
round: st.round,
|
|
separator: st.decimal_separator.clone(),
|
|
frac: st.fraction_format,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Decimal number with leading (`zin & 4`) / trailing (`zin & 8`) zero suppression.
|
|
pub fn format_decimal(v: f64, decimals: u8, zin: u8, sep: &str) -> String {
|
|
let v = if v.is_finite() { v } else { 0.0 };
|
|
let mut s = format!("{:.*}", usize::from(decimals.min(8)), v);
|
|
if s.starts_with('-') && s.trim_start_matches(['-', '0', '.']).is_empty() {
|
|
s.remove(0);
|
|
}
|
|
if zin & 8 != 0 && s.contains('.') {
|
|
s = s.trim_end_matches('0').trim_end_matches('.').to_string();
|
|
}
|
|
if zin & 4 != 0 {
|
|
if let Some(r) = s.strip_prefix("0.") {
|
|
s = format!(".{r}");
|
|
} else if let Some(r) = s.strip_prefix("-0.") {
|
|
s = format!("-.{r}");
|
|
}
|
|
}
|
|
if s.is_empty() || s == "-" {
|
|
s = "0".into();
|
|
}
|
|
if sep != "." && !sep.is_empty() { s.replace('.', sep) } else { s }
|
|
}
|
|
|
|
fn gcd(a: i64, b: i64) -> i64 {
|
|
let (mut a, mut b) = (a.abs(), b.abs());
|
|
while b != 0 {
|
|
let t = a % b;
|
|
a = b;
|
|
b = t;
|
|
}
|
|
a.max(1)
|
|
}
|
|
|
|
/// A fraction in MTEXT form per DIMFRAC.
|
|
fn frac_mtext(num: i64, den: i64, frac: u8) -> (String, String) {
|
|
let plain = format!("{num}/{den}");
|
|
let m = match frac {
|
|
1 => format!("\\S{num}#{den};"),
|
|
2 => plain.clone(),
|
|
_ => format!("\\S{num}/{den};"),
|
|
};
|
|
(plain, m)
|
|
}
|
|
|
|
/// Scientific: `1.2345E+01`.
|
|
fn scientific(v: f64, decimals: u8, zin: u8, sep: &str) -> String {
|
|
if v == 0.0 || !v.is_finite() {
|
|
return format!("{}E+00", format_decimal(0.0, decimals, zin & 8, sep));
|
|
}
|
|
let mut exp = v.abs().log10().floor() as i32;
|
|
let mut mant = v / 10f64.powi(exp);
|
|
// Rounding can carry the mantissa to 10.
|
|
let p = 10f64.powi(i32::from(decimals.min(8)));
|
|
if (mant.abs() * p).round() / p >= 10.0 {
|
|
mant /= 10.0;
|
|
exp += 1;
|
|
}
|
|
format!("{}E{}{:02}", format_decimal(mant, decimals, zin & 8, sep), if exp < 0 { '-' } else { '+' }, exp.abs())
|
|
}
|
|
|
|
/// Apply feet/inch zero suppression (DIMZIN low bits) and join the parts.
|
|
fn feet_inches(neg: bool, ft: i64, inch_plain: &str, inch_m: &str, inch_zero: bool, zin: u8) -> (String, String) {
|
|
let mode = zin & 3;
|
|
let show_ft = ft != 0 || mode == 1 || mode == 2;
|
|
let show_in = !inch_zero || mode == 1 || mode == 3 || (ft == 0 && !show_ft);
|
|
// A negative value that rounds to zero prints without a sign.
|
|
let sign = if neg && (ft != 0 || !inch_zero) { "-" } else { "" };
|
|
let mut p = String::from(sign);
|
|
let mut m = String::from(sign);
|
|
if show_ft {
|
|
p += &format!("{ft}'");
|
|
m += &format!("{ft}'");
|
|
if show_in {
|
|
p.push('-');
|
|
m.push('-');
|
|
}
|
|
}
|
|
if show_in {
|
|
p += &format!("{inch_plain}\"");
|
|
m += &format!("{inch_m}\"");
|
|
}
|
|
(p, m)
|
|
}
|
|
|
|
/// Whole number plus a fraction of 1/2^prec, in plain and MTEXT form.
|
|
fn mixed(a: f64, prec: u8, frac: u8) -> (i64, String, String, bool) {
|
|
let den = 1i64 << prec.min(8);
|
|
let total = (a * den as f64).round().clamp(-9.0e15, 9.0e15) as i64;
|
|
let whole = total / den;
|
|
let rem = total % den;
|
|
if rem == 0 {
|
|
return (whole, whole.to_string(), whole.to_string(), whole == 0);
|
|
}
|
|
let g = gcd(rem, den);
|
|
let (fp, fm) = frac_mtext(rem / g, den / g, frac);
|
|
let (p, m) = if whole == 0 {
|
|
(fp, fm)
|
|
} else if frac == 2 {
|
|
(format!("{whole} {fp}"), format!("{whole} {fm}"))
|
|
} else {
|
|
(format!("{whole} {fp}"), format!("{whole}{fm}"))
|
|
};
|
|
(whole, p, m, false)
|
|
}
|
|
|
|
/// Format a linear distance. Returns (plain text, MTEXT form).
|
|
pub fn format_linear(v: f64, f: &NumFormat) -> (String, String) {
|
|
let mut v = if v.is_finite() { v } else { 0.0 };
|
|
if f.round > 0.0 && f.round.is_finite() {
|
|
v = (v / f.round).round() * f.round;
|
|
}
|
|
let sep = f.separator.as_str();
|
|
match f.unit {
|
|
1 => {
|
|
let s = scientific(v, f.decimals, f.zin, sep);
|
|
(s.clone(), s)
|
|
}
|
|
3 => {
|
|
// Engineering: feet and decimal inches.
|
|
let neg = v < 0.0;
|
|
let a = v.abs();
|
|
let p = 10f64.powi(i32::from(f.decimals.min(8)));
|
|
let a = (a * p).round() / p;
|
|
let ft = (a / 12.0 + 1e-12).floor();
|
|
let inch = (a - ft * 12.0).max(0.0);
|
|
let s = format_decimal(inch, f.decimals, f.zin & 12, sep);
|
|
feet_inches(neg, ft as i64, &s, &s, inch.abs() < 0.5 / p, f.zin)
|
|
}
|
|
4 => {
|
|
// Architectural: feet, inches and fractions.
|
|
let neg = v < 0.0;
|
|
let den = 1i64 << f.decimals.min(8);
|
|
let total = (v.abs() * den as f64).round().clamp(0.0, 9.0e15) as i64;
|
|
let ft = total / (12 * den);
|
|
let rem_inch = (total - ft * 12 * den) as f64 / den as f64;
|
|
let (_, ip, im, zero) = mixed(rem_inch, f.decimals, f.frac);
|
|
feet_inches(neg, ft, &ip, &im, zero, f.zin)
|
|
}
|
|
5 => {
|
|
let neg = v < 0.0;
|
|
let (_, p, m, _) = mixed(v.abs(), f.decimals, f.frac);
|
|
let sign = if neg && p != "0" { "-" } else { "" };
|
|
(format!("{sign}{p}"), format!("{sign}{m}"))
|
|
}
|
|
_ => {
|
|
let s = format_decimal(v, f.decimals, f.zin, sep);
|
|
(s.clone(), s)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Format an angle (radians, non-negative sweep) per DIMAUNIT / DIMADEC.
|
|
pub fn format_angle(rad: f64, unit: u8, decimals: u8, zin: u8) -> String {
|
|
let rad = if rad.is_finite() { rad } else { 0.0 };
|
|
let deg = rad.to_degrees();
|
|
match unit {
|
|
1 => {
|
|
// Round at the displayed precision before splitting into degrees/minutes/seconds.
|
|
let seconds_per_unit = match decimals {
|
|
0 => 3600,
|
|
1 | 2 => 60,
|
|
_ => 1,
|
|
};
|
|
let units = (deg * (3600 / seconds_per_unit) as f64).round().max(0.0) as i64;
|
|
let total = units.saturating_mul(seconds_per_unit);
|
|
let (d, m, s) = (total / 3600, (total / 60) % 60, total % 60);
|
|
match decimals {
|
|
0 => format!("{d}°"),
|
|
1 | 2 => format!("{d}°{m}'"),
|
|
_ => format!("{d}°{m}'{s}\""),
|
|
}
|
|
}
|
|
2 => format!("{}g", format_decimal(deg / 0.9, decimals, zin, ".")),
|
|
3 => format!("{}r", format_decimal(rad, decimals, zin, ".")),
|
|
_ => format!("{}°", format_decimal(deg, decimals, zin, ".")),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn f(unit: u8, decimals: u8, zin: u8) -> NumFormat {
|
|
NumFormat { unit, decimals, zin, ..NumFormat::decimal(decimals) }
|
|
}
|
|
|
|
#[test]
|
|
fn decimal_and_zero_suppression() {
|
|
assert_eq!(format_linear(0.5, &f(2, 4, 0)).0, "0.5000");
|
|
assert_eq!(format_linear(0.5, &f(2, 4, 4)).0, ".5000");
|
|
assert_eq!(format_linear(0.5, &f(2, 4, 8)).0, "0.5");
|
|
assert_eq!(format_linear(0.5, &f(2, 4, 12)).0, ".5");
|
|
assert_eq!(format_linear(12.0, &f(2, 2, 8)).0, "12");
|
|
assert_eq!(format_linear(-0.00001, &f(2, 2, 0)).0, "0.00");
|
|
let mut c = f(2, 2, 0);
|
|
c.separator = ",".into();
|
|
assert_eq!(format_linear(12.5, &c).0, "12,50");
|
|
}
|
|
|
|
#[test]
|
|
fn rounding() {
|
|
let mut c = f(2, 2, 0);
|
|
c.round = 0.25;
|
|
assert_eq!(format_linear(1.13, &c).0, "1.25");
|
|
}
|
|
|
|
#[test]
|
|
fn scientific_units() {
|
|
assert_eq!(format_linear(12.345, &f(1, 2, 0)).0, "1.23E+01");
|
|
assert_eq!(format_linear(0.05, &f(1, 1, 0)).0, "5.0E-02");
|
|
assert_eq!(format_linear(9.999, &f(1, 2, 0)).0, "1.00E+01");
|
|
}
|
|
|
|
#[test]
|
|
fn engineering_units() {
|
|
assert_eq!(format_linear(18.5, &f(3, 2, 0)).0, "1'-6.50\"");
|
|
assert_eq!(format_linear(6.0, &f(3, 2, 0)).0, "6.00\"");
|
|
assert_eq!(format_linear(6.0, &f(3, 2, 1)).0, "0'-6.00\"");
|
|
}
|
|
|
|
#[test]
|
|
fn architectural_units_and_stacking() {
|
|
let (p, m) = format_linear(18.5, &f(4, 4, 0));
|
|
assert_eq!(p, "1'-6 1/2\"");
|
|
assert_eq!(m, "1'-6\\S1/2;\"");
|
|
assert_eq!(format_linear(24.0, &f(4, 4, 0)).0, "2'");
|
|
assert_eq!(format_linear(24.0, &f(4, 4, 1)).0, "2'-0\"");
|
|
assert_eq!(format_linear(24.0, &f(4, 4, 3)).0, "2'-0\"");
|
|
assert_eq!(format_linear(6.0, &f(4, 4, 0)).0, "6\"");
|
|
assert_eq!(format_linear(6.0, &f(4, 4, 2)).0, "0'-6\"");
|
|
assert_eq!(format_linear(0.25, &f(4, 4, 0)).0, "1/4\"");
|
|
assert_eq!(format_linear(0.0, &f(4, 4, 0)).0, "0\"");
|
|
let mut d = f(4, 4, 0);
|
|
d.frac = 1;
|
|
assert_eq!(format_linear(18.5, &d).1, "1'-6\\S1#2;\"");
|
|
}
|
|
|
|
#[test]
|
|
fn fractional_units() {
|
|
assert_eq!(format_linear(6.5, &f(5, 4, 0)).0, "6 1/2");
|
|
assert_eq!(format_linear(6.5, &f(5, 4, 0)).1, "6\\S1/2;");
|
|
assert_eq!(format_linear(6.0, &f(5, 4, 0)).0, "6");
|
|
assert_eq!(format_linear(0.375, &f(5, 3, 0)).0, "3/8");
|
|
assert_eq!(format_linear(0.4, &f(5, 2, 0)).0, "1/2");
|
|
}
|
|
|
|
#[test]
|
|
fn negative_values_rounding_to_zero_have_no_sign() {
|
|
for unit in 1..=5 {
|
|
for decimals in [0, 2, 4] {
|
|
for zin in [0, 1] {
|
|
for v in [-1e-9, -0.0] {
|
|
let (p, m) = format_linear(v, &f(unit, decimals, zin));
|
|
assert!(!p.starts_with('-') || unit == 1 && v != 0.0, "unit {unit} decimals {decimals} zin {zin}: {p}");
|
|
assert!(!m.starts_with('-') || unit == 1 && v != 0.0, "unit {unit} decimals {decimals} zin {zin}: {m}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// DIMRND rounding a small negative value to zero, in every unit.
|
|
for unit in 1..=5 {
|
|
let mut c = f(unit, 4, 0);
|
|
c.round = 0.25;
|
|
let (p, m) = format_linear(-0.1, &c);
|
|
assert!(!p.starts_with('-') && !m.starts_with('-'), "unit {unit} with DIMRND: {p} / {m}");
|
|
}
|
|
assert_eq!(format_linear(-1e-9, &f(4, 4, 0)).0, "0\"");
|
|
assert_eq!(format_linear(-1e-9, &f(3, 4, 0)).0, "0.0000\"");
|
|
// Scientific keeps the sign: the mantissa of a tiny negative value is not zero.
|
|
assert_eq!(format_linear(-1e-9, &f(1, 2, 0)).0, "-1.00E-09");
|
|
// The shared feet/inch joiner never signs an all-zero result.
|
|
assert_eq!(feet_inches(true, 0, "0", "0", true, 0).0, "0\"");
|
|
assert_eq!(feet_inches(true, 0, "0", "0", true, 1).0, "0'-0\"");
|
|
// Genuinely negative values keep the sign.
|
|
assert_eq!(format_linear(-18.5, &f(4, 4, 0)).0, "-1'-6 1/2\"");
|
|
assert_eq!(format_linear(-18.5, &f(3, 2, 0)).0, "-1'-6.50\"");
|
|
assert_eq!(format_linear(-24.0, &f(4, 4, 0)).0, "-2'");
|
|
}
|
|
|
|
#[test]
|
|
fn angles() {
|
|
assert_eq!(format_angle(std::f64::consts::FRAC_PI_4, 0, 0, 0), "45°");
|
|
assert_eq!(format_angle(45.5f64.to_radians(), 1, 4, 0), "45°30'0\"");
|
|
assert_eq!(format_angle(std::f64::consts::FRAC_PI_2, 2, 1, 0), "100.0g");
|
|
assert_eq!(format_angle(1.0, 3, 2, 0), "1.00r");
|
|
}
|
|
|
|
#[test]
|
|
fn dms_rounds_at_displayed_precision() {
|
|
assert_eq!(format_angle(45.75f64.to_radians(), 1, 0, 0), "46°");
|
|
for decimals in [1, 2] {
|
|
assert_eq!(format_angle(45.5125f64.to_radians(), 1, decimals, 0), "45°31'");
|
|
assert_eq!(format_angle(45.999f64.to_radians(), 1, decimals, 0), "46°0'");
|
|
}
|
|
assert_eq!(format_angle(45.5125f64.to_radians(), 1, 3, 0), "45°30'45\"");
|
|
assert_eq!(format_angle(45.9999f64.to_radians(), 1, 4, 0), "46°0'0\"");
|
|
}
|
|
|
|
#[test]
|
|
fn dms_carries_and_non_negative_sweeps() {
|
|
let near_thirty = (29.0 + 59.0 / 60.0 + 40.0 / 3600.0_f64).to_radians();
|
|
let near_minute = (29.0 + 40.0 / 3600.0_f64).to_radians();
|
|
for decimals in 0..=4 {
|
|
let (degree, minute, negative) = match decimals {
|
|
0 => ("30°", "29°", "0°"),
|
|
1 | 2 => ("30°0'", "29°1'", "0°0'"),
|
|
_ => ("29°59'40\"", "29°0'40\"", "0°0'0\""),
|
|
};
|
|
assert_eq!(format_angle(near_thirty, 1, decimals, 0), degree);
|
|
assert_eq!(format_angle(near_minute, 1, decimals, 0), minute);
|
|
assert_eq!(format_angle(-45.75f64.to_radians(), 1, decimals, 0), negative);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_numbers() {
|
|
for u in 0..8 {
|
|
for v in [f64::NAN, f64::INFINITY, -1e300, 1e300, 0.0, -0.0] {
|
|
let _ = format_linear(v, &f(u, 200, 255));
|
|
}
|
|
}
|
|
}
|
|
}
|