diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 4cdc7b5e..006def55 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -338,6 +338,7 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap, rolled_ba let mut h = std::collections::hash_map::DefaultHasher::new(); prev.hash(&mut h); rolled_back.hash(&mut h); + vals.get(DESIGN_LEN).map(|v| v.v.to_bits()).hash(&mut h); serde_json::to_string(f).unwrap_or_default().hash(&mut h); for n in param_names(f) { n.hash(&mut h); @@ -360,6 +361,7 @@ fn context_digest(doc: &Document, vals: &BTreeMap) -> u64 { for f in doc.features.iter().filter(|f| matches!(f.kind, FeatureKind::ConstructionPlane { .. })) { fingerprint(0, f, vals, false).hash(&mut h); } + doc.units.hash(&mut h); serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h); serde_json::to_string(&doc.plastic).unwrap_or_default().hash(&mut h); serde_json::to_string(&doc.components).unwrap_or_default().hash(&mut h); @@ -429,7 +431,12 @@ impl Model { /// Re-evaluate the document, reusing results up to the first changed feature. pub fn evaluate(&mut self, doc: &Document) { - let (vals, perr) = doc.param_values(); + let (mut vals, perr) = doc.param_values(); + if let Some((Kind::Length, scale)) = expr::unit_info(doc.units.trim()) + && scale != 1.0 + { + vals.insert(DESIGN_LEN.into(), Value { v: scale, len: 0, ang: 0 }); + } self.param_errors = perr; let mut prev_fp = 0u64; let mut state = self.empty.clone(); @@ -586,7 +593,18 @@ impl Model { } } +/// Key of the design's length unit (mm per unit) among the values (no parameter can be named +/// so). +pub(crate) const DESIGN_LEN: &str = "\u{1}design_len"; + +/// An expression's value. A length that comes out unit-less without being a bare number (a +/// unit-less parameter used as a length) is in the design's units. fn val(vals: &BTreeMap, e: &str, k: Kind) -> Result { + let scale = vals.get(DESIGN_LEN).map(|x| x.v).unwrap_or(1.0); + if k == Kind::Length && scale != 1.0 && !expr::is_literal(e) { + let v = expr::eval_with(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))))?; + return v.to_kind_in(k, expr::Defaults { len: scale, ..expr::Defaults::default() }); + } Document::eval_in(vals, e, k) } diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 890ac4f1..a9e35130 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -338,6 +338,23 @@ impl Document { } } + /// Every length input of every feature as the geometry reads it (mm; a unit-less result is + /// in the design's units), for checking that an edit moves nothing. + pub fn length_inputs(&self) -> Vec> { + let (vals, _) = self.param_values(); + let scale = expr::unit_info(self.units.trim()).filter(|(k, _)| *k == Kind::Length).map(|(_, s)| s).unwrap_or(1.0); + let look = |n: &str| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))); + self.features + .iter() + .flat_map(|f| f.kind.inputs()) + .filter(|(_, _, k)| *k == Kind::Length) + .map(|(_, e, _)| { + let d = if expr::is_literal(&e) { expr::Defaults::default() } else { expr::Defaults { len: scale, ..expr::Defaults::default() } }; + expr::eval_with(&e, &look).and_then(|v| v.to_kind_in(Kind::Length, d)).ok() + }) + .collect() + } + pub fn change_param(&mut self, name: &str, expr_s: &str, unit: Option<&str>, comment: Option<&str>) -> Result<()> { let hit = self.features.iter().enumerate().find_map(|(i, f)| f.param_names.iter().position(|n| n == name).map(|k| (i, k))); let Some((i, k)) = hit else { return self.set_param(name, expr_s, unit, comment) }; @@ -529,7 +546,16 @@ impl Document { for f in &self.features { for (name, (_, e, k)) in f.param_names.iter().zip(f.kind.inputs()) { if !name.is_empty() { - v.push(ParamDef { name: name.clone(), expr: e, unit: unit_of(k).into() }); + // A bare number stored in an input is mm (the design's units are written in + // when it is typed); anything else that comes out unit-less (a unit-less + // parameter used as a length) is in the design's units. + let unit = match k { + Kind::Length if !expr::is_literal(&e) && expr::unit_info(self.units.trim()).is_some_and(|(k, _)| k == Kind::Length) => { + self.units.trim().to_string() + } + _ => unit_of(k).to_string(), + }; + v.push(ParamDef { name: name.clone(), expr: e, unit }); } } } diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 78042a38..9201d635 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -165,14 +165,17 @@ fn document_units(s: &mut Session, p: &Value) -> Result { if old == u { return Ok(json!({ "units": u, "kept": [] })); } - let (before, _) = s.doc.param_values(); + // What the geometry reads: every feature's length inputs. + let before = s.doc.length_inputs(); s.doc_mut().units = u.to_string(); // Unit-less user parameters holding a bare number read it in the design's units where they - // stand for lengths. If any value moved, those numbers keep the old unit, written out. - let (after, _) = s.doc.param_values(); - let moved = before.iter().any(|(k, b)| after.get(k).is_none_or(|a| (a.v - b.v).abs() > 1e-9 * b.v.abs().max(1.0))); + // stand for lengths. If any input moved, those numbers keep the old unit, written out. + let same = |a: &[Option]| { + a.iter().zip(&before).filter(|(x, y)| matches!((x, y), (Some(x), Some(y)) if (x - y).abs() <= 1e-9 * y.abs().max(1.0))).count() + }; + let after = s.doc.length_inputs(); let mut kept = Vec::new(); - if moved { + if same(&after) < before.len() { let bare: Vec<(String, String)> = s .doc .params @@ -181,16 +184,15 @@ fn document_units(s: &mut Session, p: &Value) -> Result { .map(|p| (p.name.clone(), p.expr.trim().to_string())) .collect(); for (name, e) in bare { + let now = same(&s.doc.length_inputs()); let probe = { let mut d = (*s.doc).clone(); - let _ = d.change_param(&name, &format!("{e} {old}"), None, None); - d.param_values().0 + let _ = d.change_param(&name, &format!("{e} {old}"), Some(&old), None); + same(&d.length_inputs()) }; - // Only when writing the old unit puts the values back where they were. - let fixes = before.iter().filter(|(k, b)| probe.get(*k).is_some_and(|a| (a.v - b.v).abs() <= 1e-9 * b.v.abs().max(1.0))).count() - > before.iter().filter(|(k, b)| after.get(*k).is_some_and(|a| (a.v - b.v).abs() <= 1e-9 * b.v.abs().max(1.0))).count(); - if fixes { - s.doc_mut().change_param(&name, &format!("{e} {old}"), None, None)?; + // Only when writing the old unit puts more inputs back where they were. + if probe > now { + s.doc_mut().change_param(&name, &format!("{e} {old}"), Some(&old), None)?; kept.push(name); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b4bac4fb..526f2909 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1467,3 +1467,15 @@ fn booleans_stay_in_their_component() { assert!((vol(&mut s, "BBox") - (2000.0 - 8.0 * 6.0 * 10.0)).abs() < 1e-6, "{}", vol(&mut s, "BBox")); assert!(s.execute("Extrude", &json!({"sketch": "Slot", "distance": 1, "operation": "cut", "participants": ["Nope"]})).is_err()); } + +/// A unit-less parameter used as a length means the design's units. +#[test] +fn unitless_parameters_used_as_lengths_take_the_design_units() { + let mut s = Session::default(); + s.doc_mut().units = "in".into(); + run(&mut s, "parameters.add", json!({"name": "w", "expression": "2", "unit": ""})); + run(&mut s, "PrimitiveBox", json!({"length": "w", "width": "w * 1", "height": "10 mm"})); + let m = run(&mut s, "MeasureCommand", json!({})); + let max = |i: usize| m["bodies"][0]["bbox"]["max"][i].as_f64().unwrap_or(0.0); + assert!((max(0) - 50.8).abs() < 1e-9 && (max(1) - 50.8).abs() < 1e-9 && (max(2) - 10.0).abs() < 1e-9, "{m}"); +}