use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const UNITS_FILE: &str = "units.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum QuantityKind {
#[default]
Delta,
Absolute,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PrecisionMode {
#[default]
Decimals,
Significant,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SystemScale {
pub label: String,
pub scalar: f64,
#[serde(default, skip_serializing_if = "is_zero")]
pub offset: f64,
#[serde(default = "default_precision")]
pub precision: u32,
#[serde(default, skip_serializing_if = "is_default_precision_mode")]
pub precision_mode: PrecisionMode,
}
fn is_zero(v: &f64) -> bool {
*v == 0.0
}
fn default_precision() -> u32 {
3
}
fn is_default_precision_mode(m: &PrecisionMode) -> bool {
*m == PrecisionMode::Decimals
}
impl SystemScale {
pub fn new(label: &str, scalar: f64, precision: u32) -> Self {
Self {
label: label.to_string(),
scalar,
offset: 0.0,
precision,
precision_mode: PrecisionMode::Decimals,
}
}
pub fn affine(label: &str, scalar: f64, offset: f64, precision: u32) -> Self {
Self {
label: label.to_string(),
scalar,
offset,
precision,
precision_mode: PrecisionMode::Decimals,
}
}
pub fn significant(mut self, digits: u32) -> Self {
self.precision = digits;
self.precision_mode = PrecisionMode::Significant;
self
}
pub fn to_display(&self, backend: f64) -> f64 {
backend * self.scalar + self.offset
}
pub fn to_backend(&self, display: f64) -> f64 {
(display - self.offset) / self.scalar
}
pub fn format(&self, backend: f64) -> String {
format_value(self.to_display(backend), self.precision, self.precision_mode)
}
}
pub fn format_value(v: f64, precision: u32, mode: PrecisionMode) -> String {
if !v.is_finite() {
return v.to_string();
}
let s = match mode {
PrecisionMode::Decimals => format!("{:.*}", precision as usize, v),
PrecisionMode::Significant => {
let digits = precision.max(1);
if v == 0.0 {
"0".to_string()
} else {
let exp = v.abs().log10().floor() as i32;
let decimals = (digits as i32 - 1 - exp).max(0) as usize;
format!("{:.*}", decimals, v)
}
}
};
if s.contains('.') {
let t = s.trim_end_matches('0').trim_end_matches('.');
if t.is_empty() || t == "-" {
"0".to_string()
} else {
t.to_string()
}
} else {
s
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Scale {
pub backend: String,
#[serde(default, skip_serializing_if = "is_delta")]
pub kind: QuantityKind,
#[serde(flatten)]
pub systems: BTreeMap<String, SystemScale>,
}
fn is_delta(k: &QuantityKind) -> bool {
*k == QuantityKind::Delta
}
impl Scale {
pub fn new(backend: &str, systems: impl IntoIterator<Item = (String, SystemScale)>) -> Self {
Self {
backend: backend.to_string(),
kind: QuantityKind::Delta,
systems: systems.into_iter().collect(),
}
}
pub fn absolute(
backend: &str,
systems: impl IntoIterator<Item = (String, SystemScale)>,
) -> Self {
Self {
backend: backend.to_string(),
kind: QuantityKind::Absolute,
systems: systems.into_iter().collect(),
}
}
pub fn for_system(&self, system: &str) -> Option<&SystemScale> {
self.systems
.get(system)
.or_else(|| self.systems.values().find(|s| s.label == self.backend))
.or_else(|| self.systems.values().next())
}
pub fn by_label(&self, label: &str) -> Option<&SystemScale> {
self.systems.values().find(|s| s.label == label)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Units {
pub systems: Vec<String>,
pub default_system: String,
pub scales: BTreeMap<String, Scale>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub configurations: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_configuration: Option<String>,
}
impl Units {
pub fn scale(&self, quantity: &str, system: &str) -> Option<&SystemScale> {
self.scales.get(quantity)?.for_system(system)
}
pub fn label(&self, quantity: &str, system: &str) -> Option<&str> {
self.scale(quantity, system).map(|s| s.label.as_str())
}
pub fn format(&self, quantity: &str, system: &str, backend: f64) -> String {
match self.scale(quantity, system) {
Some(s) => s.format(backend),
None => backend.to_string(),
}
}
pub fn backend_stamp(&self) -> BTreeMap<String, String> {
self.scales
.iter()
.map(|(q, s)| (q.clone(), s.backend.clone()))
.collect()
}
pub fn convert_from_label(
&self,
quantity: &str,
from_label: &str,
value: f64,
) -> Result<Option<f64>, UnitsError> {
let scale = self
.scales
.get(quantity)
.ok_or_else(|| UnitsError::UnknownQuantity(quantity.to_string()))?;
if scale.backend == from_label {
return Ok(None); }
let entry = scale
.by_label(from_label)
.ok_or_else(|| UnitsError::UnconvertibleLabel {
quantity: quantity.to_string(),
label: from_label.to_string(),
backend: scale.backend.clone(),
})?;
Ok(Some(entry.to_backend(value)))
}
pub fn validate(&self) -> Vec<String> {
let mut out = Vec::new();
if self.systems.is_empty() {
out.push("units: `systems` is empty — at least one system is required".into());
}
if !self.systems.iter().any(|s| s == &self.default_system) {
out.push(format!(
"units: default_system '{}' is not one of {:?}",
self.default_system, self.systems
));
}
for (name, scale) in &self.scales {
if scale.backend.trim().is_empty()
&& scale.systems.values().any(|s| !s.label.trim().is_empty())
{
out.push(format!(
"units.scales.{name}: `backend` is empty but its systems have \
labels — declare the unit GM actually holds"
));
}
for sys in &self.systems {
let Some(entry) = scale.systems.get(sys) else {
out.push(format!(
"units.scales.{name}: no entry for system '{sys}'"
));
continue;
};
if !entry.scalar.is_finite() || entry.scalar == 0.0 {
out.push(format!(
"units.scales.{name}.{sys}: scalar must be finite and non-zero (got {})",
entry.scalar
));
}
if entry.offset != 0.0 && scale.kind != QuantityKind::Absolute {
out.push(format!(
"units.scales.{name}.{sys}: `offset` is only valid on an \
`absolute` quantity (a difference must not take the offset)"
));
}
if entry.precision_mode == PrecisionMode::Significant && entry.precision == 0 {
out.push(format!(
"units.scales.{name}.{sys}: significant precision must be >= 1"
));
}
if entry.label == scale.backend
&& (entry.scalar != 1.0 || entry.offset != 0.0)
{
out.push(format!(
"units.scales.{name}.{sys}: label '{}' equals the declared backend, \
so scalar must be 1.0 and offset 0 (got scalar {}, offset {})",
entry.label, entry.scalar, entry.offset
));
}
}
}
out
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum UnitsError {
UnknownQuantity(String),
UnconvertibleLabel {
quantity: String,
label: String,
backend: String,
},
}
impl std::fmt::Display for UnitsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UnitsError::UnknownQuantity(q) => {
write!(f, "no scale group named '{q}' in units.json")
}
UnitsError::UnconvertibleLabel { quantity, label, backend } => write!(
f,
"authored in '{label}'; this machine stores {quantity} in '{backend}' and has \
no '{label}' entry — add one to the units table, or convert by hand"
),
}
}
}
impl std::error::Error for UnitsError {}
pub fn default_units() -> Units {
let m = "Metric".to_string();
let i = "Imperial".to_string();
let pair = |metric: SystemScale, imperial: SystemScale| {
vec![(m.clone(), metric), (i.clone(), imperial)]
};
let mut scales = BTreeMap::new();
scales.insert(
"position".to_string(),
Scale::new("mm", pair(
SystemScale::new("mm", 1.0, 3),
SystemScale::new("in", 1.0 / 25.4, 4),
)),
);
scales.insert(
"length".to_string(),
Scale::new("mm", pair(
SystemScale::new("mm", 1.0, 3),
SystemScale::new("in", 1.0 / 25.4, 4),
)),
);
scales.insert(
"force".to_string(),
Scale::new("N", pair(
SystemScale::new("N", 1.0, 2),
SystemScale::new("lbf", 0.224_808_943_099_71, 3),
)),
);
scales.insert(
"velocity".to_string(),
Scale::new("mm/s", pair(
SystemScale::new("mm/s", 1.0, 3),
SystemScale::new("in/s", 1.0 / 25.4, 4),
)),
);
scales.insert(
"acceleration".to_string(),
Scale::new("mm/s²", pair(
SystemScale::new("mm/s²", 1.0, 2),
SystemScale::new("in/s²", 1.0 / 25.4, 3),
)),
);
scales.insert(
"angle".to_string(),
Scale::new("deg", pair(
SystemScale::new("deg", 1.0, 2),
SystemScale::new("deg", 1.0, 2),
)),
);
scales.insert(
"angular_velocity".to_string(),
Scale::new("deg/s", pair(
SystemScale::new("deg/s", 1.0, 2),
SystemScale::new("deg/s", 1.0, 2),
)),
);
scales.insert(
"angular_acceleration".to_string(),
Scale::new("deg/s²", pair(
SystemScale::new("deg/s²", 1.0, 2),
SystemScale::new("deg/s²", 1.0, 2),
)),
);
scales.insert(
"torque".to_string(),
Scale::new("N·m", pair(
SystemScale::new("N·m", 1.0, 3),
SystemScale::new("lbf·in", 8.850_745_79, 3),
)),
);
scales.insert(
"pressure".to_string(),
Scale::new("kPa", pair(
SystemScale::new("kPa", 1.0, 2),
SystemScale::new("psi", 0.145_037_738, 3),
)),
);
scales.insert(
"mass".to_string(),
Scale::new("kg", pair(
SystemScale::new("kg", 1.0, 3),
SystemScale::new("lb", 2.204_622_62, 3),
)),
);
scales.insert(
"time".to_string(),
Scale::new("s", pair(
SystemScale::new("s", 1.0, 3),
SystemScale::new("s", 1.0, 3),
)),
);
scales.insert(
"temperature".to_string(),
Scale::absolute("degC", pair(
SystemScale::affine("°C", 1.0, 0.0, 1),
SystemScale::affine("°F", 1.8, 32.0, 1),
)),
);
scales.insert(
"temperature_delta".to_string(),
Scale::new("degC", pair(
SystemScale::new("°C", 1.0, 1),
SystemScale::new("°F", 1.8, 1),
)),
);
scales.insert(
"dimensionless".to_string(),
Scale::new("", pair(
SystemScale::new("", 1.0, 3),
SystemScale::new("", 1.0, 3),
)),
);
Units {
systems: vec![m, i],
default_system: "Metric".to_string(),
scales,
configurations: None,
default_configuration: None,
}
}
pub fn units_path(project_file: &std::path::Path) -> std::path::PathBuf {
project_file
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join(UNITS_FILE)
}
pub fn load_units(
project_file: &std::path::Path,
active_configuration: Option<&str>,
) -> Result<Option<Units>, String> {
let path = units_path(project_file);
if !path.exists() {
return Ok(None);
}
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("read {}: {e}", path.display()))?;
let raw: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| format!("parse {}: {e}", path.display()))?;
let resolved = if raw.get("configurations").is_some() {
crate::config_overlay::resolve_active_config(&raw, active_configuration)
.map_err(|e| format!("{}: {e}", path.display()))?
} else {
raw
};
let units: Units = serde_json::from_value(resolved)
.map_err(|e| format!("{}: {e}", path.display()))?;
Ok(Some(units))
}
pub fn save_units(project_file: &std::path::Path, units: &Units) -> Result<(), String> {
let path = units_path(project_file);
let text = serde_json::to_string_pretty(units)
.map_err(|e| format!("serialize units: {e}"))?;
if path.exists() {
let _ = std::fs::copy(&path, path.with_extension("json.bak"));
}
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, text).map_err(|e| format!("write {}: {e}", tmp.display()))?;
std::fs::rename(&tmp, &path).map_err(|e| format!("rename into {}: {e}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_table_validates() {
let findings = default_units().validate();
assert!(findings.is_empty(), "{findings:#?}");
}
#[test]
fn converts_backend_to_display_and_back() {
let u = default_units();
let imperial = u.scale("position", "Imperial").unwrap();
assert!((imperial.to_display(25.4) - 1.0).abs() < 1e-9);
assert!((imperial.to_backend(1.0) - 25.4).abs() < 1e-9);
}
#[test]
fn temperature_delta_does_not_take_the_offset() {
let u = default_units();
let abs = u.scale("temperature", "Imperial").unwrap();
assert!((abs.to_display(100.0) - 212.0).abs() < 1e-9);
assert!((abs.to_display(0.0) - 32.0).abs() < 1e-9);
let delta = u.scale("temperature_delta", "Imperial").unwrap();
assert!(
(delta.to_display(10.0) - 18.0).abs() < 1e-9,
"a 10 degC rise must read as 18 degF, not 50"
);
}
#[test]
fn precision_caps_without_padding() {
assert_eq!(format_value(2.0, 3, PrecisionMode::Decimals), "2");
assert_eq!(format_value(2.34567, 3, PrecisionMode::Decimals), "2.346");
assert_eq!(
format_value(0.30000000000000004, 2, PrecisionMode::Decimals),
"0.3"
);
}
#[test]
fn significant_mode_tracks_magnitude() {
assert_eq!(format_value(0.001234, 3, PrecisionMode::Significant), "0.00123");
assert_eq!(format_value(5432.1, 3, PrecisionMode::Significant), "5432");
assert_eq!(format_value(0.0, 3, PrecisionMode::Significant), "0");
}
#[test]
fn imports_a_method_by_inverting_its_own_display_entry() {
let mut u = default_units();
u.scales.insert(
"position".to_string(),
Scale::new("m", vec![
("Metric".to_string(), SystemScale::new("mm", 1000.0, 1)),
("Imperial".to_string(), SystemScale::new("in", 39.370_078_74, 3)),
]),
);
let converted = u.convert_from_label("position", "mm", 68.0).unwrap();
assert!(
(converted.unwrap() - 0.068).abs() < 1e-12,
"68 mm must import as 0.068 m"
);
}
#[test]
fn import_is_a_noop_when_the_stamp_matches_the_backend() {
let u = default_units();
assert_eq!(u.convert_from_label("force", "N", 50.0).unwrap(), None);
}
#[test]
fn import_refuses_a_label_absent_from_this_machines_table() {
let u = default_units(); let err = u.convert_from_label("force", "kN", 5.0).unwrap_err();
match &err {
UnitsError::UnconvertibleLabel { label, backend, .. } => {
assert_eq!(label, "kN");
assert_eq!(backend, "N");
}
other => panic!("expected UnconvertibleLabel, got {other:?}"),
}
assert!(err.to_string().contains("add one to the units table"));
}
#[test]
fn validation_rejects_a_backend_entry_that_is_not_the_identity() {
let mut u = default_units();
u.scales.get_mut("position").unwrap().systems.get_mut("Metric").unwrap().scalar = 2.0;
let findings = u.validate();
assert!(
findings.iter().any(|f| f.contains("equals the declared backend")),
"{findings:#?}"
);
}
#[test]
fn validation_rejects_an_offset_on_a_delta_quantity() {
let mut u = default_units();
u.scales
.get_mut("temperature_delta")
.unwrap()
.systems
.get_mut("Imperial")
.unwrap()
.offset = 32.0;
let findings = u.validate();
assert!(
findings.iter().any(|f| f.contains("only valid on an")),
"{findings:#?}"
);
}
#[test]
fn backend_stamp_covers_every_quantity() {
let u = default_units();
let stamp = u.backend_stamp();
assert_eq!(stamp.get("force").map(String::as_str), Some("N"));
assert_eq!(stamp.get("position").map(String::as_str), Some("mm"));
assert_eq!(stamp.len(), u.scales.len());
}
#[test]
fn sidecar_round_trips_and_absent_is_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path().join("project.json");
std::fs::write(&project, "{}").unwrap();
assert_eq!(load_units(&project, None).unwrap(), None);
let u = default_units();
save_units(&project, &u).unwrap();
assert_eq!(load_units(&project, None).unwrap().as_ref(), Some(&u));
}
#[test]
fn per_build_overlay_can_change_the_backend() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path().join("project.json");
std::fs::write(&project, "{}").unwrap();
let mut raw = serde_json::to_value(default_units()).unwrap();
raw["configurations"] = serde_json::json!({
"kn_frame": { "scales": { "force": {
"backend": "kN",
"Metric": { "label": "kN", "scalar": 1.0, "precision": 3 }
} } }
});
std::fs::write(units_path(&project), serde_json::to_string(&raw).unwrap()).unwrap();
let base = load_units(&project, Some("kn_frame")).unwrap().unwrap();
assert_eq!(base.scales["force"].backend, "kN");
assert_eq!(base.scales["position"].backend, "mm");
}
#[test]
fn round_trips_through_json() {
let u = default_units();
let s = serde_json::to_string_pretty(&u).unwrap();
let back: Units = serde_json::from_str(&s).unwrap();
assert_eq!(u, back);
}
}