From dd7485503ae9112c5b10d87ae1b9a625849e5c4b Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 23:57:56 +0000 Subject: [PATCH] =?UTF-8?q?Parameters:=20unit-aware=20parameters=20(an=20i?= =?UTF-8?q?nch=20parameter=20reads=20bare=20numbers=20as=20inches),=20atan?= =?UTF-8?q?2/mod/trunc=20and=20clearer=20unit=20errors,=20cycle=20paths=20?= =?UTF-8?q?(a=20=E2=86=92=20b=20=E2=86=92=20a),=20rename=20with=20every=20?= =?UTF-8?q?reference=20following=20(expressions,=20feature=20inputs,=20dim?= =?UTF-8?q?ensions,=20favourites,=20comments),=20deletes=20refused=20with?= =?UTF-8?q?=20the=20list=20of=20users,=20parameter=20errors=20shown=20on?= =?UTF-8?q?=20the=20features=20using=20them,=20parameters.add/rename/delet?= =?UTF-8?q?e/comment/users/graph/complete=20and=20expr.evaluate;=20ChangeP?= =?UTF-8?q?arameterCommand=20renames=20and=20favourites;=20hostile=20test?= =?UTF-8?q?=20no=20longer=20writes=20stray=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/document.rs | 66 +- crates/doc/src/eval.rs | 28 +- crates/doc/src/expr.rs | 769 ++++++++++++++++------ crates/doc/src/lib.rs | 2 +- crates/doc/src/params.rs | 267 +++++++- crates/doc/src/tests.rs | 5 +- crates/engine/((((((((((1 | 1 - crates/engine/nope | 1 - crates/engine/src/cmd/edit.rs | 90 +-- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/parameters.rs | 326 +++++++++ crates/engine/src/cmd/parameters_tests.rs | 226 +++++++ crates/engine/src/tests.rs | 9 + 13 files changed, 1470 insertions(+), 322 deletions(-) delete mode 100644 crates/engine/((((((((((1 delete mode 100644 crates/engine/nope create mode 100644 crates/engine/src/cmd/parameters.rs create mode 100644 crates/engine/src/cmd/parameters_tests.rs diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 76a285c0..2d11f835 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -866,31 +866,54 @@ impl Document { self.params.iter().find(|p| p.name == name) } - fn valid_name(name: &str) -> bool { + /// Why a name cannot be a parameter name (None = fine). + pub fn param_name_problem(name: &str) -> Option { let mut cs = name.chars(); - matches!(cs.next(), Some(c) if c.is_alphabetic() || c == '_') - && cs.all(|c| c.is_alphanumeric() || c == '_') - && !matches!(name, "mm" | "cm" | "m" | "um" | "in" | "inch" | "mil" | "yd" | "km" | "nm" | "ft" | "deg" | "rad" | "pi" | "PI") - && name.len() <= 64 + if !matches!(cs.next(), Some(c) if c.is_alphabetic() || c == '_') || !cs.all(|c| c.is_alphanumeric() || c == '_') { + return Some(format!("`{name}` is not a valid parameter name (letters, digits and _, starting with a letter)")); + } + if name.len() > 64 { + return Some("parameter names are at most 64 characters".into()); + } + if expr::is_reserved(name) { + return Some(format!("`{name}` is a unit, constant or function name")); + } + None } - /// Add or change a parameter. Fails if the result would not evaluate. + /// Add or change a parameter. Fails if the result would not evaluate. A new parameter + /// without a unit takes the unit of its value (mm, deg or none). pub fn set_param(&mut self, name: &str, expr_s: &str, unit: Option<&str>, comment: Option<&str>) -> Result<()> { - if !Self::valid_name(name) { - return Err(DocError::Invalid(format!("`{name}` is not a valid parameter name"))); + if let Some(p) = Self::param_name_problem(name) { + return Err(DocError::Invalid(p)); } if expr_s.len() > 4096 { return Err(DocError::Expr("expression too long".into())); } + if expr_s.trim().is_empty() { + return Err(DocError::Expr("empty expression".into())); + } + if let Some(u) = unit + && expr::unit_info(u).is_none() + { + return Err(DocError::Expr(format!("unknown unit `{u}` (mm, cm, m, in, ft, deg, rad or none)"))); + } + if comment.is_some_and(|c| c.len() > 4096) { + return Err(DocError::Invalid("comment too long".into())); + } if self.features.iter().any(|f| f.param_names.iter().any(|n| n == name)) { return Err(DocError::Invalid(format!("`{name}` is a feature's parameter"))); } let mut next = self.clone(); + let inferred = match unit { + Some(u) => u.trim().to_string(), + None => next.infer_unit(expr_s), + }; match next.params.iter_mut().find(|p| p.name == name) { Some(p) => { - p.expr = expr_s.to_string(); + p.expr = expr_s.trim().to_string(); if let Some(u) = unit { - p.unit = u.to_string(); + p.unit = u.trim().to_string(); } if let Some(c) = comment { p.comment = c.to_string(); @@ -902,8 +925,8 @@ impl Document { } next.params.push(Param { name: name.into(), - expr: expr_s.into(), - unit: unit.unwrap_or("mm").into(), + expr: expr_s.trim().into(), + unit: inferred, comment: comment.unwrap_or_default().into(), model: false, }) @@ -924,25 +947,6 @@ impl Document { name } - /// Remove a user parameter (fails while something references it). - pub fn remove_param(&mut self, name: &str) -> Result<()> { - if self.param(name).is_none() { - return Err(DocError::Unknown(format!("parameter `{name}`"))); - } - let mut probe = self.clone(); - probe.params.retain(|p| p.name != name); - if probe.referenced_names().iter().any(|n| n == name) { - return Err(DocError::Invalid(format!("`{name}` is still used"))); - } - *self = probe; - Ok(()) - } - - /// Evaluate all parameters: values (mm / rad / unit-less) and errors by name. - pub fn param_values(&self) -> (std::collections::BTreeMap, std::collections::BTreeMap) { - expr::eval_params(&self.all_param_exprs()) - } - /// Evaluate an expression against the document's parameters. pub fn eval(&self, e: &str, kind: Kind) -> Result { let (vals, _) = self.param_values(); diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index af0b828e..3b61c9da 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -221,18 +221,23 @@ pub struct Model { pub last_recomputed: usize, } -fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap, rolled_back: bool) -> u64 { - let mut h = std::collections::hash_map::DefaultHasher::new(); - prev.hash(&mut h); - rolled_back.hash(&mut h); - serde_json::to_string(f).unwrap_or_default().hash(&mut h); +/// Parameters a feature uses directly (inputs and sketch dimensions), sorted. +fn param_names(f: &Feature) -> Vec { let mut names: Vec = f.kind.expressions().iter().flat_map(|e| expr::references(e)).collect(); if let FeatureKind::Sketch { sketch, .. } = &f.kind { names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone())); } names.sort(); names.dedup(); - for n in names { + names +} + +fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap, rolled_back: bool) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + prev.hash(&mut h); + rolled_back.hash(&mut h); + serde_json::to_string(f).unwrap_or_default().hash(&mut h); + for n in param_names(f) { n.hash(&mut h); match vals.get(&n) { Some(v) => { @@ -313,9 +318,14 @@ impl Model { let t0 = now(); let mut next = (*state).clone(); let mut warning = None; - let r = kernel::guard(&f.name, || { - eval_feature(doc, &vals, f, &mut next, &mut warning).map_err(|e| kernel::KernelError::Failed(e.to_string())) - }); + // A parameter with an error fails the features using it, with that error. + let bad_param = param_names(f).into_iter().find_map(|n| self.param_errors.get(&n).map(|e| format!("parameter `{n}`: {e}"))); + let r = match bad_param { + Some(e) => Err(kernel::KernelError::Failed(e)), + None => kernel::guard(&f.name, || { + eval_feature(doc, &vals, f, &mut next, &mut warning).map_err(|e| kernel::KernelError::Failed(e.to_string())) + }), + }; let ms = t0.elapsed().as_secs_f64() * 1000.0; let error = match r { Ok(()) => { diff --git a/crates/doc/src/expr.rs b/crates/doc/src/expr.rs index cc891c36..e1efc327 100644 --- a/crates/doc/src/expr.rs +++ b/crates/doc/src/expr.rs @@ -1,9 +1,10 @@ -//! Parameter expressions: `2 * width + 5 mm`, `angle / 2`, `sqrt(a^2 + b^2)`. +//! Parameter expressions: `2 * width + 5 mm`, `angle / 2`, `sqrt(a^2 + b^2)`, `atan2(h; w)`. //! //! Values carry a simple dimension (powers of length and angle). Lengths are millimetres and //! angles radians internally. A unit-less number added to a dimensioned value takes that value's -//! unit (`width + 5` adds 5 mm); a unit-less final result takes the expected unit's default -//! (mm for lengths, degrees for angles). Trigonometric functions take an angle; a unit-less +//! unit: the parameter's own unit when it has one (`width + 5` adds 5 in for an inch +//! parameter), else millimetres and degrees. A unit-less final result takes the expected unit +//! (`2` for an inch parameter is 2 in). Trigonometric functions take an angle; a unit-less //! argument is read as degrees. use std::collections::BTreeMap; @@ -12,6 +13,7 @@ use crate::DocError; const MAX_LEN: usize = 4096; const MAX_DEPTH: usize = 64; +const MAX_ARGS: usize = 64; /// What an expression must evaluate to. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -24,14 +26,104 @@ pub enum Kind { impl Kind { pub fn from_unit(u: &str) -> Kind { - match u.trim() { - "deg" | "rad" | "°" => Kind::Angle, - "" | "none" | "unitless" => Kind::Unitless, - _ => Kind::Length, + unit_info(u).map(|(k, _)| k).unwrap_or(Kind::Length) + } + /// The unit a new parameter of this kind gets (`mm`, `deg` or none). + pub fn default_unit(self) -> &'static str { + match self { + Kind::Length => "mm", + Kind::Angle => "deg", + Kind::Unitless => "", } } } +/// Units a parameter or an expression may use: name, kind and size (mm or radians). +pub const UNITS: [(&str, Kind, f64); 14] = [ + ("mm", Kind::Length, 1.0), + ("cm", Kind::Length, 10.0), + ("m", Kind::Length, 1000.0), + ("um", Kind::Length, 0.001), + ("µm", Kind::Length, 0.001), + ("in", Kind::Length, 25.4), + ("inch", Kind::Length, 25.4), + ("ft", Kind::Length, 304.8), + ("yd", Kind::Length, 914.4), + ("km", Kind::Length, 1_000_000.0), + ("mil", Kind::Length, 0.0254), + ("nm", Kind::Length, 1e-6), + ("deg", Kind::Angle, std::f64::consts::PI / 180.0), + ("rad", Kind::Angle, 1.0), +]; + +/// Functions an expression may call (name, signature) — for autocomplete and help. +pub const FUNCTIONS: [(&str, &str); 23] = [ + ("sin", "sin(angle)"), + ("cos", "cos(angle)"), + ("tan", "tan(angle)"), + ("asin", "asin(x) → angle"), + ("acos", "acos(x) → angle"), + ("atan", "atan(x) → angle"), + ("atan2", "atan2(y, x) → angle"), + ("sqrt", "sqrt(x)"), + ("abs", "abs(x)"), + ("min", "min(a, b, …)"), + ("max", "max(a, b, …)"), + ("floor", "floor(x)"), + ("ceil", "ceil(x)"), + ("round", "round(x)"), + ("exp", "exp(x)"), + ("ln", "ln(x)"), + ("log", "log(x) (base 10)"), + ("log10", "log10(x)"), + ("sign", "sign(x)"), + ("hypot", "hypot(a, b)"), + ("pow", "pow(x, n)"), + ("trunc", "trunc(x)"), + ("mod", "mod(a, b)"), +]; + +/// Constants an expression may use. +pub const CONSTANTS: [&str; 2] = ["pi", "PI"]; + +/// Kind and size (mm or radians per unit) of a parameter's unit; empty means unit-less. +pub fn unit_info(u: &str) -> Option<(Kind, f64)> { + match u.trim() { + "" | "none" | "unitless" => Some((Kind::Unitless, 1.0)), + "°" | "degree" | "degrees" => Some((Kind::Angle, std::f64::consts::PI / 180.0)), + t => UNITS.iter().find(|(n, _, _)| *n == t).map(|(_, k, s)| (*k, *s)), + } +} + +/// How unit-less numbers are read: millimetres / degrees by default. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Defaults { + /// Millimetres per unit-less length number. + pub len: f64, + /// Radians per unit-less angle number. + pub ang: f64, +} + +impl Default for Defaults { + fn default() -> Self { + Defaults { len: 1.0, ang: std::f64::consts::PI / 180.0 } + } +} + +impl Defaults { + /// Defaults for a parameter with this unit (`in` reads bare numbers as inches); the + /// document's length unit covers the other kind. + pub fn for_unit(unit: &str, doc_len: f64) -> Defaults { + let mut d = Defaults { len: doc_len, ..Defaults::default() }; + match unit_info(unit) { + Some((Kind::Length, s)) => d.len = s, + Some((Kind::Angle, s)) => d.ang = s, + _ => {} + } + d + } +} + /// A dimensioned value. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Value { @@ -50,41 +142,85 @@ impl Value { pub fn angle(rad: f64) -> Value { Value { v: rad, len: 0, ang: 1 } } - fn unitless(&self) -> bool { + pub fn unitless(&self) -> bool { self.len == 0 && self.ang == 0 } - /// Convert to the expected kind (mm or radians). + /// The value's kind, if it is a plain length, angle or number. + pub fn kind(&self) -> Option { + match (self.len, self.ang) { + (0, 0) => Some(Kind::Unitless), + (1, 0) => Some(Kind::Length), + (0, 1) => Some(Kind::Angle), + _ => None, + } + } + /// Convert to the expected kind (mm or radians); unit-less numbers are mm / degrees. pub fn to_kind(self, k: Kind) -> Result { + self.to_kind_in(k, Defaults::default()) + } + /// Convert to the expected kind, reading a unit-less result in the given defaults. + pub fn to_kind_in(self, k: Kind, d: Defaults) -> Result { let out = match k { Kind::Length if self.len == 1 && self.ang == 0 => self.v, - Kind::Length if self.unitless() => self.v, + Kind::Length if self.unitless() => self.v * d.len, Kind::Angle if self.ang == 1 && self.len == 0 => self.v, - Kind::Angle if self.unitless() => self.v.to_radians(), + Kind::Angle if self.unitless() => self.v * d.ang, Kind::Unitless if self.unitless() => self.v, - _ => return Err(DocError::Expr(format!("wrong units for a {k:?} value"))), + _ => { + return Err(DocError::Expr(format!("expected {}, got {}", kind_word(k), self.describe()))); + } }; if out.is_finite() { Ok(out) } else { Err(DocError::Expr("result is not a finite number".into())) } } + /// "a length", "an angle", "length²"… + pub fn describe(&self) -> String { + match self.kind() { + Some(k) => kind_word(k).to_string(), + None => { + let pow = |name: &str, n: i8| match n { + 0 => String::new(), + 1 => name.to_string(), + _ => format!("{name}^{n}"), + }; + let parts: Vec = [pow("length", self.len), pow("angle", self.ang)].into_iter().filter(|s| !s.is_empty()).collect(); + format!("a value in {}", parts.join("·")) + } + } + } +} + +fn kind_word(k: Kind) -> &'static str { + match k { + Kind::Length => "a length", + Kind::Angle => "an angle", + Kind::Unitless => "a unit-less number", + } +} + +/// A number in the given unit (e.g. 50.8 mm in `in` is 2); unknown units leave it as is. +pub fn value_in_unit(v: Value, unit: &str) -> f64 { + match (unit_info(unit), v.kind()) { + (Some((Kind::Length, s)), Some(Kind::Length)) | (Some((Kind::Angle, s)), Some(Kind::Angle)) if s != 0.0 => v.v / s, + _ => v.v, + } } fn unit(name: &str) -> Option { - Some(match name { - "mm" => Value::length(1.0), - "cm" => Value::length(10.0), - "m" => Value::length(1000.0), - "um" => Value::length(0.001), - "in" | "inch" => Value::length(25.4), - "mil" => Value::length(0.0254), - "yd" => Value::length(914.4), - "km" => Value::length(1_000_000.0), - "nm" => Value::length(1e-6), - "ft" => Value::length(304.8), - "deg" => Value::angle(std::f64::consts::PI / 180.0), - "rad" => Value::angle(1.0), - _ => return None, + UNITS.iter().find(|(n, _, _)| *n == name).map(|(_, k, s)| match k { + Kind::Angle => Value::angle(*s), + _ => Value::length(*s), }) } +fn is_constant(id: &str) -> bool { + CONSTANTS.contains(&id) +} + +/// Is this a name the expression language reserves (unit, constant or function)? +pub fn is_reserved(name: &str) -> bool { + unit(name).is_some() || is_constant(name) || FUNCTIONS.iter().any(|(f, _)| *f == name) +} + #[derive(Clone, Debug, PartialEq)] enum Tok { Num(f64), @@ -92,41 +228,61 @@ enum Tok { Op(char), } -fn lex(s: &str) -> Result, DocError> { +/// Tokens with their byte ranges in the source. +fn lex_spans(s: &str) -> Result, DocError> { if s.len() > MAX_LEN { return Err(DocError::Expr("expression too long".into())); } - let cs: Vec = s.chars().collect(); + let cs: Vec<(usize, char)> = s.char_indices().collect(); + let at = |i: usize| cs.get(i).map(|x| x.0).unwrap_or(s.len()); let mut i = 0; let mut out = Vec::new(); - while let Some(&c) = cs.get(i) { + while let Some(&(_, c)) = cs.get(i) { if c.is_whitespace() { i += 1; } else if c.is_ascii_digit() || c == '.' { let st = i; - while cs.get(i).is_some_and(|c| c.is_ascii_digit() || *c == '.') { + while cs.get(i).is_some_and(|(_, c)| c.is_ascii_digit() || *c == '.') { i += 1; } - // Exponent: 1e-3 - if cs.get(i).is_some_and(|c| *c == 'e' || *c == 'E') && cs.get(i + 1).is_some_and(|c| c.is_ascii_digit() || *c == '-' || *c == '+') { - i += 2; - while cs.get(i).is_some_and(char::is_ascii_digit) { - i += 1; + // Exponent: 1e-3 (but not `2em`). + if cs.get(i).is_some_and(|(_, c)| *c == 'e' || *c == 'E') { + let sign = cs.get(i + 1).is_some_and(|(_, c)| *c == '-' || *c == '+'); + let digit_at = if sign { i + 2 } else { i + 1 }; + if cs.get(digit_at).is_some_and(|(_, c)| c.is_ascii_digit()) { + i = digit_at; + while cs.get(i).is_some_and(|(_, c)| c.is_ascii_digit()) { + i += 1; + } } } - let t: String = cs.get(st..i).map(|s| s.iter().collect()).unwrap_or_default(); - out.push(Tok::Num(t.parse().map_err(|_| DocError::Expr(format!("bad number `{t}`")))?)); - } else if c.is_alphabetic() || c == '_' { + let t = s.get(at(st)..at(i)).unwrap_or_default(); + let v: f64 = t.parse().map_err(|_| DocError::Expr(format!("bad number `{t}`")))?; + out.push((Tok::Num(v), at(st), at(i))); + } else if c.is_alphabetic() || c == '_' || c == 'µ' { let st = i; - while cs.get(i).is_some_and(|c| c.is_alphanumeric() || *c == '_') { + while cs.get(i).is_some_and(|(_, c)| c.is_alphanumeric() || *c == '_') { i += 1; } - out.push(Tok::Id(cs.get(st..i).map(|s| s.iter().collect()).unwrap_or_default())); + out.push((Tok::Id(s.get(at(st)..at(i)).unwrap_or_default().to_string()), at(st), at(i))); } else if c == '°' { - out.push(Tok::Id("deg".into())); + out.push((Tok::Id("deg".into()), at(i), at(i + 1))); i += 1; - } else if "+-*/^(),".contains(c) { - out.push(Tok::Op(c)); + } else if c == '×' || c == '·' { + out.push((Tok::Op('*'), at(i), at(i + 1))); + i += 1; + } else if c == '÷' { + out.push((Tok::Op('/'), at(i), at(i + 1))); + i += 1; + } else if c == '−' { + out.push((Tok::Op('-'), at(i), at(i + 1))); + i += 1; + } else if c == ';' { + // Fusion separates function arguments with `;` in some locales. + out.push((Tok::Op(','), at(i), at(i + 1))); + i += 1; + } else if "+-*/^(),%".contains(c) { + out.push((Tok::Op(c), at(i), at(i + 1))); i += 1; } else { return Err(DocError::Expr(format!("unexpected `{c}`"))); @@ -135,26 +291,71 @@ fn lex(s: &str) -> Result, DocError> { Ok(out) } -/// Identifiers an expression refers to (excluding units and functions). +fn lex(s: &str) -> Result, DocError> { + Ok(lex_spans(s)?.into_iter().map(|(t, _, _)| t).collect()) +} + +/// Is the identifier token at `i` a reference to a parameter (not a call, unit or constant)? +fn is_reference(toks: &[Tok], i: usize, id: &str) -> bool { + let is_call = matches!(toks.get(i + 1), Some(Tok::Op('('))); + !is_call && unit(id).is_none() && !is_constant(id) +} + +/// Identifiers an expression refers to (excluding units, constants and functions). pub fn references(s: &str) -> Vec { let Ok(toks) = lex(s) else { return Vec::new() }; let mut out: Vec = Vec::new(); for (i, t) in toks.iter().enumerate() { - if let Tok::Id(id) = t { - let is_call = matches!(toks.get(i + 1), Some(Tok::Op('('))); - if !is_call && unit(id).is_none() && id != "pi" && id != "PI" && !out.contains(id) { - out.push(id.clone()); - } + if let Tok::Id(id) = t + && is_reference(&toks, i, id) + && !out.contains(id) + { + out.push(id.clone()); } } out } +/// The expression with every reference to `old` renamed to `new` (text otherwise kept). +pub fn rename_reference(s: &str, old: &str, new: &str) -> String { + let Ok(spans) = lex_spans(s) else { return s.to_string() }; + let toks: Vec = spans.iter().map(|(t, _, _)| t.clone()).collect(); + let mut out = String::with_capacity(s.len()); + let mut last = 0; + for (i, (t, a, b)) in spans.iter().enumerate() { + if let Tok::Id(id) = t + && id == old + && is_reference(&toks, i, id) + { + out.push_str(s.get(last..*a).unwrap_or_default()); + out.push_str(new); + last = *b; + } + } + out.push_str(s.get(last..).unwrap_or_default()); + out +} + +/// Is the expression just a number (with an optional unit), like `20`, `20 mm` or `-1.5 in`? +pub fn is_literal(s: &str) -> bool { + let Ok(toks) = lex(s) else { return false }; + let toks: &[Tok] = match toks.first() { + Some(Tok::Op('-' | '+')) => toks.get(1..).unwrap_or_default(), + _ => &toks, + }; + match toks { + [Tok::Num(_)] => true, + [Tok::Num(_), Tok::Id(u)] => unit(u).is_some(), + _ => false, + } +} + struct Parser<'a> { toks: Vec, pos: usize, lookup: &'a dyn Fn(&str) -> Result, depth: usize, + defaults: Defaults, } impl Parser<'_> { @@ -176,7 +377,7 @@ impl Parser<'_> { while let Some(Tok::Op(c @ ('+' | '-'))) = self.peek().cloned() { self.pos += 1; let b = self.term()?; - a = add(a, b, c == '-')?; + a = add(a, b, c == '-', self.defaults)?; } self.depth -= 1; Ok(a) @@ -188,17 +389,22 @@ impl Parser<'_> { Some(Tok::Op('*')) => { self.pos += 1; let b = self.unary()?; - a = mul(a, b, false); + a = mul(a, b, false)?; } Some(Tok::Op('/')) => { self.pos += 1; let b = self.unary()?; - a = mul(a, b, true); + a = mul(a, b, true)?; + } + Some(Tok::Op('%')) => { + self.pos += 1; + let b = self.unary()?; + a = modulo(a, b, self.defaults)?; } // Juxtaposition: `10 mm`, `2 width`. Some(Tok::Id(_) | Tok::Num(_)) | Some(Tok::Op('(')) => { let b = self.unary()?; - a = mul(a, b, false); + a = mul(a, b, false)?; } _ => return Ok(a), } @@ -215,7 +421,10 @@ impl Parser<'_> { } Some(Tok::Op('+')) => { self.pos += 1; - self.unary() + self.enter()?; + let v = self.unary()?; + self.depth -= 1; + Ok(v) } _ => self.power(), } @@ -227,15 +436,7 @@ impl Parser<'_> { self.enter()?; let e = self.unary()?; self.depth -= 1; - if !e.unitless() { - return Err(DocError::Expr("exponent must be unit-less".into())); - } - let n = e.v; - let k = if (n - n.round()).abs() < 1e-12 && n.abs() < 16.0 { n.round() as i8 } else { 0 }; - if !base.unitless() && k == 0 { - return Err(DocError::Expr("dimensioned values need a small integer exponent".into())); - } - return Ok(Value { v: base.v.powf(n), len: base.len.saturating_mul(k), ang: base.ang.saturating_mul(k) }); + return pow(base, e); } Ok(base) } @@ -255,20 +456,24 @@ impl Parser<'_> { let mut args = Vec::new(); if !matches!(self.peek(), Some(Tok::Op(')'))) { loop { + if args.len() >= MAX_ARGS { + return Err(DocError::Expr(format!("too many arguments to `{id}`"))); + } args.push(self.expr()?); match self.next() { Some(Tok::Op(',')) => continue, Some(Tok::Op(')')) => break, - _ => return Err(DocError::Expr("bad function arguments".into())), + _ => return Err(DocError::Expr(format!("missing `)` after the arguments of `{id}`"))), } } } else { self.pos += 1; } - return call(&id, &args); + return call(&id, &args, self.defaults); } - if id == "pi" || id == "PI" { - return Ok(Value::num(std::f64::consts::PI)); + match id.as_str() { + "pi" | "PI" => return Ok(Value::num(std::f64::consts::PI)), + _ => {} } if let Some(u) = unit(&id) { return Ok(u); @@ -281,115 +486,178 @@ impl Parser<'_> { } } -fn add(a: Value, b: Value, sub: bool) -> Result { +/// A unit-less number next to a dimensioned value, in that value's default unit. +fn scale_bare(x: Value, len: i8, ang: i8, d: Defaults) -> f64 { + if !x.unitless() { + x.v + } else if len == 1 && ang == 0 { + x.v * d.len + } else if ang == 1 && len == 0 { + x.v * d.ang + } else { + x.v + } +} + +fn add(a: Value, b: Value, sub: bool, d: Defaults) -> Result { let (len, ang) = if a.unitless() { (b.len, b.ang) } else if b.unitless() || (a.len == b.len && a.ang == b.ang) { (a.len, a.ang) } else { - return Err(DocError::Expr("cannot add values with different units".into())); + return Err(DocError::Expr(format!("cannot {} {} and {}", if sub { "subtract" } else { "add" }, a.describe(), b.describe()))); }; - // A unit-less operand next to an angle is in degrees. - let conv = |x: Value| if x.unitless() && ang == 1 && len == 0 { x.v.to_radians() } else { x.v }; - let (x, y) = (conv(a), conv(b)); + let (x, y) = (scale_bare(a, len, ang, d), scale_bare(b, len, ang, d)); Ok(Value { v: if sub { x - y } else { x + y }, len, ang }) } -fn mul(a: Value, b: Value, div: bool) -> Value { +fn dims(a: i8, b: i8, sub: bool) -> Result { + let r = if sub { a.checked_sub(b) } else { a.checked_add(b) }; + r.filter(|x| x.abs() <= 16).ok_or_else(|| DocError::Expr("unit powers too large".into())) +} + +fn mul(a: Value, b: Value, div: bool) -> Result { if div { - Value { v: a.v / b.v, len: a.len.saturating_sub(b.len), ang: a.ang.saturating_sub(b.ang) } + if b.v == 0.0 { + return Err(DocError::Expr("division by zero".into())); + } + Ok(Value { v: a.v / b.v, len: dims(a.len, b.len, true)?, ang: dims(a.ang, b.ang, true)? }) } else { - Value { v: a.v * b.v, len: a.len.saturating_add(b.len), ang: a.ang.saturating_add(b.ang) } + Ok(Value { v: a.v * b.v, len: dims(a.len, b.len, false)?, ang: dims(a.ang, b.ang, false)? }) } } -fn call(f: &str, args: &[Value]) -> Result { +fn modulo(a: Value, b: Value, d: Defaults) -> Result { + let (len, ang) = if a.unitless() { (b.len, b.ang) } else { (a.len, a.ang) }; + if !(a.unitless() || b.unitless() || (a.len == b.len && a.ang == b.ang)) { + return Err(DocError::Expr(format!("cannot take {} modulo {}", a.describe(), b.describe()))); + } + let (x, y) = (scale_bare(a, len, ang, d), scale_bare(b, len, ang, d)); + if y == 0.0 { + return Err(DocError::Expr("modulo by zero".into())); + } + Ok(Value { v: x.rem_euclid(y), len, ang }) +} + +fn pow(base: Value, e: Value) -> Result { + if !e.unitless() { + return Err(DocError::Expr(format!("an exponent must be a unit-less number, not {}", e.describe()))); + } + let n = e.v; + let k = if (n - n.round()).abs() < 1e-12 && n.abs() < 16.0 { n.round() as i8 } else { 0 }; + if !base.unitless() && k == 0 { + return Err(DocError::Expr("a value with units needs a small whole-number exponent".into())); + } + Ok(Value { v: base.v.powf(n), len: dims(0, base.len.saturating_mul(k), false)?, ang: dims(0, base.ang.saturating_mul(k), false)? }) +} + +fn call(f: &str, args: &[Value], d: Defaults) -> Result { let one = || match args { [a] => Ok(*a), _ => Err(DocError::Expr(format!("`{f}` takes one argument"))), }; + let two = || match args { + [a, b] => Ok((*a, *b)), + _ => Err(DocError::Expr(format!("`{f}` takes two arguments"))), + }; let rad = |a: Value| { if a.ang == 1 && a.len == 0 { Ok(a.v) } else if a.unitless() { - Ok(a.v.to_radians()) + Ok(a.v * d.ang) } else { - Err(DocError::Expr(format!("`{f}` needs an angle"))) + Err(DocError::Expr(format!("`{f}` needs an angle, not {}", a.describe()))) } }; - let num = |a: Value| if a.unitless() { Ok(a.v) } else { Err(DocError::Expr(format!("`{f}` needs a unit-less value"))) }; + let num = |a: Value| if a.unitless() { Ok(a.v) } else { Err(DocError::Expr(format!("`{f}` needs a unit-less number, not {}", a.describe()))) }; + let same = |a: Value, b: Value| -> Result<(Value, Value), DocError> { + // Bring a bare number to the other operand's unit (as in `a + b`). + let z = add(a, Value { v: 0.0, ..b }, false, d)?; + let w = add(b, Value { v: 0.0, ..a }, false, d)?; + Ok((z, w)) + }; + let unit_fn = |x: f64, a: Value| Value { v: x, ..a }; Ok(match f { "sin" => Value::num(rad(one()?)?.sin()), "cos" => Value::num(rad(one()?)?.cos()), "tan" => Value::num(rad(one()?)?.tan()), - "asin" => Value::angle(num(one()?)?.asin()), - "acos" => Value::angle(num(one()?)?.acos()), + "asin" | "acos" => { + let x = num(one()?)?; + if !(-1.0..=1.0).contains(&x) { + return Err(DocError::Expr(format!("`{f}` needs a number between -1 and 1"))); + } + Value::angle(if f == "asin" { x.asin() } else { x.acos() }) + } "atan" => Value::angle(num(one()?)?.atan()), + "atan2" => { + let (y, x) = two()?; + let (y, x) = same(y, x)?; + if y.len != x.len || y.ang != x.ang { + return Err(DocError::Expr("`atan2` needs two values with the same units".into())); + } + Value::angle(y.v.atan2(x.v)) + } "sqrt" => { let a = one()?; if a.len % 2 != 0 || a.ang % 2 != 0 { return Err(DocError::Expr("sqrt of an odd power of a unit".into())); } + if a.v < 0.0 { + return Err(DocError::Expr("sqrt of a negative number".into())); + } Value { v: a.v.sqrt(), len: a.len / 2, ang: a.ang / 2 } } - "exp" => Value::num(num(one()?)?.exp()), - "ln" => Value::num(num(one()?)?.ln()), - "log" | "log10" => Value::num(num(one()?)?.log10()), - "sign" => { - let a = one()?; - Value::num(if a.v > 0.0 { - 1.0 - } else if a.v < 0.0 { - -1.0 - } else { - 0.0 - }) - } - "pow" => match args { - [a, b] => { - let e = num(*b)?; - if a.unitless() { - Value::num(a.v.powf(e)) - } else if (e - e.round()).abs() < 1e-12 && e.abs() <= 6.0 { - let k = e.round() as i8; - let (Some(len), Some(ang)) = (a.len.checked_mul(k), a.ang.checked_mul(k)) else { - return Err(DocError::Expr("unit power too large".into())); - }; - Value { v: a.v.powi(i32::from(k)), len, ang } - } else { - return Err(DocError::Expr("pow of a unit needs a whole exponent".into())); - } - } - _ => return Err(DocError::Expr("`pow` takes two arguments".into())), - }, - "hypot" => match args { - [a, b] => { - let s = add(*b, Value { v: 0.0, ..*a }, false)?; - Value { v: a.v.hypot(s.v), ..*a } - } - _ => return Err(DocError::Expr("`hypot` takes two arguments".into())), - }, "abs" => { let a = one()?; - Value { v: a.v.abs(), ..a } + unit_fn(a.v.abs(), a) } - "floor" | "ceil" | "round" => { + "sign" => Value::num(one()?.v.signum()), + "floor" | "ceil" | "round" | "trunc" => { let a = one()?; - let v = match f { - "floor" => a.v.floor(), - "ceil" => a.v.ceil(), - _ => a.v.round(), + // Round in the value's own default unit (round(2.4 in) = 2 in for an inch parameter). + let scale = scale_bare(Value::num(1.0), a.len, a.ang, d); + let x = if scale != 0.0 { a.v / scale } else { a.v }; + let r = match f { + "floor" => x.floor(), + "ceil" => x.ceil(), + "trunc" => x.trunc(), + _ => x.round(), }; - Value { v, ..a } + unit_fn(r * if scale != 0.0 { scale } else { 1.0 }, a) + } + "exp" => Value::num(num(one()?)?.exp()), + "ln" | "log" | "log10" => { + let x = num(one()?)?; + if x <= 0.0 { + return Err(DocError::Expr(format!("`{f}` needs a positive number"))); + } + Value::num(if f == "ln" { x.ln() } else { x.log10() }) + } + "hypot" => { + let (a, b) = two()?; + let (a, b) = same(a, b)?; + if a.len != b.len || a.ang != b.ang { + return Err(DocError::Expr("`hypot` needs two values with the same units".into())); + } + unit_fn(a.v.hypot(b.v), a) + } + "pow" => { + let (a, b) = two()?; + pow(a, b)? + } + "mod" => { + let (a, b) = two()?; + modulo(a, b, d)? } "min" | "max" => { let Some(first) = args.first().copied() else { return Err(DocError::Expr(format!("`{f}` needs arguments"))) }; let mut acc = first; for a in args.iter().skip(1) { - let d = add(*a, Value { v: 0.0, ..acc }, false)?; - if (f == "min" && d.v < acc.v) || (f == "max" && d.v > acc.v) { - acc = d; + let (x, y) = same(*a, acc)?; + if x.len != y.len || x.ang != y.ang { + return Err(DocError::Expr(format!("`{f}` needs values with the same units"))); } + acc = if (f == "min" && x.v < y.v) || (f == "max" && x.v > y.v) { x } else { y }; } acc } @@ -397,13 +665,18 @@ fn call(f: &str, args: &[Value]) -> Result { }) } -/// Evaluate an expression; `lookup` resolves parameter names. +/// Evaluate an expression; `lookup` resolves parameter names. Bare numbers are mm / degrees. pub fn eval_with(s: &str, lookup: &dyn Fn(&str) -> Result) -> Result { + eval_with_defaults(s, lookup, Defaults::default()) +} + +/// Evaluate an expression reading bare numbers next to lengths / angles in `defaults`. +pub fn eval_with_defaults(s: &str, lookup: &dyn Fn(&str) -> Result, defaults: Defaults) -> Result { let toks = lex(s)?; if toks.is_empty() { return Err(DocError::Expr("empty expression".into())); } - let mut p = Parser { toks, pos: 0, lookup, depth: 0 }; + let mut p = Parser { toks, pos: 0, lookup, depth: 0, defaults }; let v = p.expr()?; if p.pos < p.toks.len() { return Err(DocError::Expr(format!("unexpected input in `{s}`"))); @@ -414,73 +687,107 @@ pub fn eval_with(s: &str, lookup: &dyn Fn(&str) -> Result) -> R Ok(v) } -/// Evaluate all parameters (name → (expression, kind)) in dependency order. -pub fn eval_params(params: &[(String, String, Kind)]) -> (BTreeMap, BTreeMap) { +/// One parameter to evaluate: name, expression and unit (`mm`, `in`, `deg`, empty…). +#[derive(Clone, Debug)] +pub struct ParamDef { + pub name: String, + pub expr: String, + pub unit: String, +} + +/// Evaluate all parameters in dependency order: values (mm / rad / unit-less) and errors by +/// name. Circular references are reported with their path (`a → b → a`). `doc_len` is the +/// document's length unit in mm (bare numbers of unit-less-unit parameters next to lengths). +pub fn eval_params(params: &[ParamDef], doc_len: f64) -> (BTreeMap, BTreeMap) { let mut done: BTreeMap = BTreeMap::new(); let mut errors: BTreeMap = BTreeMap::new(); - fn visit( - name: &str, - params: &[(String, String, Kind)], - done: &mut BTreeMap, - errors: &mut BTreeMap, - stack: &mut Vec, - ) -> Result { - if let Some(v) = done.get(name) { + let index: BTreeMap<&str, &ParamDef> = params.iter().map(|p| (p.name.as_str(), p)).collect(); + struct Ctx<'a> { + index: BTreeMap<&'a str, &'a ParamDef>, + done: BTreeMap, + errors: BTreeMap, + stack: Vec, + doc_len: f64, + } + fn visit(name: &str, cx: &mut Ctx) -> Result { + if let Some(v) = cx.done.get(name) { return Ok(*v); } - if let Some(e) = errors.get(name) { - return Err(DocError::Expr(e.clone())); + if let Some(e) = cx.errors.get(name) { + return Err(DocError::Expr(format!("`{name}` has an error: {e}"))); } - let Some((_, expr, kind)) = params.iter().find(|(n, _, _)| n == name) else { + let Some(p) = cx.index.get(name).copied() else { return Err(DocError::Expr(format!("unknown parameter `{name}`"))); }; - if stack.iter().any(|s| s == name) || stack.len() > 256 { - return Err(DocError::Expr(format!("circular reference through `{name}`"))); + if let Some(at) = cx.stack.iter().position(|s| s == name) { + let mut path: Vec<&str> = cx.stack.iter().skip(at).map(String::as_str).collect(); + path.push(name); + return Err(DocError::Expr(format!("circular reference: {}", path.join(" → ")))); } - stack.push(name.to_string()); - let r = { - let deps = references(expr); - let mut vals: BTreeMap = BTreeMap::new(); - let mut err = None; - for d in deps { - match visit(&d, params, done, errors, stack) { - Ok(v) => { - vals.insert(d, v); - } - Err(e) => { - err = Some(e); - break; - } + if cx.stack.len() > 512 { + return Err(DocError::Expr("parameters nested too deeply".into())); + } + let Some((kind, _)) = unit_info(&p.unit).or(Some((Kind::Length, 1.0))) else { + return Err(DocError::Expr(format!("unknown unit `{}`", p.unit))); + }; + let defaults = Defaults::for_unit(&p.unit, cx.doc_len); + cx.stack.push(name.to_string()); + let mut vals: BTreeMap = BTreeMap::new(); + let mut err = None; + for d in references(&p.expr) { + match visit(&d, cx) { + Ok(v) => { + vals.insert(d, v); + } + Err(e) => { + err = Some(e); + break; } } - match err { - Some(e) => Err(e), - None => eval_with(expr, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`")))).and_then(|v| { - let x = v.to_kind(*kind)?; - Ok(match kind { - Kind::Length => Value::length(x), - Kind::Angle => Value::angle(x), - Kind::Unitless => Value::num(x), + } + cx.stack.pop(); + let r = match err { + Some(e) => Err(e), + None => { + eval_with_defaults(&p.expr, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))), defaults) + .and_then(|v| { + let x = v.to_kind_in(kind, defaults)?; + Ok(match kind { + Kind::Length => Value::length(x), + Kind::Angle => Value::angle(x), + Kind::Unitless => Value::num(x), + }) }) - }), } }; - stack.pop(); match &r { Ok(v) => { - done.insert(name.to_string(), *v); + cx.done.insert(name.to_string(), *v); } Err(e) => { - errors.insert(name.to_string(), e.to_string()); + let msg = e.to_string(); + cx.errors.insert(name.to_string(), msg.strip_prefix("expression: ").unwrap_or(&msg).to_string()); } } r } - for (n, _, _) in params { - let mut stack = Vec::new(); - let _ = visit(n, params, &mut done, &mut errors, &mut stack); + let mut cx = Ctx { index, done: std::mem::take(&mut done), errors: std::mem::take(&mut errors), stack: Vec::new(), doc_len }; + for p in params { + cx.stack.clear(); + let _ = visit(&p.name, &mut cx); } - (done, errors) + (cx.done, cx.errors) +} + +/// Dependency edges between parameters: (parameter, parameter it references). +pub fn param_edges(params: &[ParamDef]) -> Vec<(String, String)> { + let mut out = Vec::new(); + for p in params { + for r in references(&p.expr) { + out.push((p.name.clone(), r)); + } + } + out } #[cfg(test)] @@ -497,6 +804,10 @@ mod tests { eval_with(s, &look)?.to_kind(k) } + fn def(n: &str, e: &str, u: &str) -> ParamDef { + ParamDef { name: n.into(), expr: e.into(), unit: u.into() } + } + #[test] fn arithmetic_and_units() { assert_eq!(ev("10", Kind::Length).unwrap(), 10.0); @@ -514,34 +825,122 @@ mod tests { assert!((ev("sin(30)", Kind::Unitless).unwrap() - 0.5).abs() < 1e-12); assert!((ev("2*pi", Kind::Unitless).unwrap() - std::f64::consts::TAU).abs() < 1e-12); assert_eq!(ev("max(1 mm, 3, 2)", Kind::Length).unwrap(), 3.0); + assert_eq!(ev("min(1 in, 30 mm)", Kind::Length).unwrap(), 25.4); assert_eq!(ev("1e-3 m", Kind::Length).unwrap(), 1.0); + assert_eq!(ev("1 ft - 1 in", Kind::Length).unwrap(), 304.8 - 25.4); + assert!((ev("atan2(1 mm, 1 mm)", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_4).abs() < 1e-12); + assert!((ev("atan2(1; 1)", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_4).abs() < 1e-12); + assert!((ev("exp(ln(5))", Kind::Unitless).unwrap() - 5.0).abs() < 1e-12); + assert!((ev("log(1000)", Kind::Unitless).unwrap() - 3.0).abs() < 1e-12); + assert_eq!(ev("floor(2.7) + ceil(0.2) + round(1.5) + abs(-1)", Kind::Unitless).unwrap(), 6.0); + assert!((ev("1 rad", Kind::Angle).unwrap() - 1.0).abs() < 1e-12); + assert!((ev("acos(0)", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_2).abs() < 1e-12); + assert_eq!(ev("2 × 3 ÷ 4 − 1", Kind::Unitless).unwrap(), 0.5); + assert_eq!(ev("hypot(3 mm, 4 mm)", Kind::Length).unwrap(), 5.0); + assert_eq!(ev("7 % 4", Kind::Unitless).unwrap(), 3.0); + assert_eq!(ev("width^2 / width", Kind::Length).unwrap(), 40.0); } #[test] fn errors() { assert!(ev("width + ang", Kind::Length).is_err()); - assert!(ev("width * width", Kind::Length).is_err()); + let e = ev("10 mm + 5 deg", Kind::Length).unwrap_err().to_string(); + assert!(e.contains("cannot add a length and an angle"), "{e}"); + assert!(ev("width * width", Kind::Length).unwrap_err().to_string().contains("expected a length")); assert!(ev("nope", Kind::Length).is_err()); assert!(ev("(1", Kind::Length).is_err()); assert!(ev("1 / 0", Kind::Length).is_err()); assert!(ev("", Kind::Length).is_err()); assert!(ev("1 $ 2", Kind::Length).is_err()); + assert!(ev("sqrt(-1)", Kind::Unitless).is_err()); + assert!(ev("asin(2)", Kind::Angle).is_err()); + assert!(ev("ln(0)", Kind::Unitless).is_err()); + assert!(ev("sin(1 mm)", Kind::Unitless).is_err()); + assert!(ev("atan2(1 mm, 1 deg)", Kind::Angle).is_err()); + assert!(ev("10 ^ 400", Kind::Unitless).is_err()); + assert!(ev("width ^ 1.5", Kind::Unitless).is_err()); + assert!(ev("max()", Kind::Unitless).is_err()); + assert!(ev("frob(1)", Kind::Unitless).is_err()); let deep = "(".repeat(500) + "1" + &")".repeat(500); assert!(ev(&deep, Kind::Length).is_err()); assert!(ev(&"-".repeat(500), Kind::Length).is_err()); + assert!(ev(&"+".repeat(500), Kind::Length).is_err()); + assert!(ev(&"width*".repeat(100), Kind::Length).is_err()); + assert!(ev(&("2^".repeat(200) + "2"), Kind::Unitless).is_err()); + } + + /// Hostile input never panics: random strings over the expression alphabet. + #[test] + fn fuzz_never_panics() { + let alphabet: Vec = "0123456789.eE+-*/^(),;% abcdmnpitrsgxw_°×÷−µ\u{0}\u{7f}é\"'".chars().collect(); + let words = ["width", "sin(", "atan2(", "mm", "deg", "pi", "1e308", "1e-400", "(", ")", "^", "max(", "in", "count", ","]; + let mut seed: u64 = 0x9e37_79b9_7f4a_7c15; + let mut rnd = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for _ in 0..20_000 { + let n = (rnd() % 40) as usize; + let mut s = String::new(); + for _ in 0..n { + if rnd() % 4 == 0 { + s.push_str(words[(rnd() % words.len() as u64) as usize]); + } else { + s.push(alphabet[(rnd() % alphabet.len() as u64) as usize]); + } + } + for k in [Kind::Length, Kind::Angle, Kind::Unitless] { + let _ = ev(&s, k); + } + let _ = references(&s); + let _ = rename_reference(&s, "width", "w2"); + let _ = is_literal(&s); + } } #[test] fn params_in_order_with_cycles() { - let p = vec![ - ("b".to_string(), "a * 2".to_string(), Kind::Length), - ("a".to_string(), "10 mm".to_string(), Kind::Length), - ("x".to_string(), "y".to_string(), Kind::Length), - ("y".to_string(), "x + 1".to_string(), Kind::Length), - ]; - let (v, e) = eval_params(&p); + let p = vec![def("b", "a * 2", "mm"), def("a", "10 mm", "mm"), def("x", "y", "mm"), def("y", "z + 1", "mm"), def("z", "x", "mm")]; + let (v, e) = eval_params(&p, 1.0); assert_eq!(v.get("b").unwrap().v, 20.0); - assert!(e.contains_key("x") && e.contains_key("y")); - assert_eq!(references("2*width + sin(ang) + 3 mm"), vec!["width".to_string(), "ang".to_string()]); + assert!(e.contains_key("x") && e.contains_key("y") && e.contains_key("z")); + assert!(e["x"].contains("circular reference: x → y → z → x"), "{}", e["x"]); + assert_eq!(references("2*width + sin(ang) + 3 mm + pi"), vec!["width".to_string(), "ang".to_string()]); + let (_, e) = eval_params(&[def("s", "s", "mm")], 1.0); + assert!(e["s"].contains("s → s")); + } + + #[test] + fn parameter_units() { + let p = vec![ + def("a", "2", "in"), + def("b", "a + 1", "in"), + def("c", "b", "mm"), + def("t", "0.5", "rad"), + def("u", "t + 1", "rad"), + def("k", "3", ""), + def("bad", "a + t", "mm"), + def("mixed", "1 in + 2", "mm"), + ]; + let (v, e) = eval_params(&p, 1.0); + assert!((v["a"].v - 50.8).abs() < 1e-9); + assert!((v["b"].v - 76.2).abs() < 1e-9); + assert!((v["c"].v - 76.2).abs() < 1e-9); + assert!((v["u"].v - 1.5).abs() < 1e-12); + assert_eq!(v["k"], Value::num(3.0)); + assert!(e["bad"].contains("cannot add")); + assert!((v["mixed"].v - 27.4).abs() < 1e-9); + assert!((value_in_unit(v["b"], "in") - 3.0).abs() < 1e-12); + assert!((value_in_unit(Value::angle(std::f64::consts::PI), "deg") - 180.0).abs() < 1e-9); + } + + #[test] + fn rename_and_literals() { + assert_eq!(rename_reference("width*2 + widths + width(1) + sin(width)", "width", "w"), "w*2 + widths + width(1) + sin(w)"); + assert_eq!(rename_reference("10 mm", "mm", "x"), "10 mm"); + assert!(is_literal("20") && is_literal("-1.5 in") && is_literal("3 deg")); + assert!(!is_literal("d1") && !is_literal("2 * 3") && !is_literal("2 width")); } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 88e415f5..f981dba6 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -16,7 +16,7 @@ mod project3d; pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, mat_inverse, mat_mul, rigid}; pub use document::*; pub use eval::{FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, parse_metric_thread, world_state}; -pub use params::{ParamRow, cycles}; +pub use params::{ParamRow, ParamUser, cycles}; pub use solvecraft_kernel as kernel; pub use solvecraft_sketch as sketch; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 7b74e6f8..8bc13448 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -6,10 +6,10 @@ use std::collections::{BTreeMap, BTreeSet}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; -use crate::expr::{self, Kind}; -use crate::{DocError, Document, FeatureKind, HoleKind, PatternKind, PlaneRef, Result}; +use crate::expr::{self, Kind, ParamDef, Value}; +use crate::{DocError, Document, FeatureKind, HoleKind, MAX_PARAMS, PatternKind, PlaneRef, Result}; fn plane_inputs_mut<'a>(p: &'a mut PlaneRef, v: &mut Vec<(&'static str, &'a mut String, Kind)>) { match p { @@ -144,6 +144,10 @@ impl FeatureKind { /// Read-only view of [`FeatureKind::inputs_mut`]: (label, expression, kind). pub fn inputs(&self) -> Vec<(&'static str, String, Kind)> { + // Imports have no inputs (and their STEP text is large: don't clone it). + if matches!(self, FeatureKind::Import { .. } | FeatureKind::MeshImport { .. }) { + return Vec::new(); + } let mut c = self.clone(); c.inputs_mut().into_iter().map(|(l, e, k)| (l, e.clone(), k)).collect() } @@ -271,7 +275,7 @@ impl Document { name: p.name.clone(), expression: p.expr.clone(), unit: p.unit.clone(), - value: value(&p.name, p.kind()), + value: self.param_display_value(&vals, &p.name), error: errs.get(&p.name).cloned(), comment: p.comment.clone(), source: if p.model { "sketch" } else { "user" }, @@ -385,6 +389,261 @@ impl Document { } } +/// Something that uses a parameter. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ParamUser { + /// Another parameter's expression (a stored one or a feature input). + Parameter { name: String }, + /// A feature input (`input` is its label, e.g. `Distance`). + Feature { feature: u64, name: String, input: String }, + /// A sketch dimension (`constraint` is its id). + Dimension { feature: u64, name: String, constraint: String }, +} + +impl ParamUser { + pub fn describe(&self) -> String { + match self { + ParamUser::Parameter { name } => format!("parameter {name}"), + ParamUser::Feature { name, input, .. } => format!("{name} ({input})"), + ParamUser::Dimension { name, constraint, .. } => format!("{name} dimension {constraint}"), + } + } +} + +/// Length unit of the document in mm (bare numbers of unit-less parameters next to lengths). +fn doc_len(units: &str) -> f64 { + match expr::unit_info(units) { + Some((Kind::Length, s)) => s, + _ => 1.0, + } +} + +impl Document { + /// Every parameter with its unit, for evaluation: stored parameters and named feature + /// inputs (mm / deg / none by their kind). + pub fn param_defs(&self) -> Vec { + let mut v: Vec = + self.params.iter().map(|p| ParamDef { name: p.name.clone(), expr: p.expr.clone(), unit: p.unit.clone() }).collect(); + 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() }); + } + } + } + v + } + + /// Evaluate all parameters: values (mm / rad / unit-less) and errors by name. Errors name + /// the problem (`circular reference: a → b → a`, `cannot add a length and an angle`). + pub fn param_values(&self) -> (BTreeMap, BTreeMap) { + expr::eval_params(&self.param_defs(), doc_len(&self.units)) + } + + /// A parameter's value in its own unit (e.g. 2 for a 50.8 mm parameter in inches). + pub fn param_display_value(&self, vals: &BTreeMap, name: &str) -> Option { + let v = vals.get(name)?; + let unit = match self.param(name) { + Some(p) => p.unit.clone(), + None => v.kind().map(|k| unit_of(k).to_string()).unwrap_or_default(), + }; + Some(expr::value_in_unit(*v, &unit)) + } + + /// The unit for a new parameter with this expression: that of its value; a bare number is + /// a length (mm). + pub(crate) fn infer_unit(&self, e: &str) -> String { + let (vals, _) = self.param_values(); + let v = expr::eval_with(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`")))); + match v.ok().and_then(|v| v.kind()) { + Some(Kind::Unitless) if !expr::references(e).is_empty() => String::new(), + Some(Kind::Angle) => "deg".into(), + _ => "mm".into(), + } + } + + /// Everything that uses a parameter: other parameters (and feature inputs) whose + /// expressions refer to it, and the sketch dimensions it drives. + pub fn param_users(&self, name: &str) -> Vec { + let mut out = Vec::new(); + for p in &self.params { + if p.name != name && expr::references(&p.expr).iter().any(|r| r == name) { + out.push(ParamUser::Parameter { name: p.name.clone() }); + } + } + for f in &self.features { + for (k, (label, e, _)) in f.kind.inputs().into_iter().enumerate() { + if expr::references(&e).iter().any(|r| r == name) { + match f.param_names.get(k).filter(|n| !n.is_empty()) { + Some(n) => out.push(ParamUser::Parameter { name: n.clone() }), + None => out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: label.to_string() }), + } + } + } + if let FeatureKind::Sketch { sketch, .. } = &f.kind { + for c in &sketch.constraints { + if c.param.as_deref() == Some(name) { + out.push(ParamUser::Dimension { feature: f.id, name: f.name.clone(), constraint: c.id.clone() }); + } + } + } + } + out + } + + /// Remove a stored parameter; refused while something uses it (the error lists the users). + pub fn remove_param(&mut self, name: &str) -> Result<()> { + if self.param(name).is_none() { + if self.features.iter().any(|f| f.param_names.iter().any(|n| n == name)) { + return Err(DocError::Invalid(format!("`{name}` is a feature's input: delete or edit the feature instead"))); + } + return Err(DocError::Unknown(format!("parameter `{name}`"))); + } + let users = self.param_users(name); + if !users.is_empty() { + let list: Vec = users.iter().map(ParamUser::describe).collect(); + return Err(DocError::Invalid(format!("`{name}` is used by {}", list.join(", ")))); + } + self.params.retain(|p| p.name != name); + self.favorites.remove(name); + Ok(()) + } + + /// Rename a parameter (stored or a feature input); every expression, feature input, + /// dimension, favourite and comment that refers to it follows. Returns how many + /// references were updated. + pub fn rename_param(&mut self, old: &str, new: &str) -> Result { + let new = new.trim(); + if !self.name_taken(old) { + return Err(DocError::Unknown(format!("parameter `{old}`"))); + } + if old == new { + return Ok(0); + } + if let Some(p) = Self::param_name_problem(new) { + return Err(DocError::Invalid(p)); + } + if self.name_taken(new) { + return Err(DocError::Invalid(format!("there is already a parameter `{new}`"))); + } + let mut n = 0; + for p in &mut self.params { + if p.name == old { + p.name = new.to_string(); + } + let r = expr::rename_reference(&p.expr, old, new); + if r != p.expr { + p.expr = r; + n += 1; + } + } + for f in &mut self.features { + for x in &mut f.param_names { + if x == old { + *x = new.to_string(); + } + } + for (_, e, _) in f.kind.inputs_mut() { + let r = expr::rename_reference(e, old, new); + if r != *e { + *e = r; + n += 1; + } + } + if let FeatureKind::Sketch { sketch, .. } = &mut f.kind { + for c in &mut sketch.constraints { + if c.param.as_deref() == Some(old) { + c.param = Some(new.to_string()); + n += 1; + } + } + } + } + if self.favorites.remove(old) { + self.favorites.insert(new.to_string()); + } + if let Some(c) = self.param_comments.remove(old) { + self.param_comments.insert(new.to_string(), c); + } + Ok(n) + } + + /// Set a parameter's comment (stored parameters keep it, feature inputs in `param_comments`). + pub fn set_param_comment(&mut self, name: &str, comment: &str) -> Result<()> { + if comment.len() > 4096 { + return Err(DocError::Invalid("comment too long".into())); + } + if let Some(p) = self.params.iter_mut().find(|p| p.name == name) { + p.comment = comment.to_string(); + return Ok(()); + } + if !self.name_taken(name) { + return Err(DocError::Unknown(format!("parameter `{name}`"))); + } + if comment.is_empty() { + self.param_comments.remove(name); + } else { + self.param_comments.insert(name.to_string(), comment.to_string()); + } + Ok(()) + } + + /// Dependency edges: (user, parameter used). Users are parameter names, or + /// `Feature:` for a feature whose inputs or dimensions use the parameter. + pub fn param_graph(&self) -> Vec<(String, String)> { + let mut out = expr::param_edges(&self.param_defs()); + for f in &self.features { + let mut names: BTreeSet = f.param_names.iter().filter(|n| !n.is_empty()).cloned().collect(); + if let FeatureKind::Sketch { sketch, .. } = &f.kind { + names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone())); + } + out.extend(names.into_iter().map(|n| (format!("Feature:{}", f.name), n))); + } + out + } + + /// Features whose result depends on a parameter (directly or through other parameters). + pub fn features_using_param(&self, name: &str) -> Vec { + let defs = self.param_defs(); + let mut affected: BTreeSet = BTreeSet::from([name.to_string()]); + for _ in 0..defs.len().min(MAX_PARAMS * 2) { + let before = affected.len(); + for d in &defs { + if expr::references(&d.expr).iter().any(|r| affected.contains(r)) { + affected.insert(d.name.clone()); + } + } + if affected.len() == before { + break; + } + } + let mut out = Vec::new(); + for f in &self.features { + let mut hit = f.param_names.iter().any(|n| affected.contains(n)); + hit |= f.kind.expressions().iter().any(|e| expr::references(e).iter().any(|r| affected.contains(r))); + if let FeatureKind::Sketch { sketch, .. } = &f.kind { + hit |= sketch.constraints.iter().any(|c| c.param.as_ref().is_some_and(|p| affected.contains(p))); + } + if hit { + out.push(f.id); + } + } + out + } + + /// Add a user parameter (refused when the name exists). + pub fn add_user_param(&mut self, name: &str, expr_s: &str, unit: Option<&str>, comment: Option<&str>) -> Result<()> { + if self.name_taken(name.trim()) { + return Err(DocError::Invalid(format!("there is already a parameter `{}`", name.trim()))); + } + if self.params.len() >= MAX_PARAMS { + return Err(DocError::Invalid("too many parameters".into())); + } + self.set_param(name.trim(), expr_s, unit, comment) + } +} + /// One CSV line with quoted fields. fn parse_csv_line(l: &str) -> Vec { let mut out = Vec::new(); diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 99f27c31..83f77c5c 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -258,6 +258,9 @@ fn parameter_cycles_are_found() { ]; let c = crate::cycles(&list); assert_eq!(c.into_iter().collect::>(), vec!["a", "b", "c", "e"]); - let (vals, errs) = crate::expr::eval_params(&list); + let defs: Vec = + list.iter().map(|(n, e, _)| crate::expr::ParamDef { name: n.clone(), expr: e.clone(), unit: "mm".into() }).collect(); + let (vals, errs) = crate::expr::eval_params(&defs, 1.0); + assert!(errs["a"].contains("circular reference: a → b → c → a"), "{}", errs["a"]); assert!(vals.contains_key("d") && errs.contains_key("a") && errs.contains_key("e")); } diff --git a/crates/engine/((((((((((1 b/crates/engine/((((((((((1 deleted file mode 100644 index fc049516..00000000 --- a/crates/engine/((((((((((1 +++ /dev/null @@ -1 +0,0 @@ -name,unit,expression,value,comment,favorite diff --git a/crates/engine/nope b/crates/engine/nope deleted file mode 100644 index fc049516..00000000 --- a/crates/engine/nope +++ /dev/null @@ -1 +0,0 @@ -name,unit,expression,value,comment,favorite diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 896349e9..cb879fdf 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1,4 +1,4 @@ -//! Undo/redo, parameters, the timeline, deletion and selection. +//! Undo/redo, the timeline, deletion and selection. use serde_json::{Value, json}; use solvecraft_doc::{Feature, FeatureKind}; @@ -10,17 +10,6 @@ use crate::{EngineError, Result, Sel, Session, Snapshot}; pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("UndoCommand", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(), CommandSpec::new("RedoCommand", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(), - CommandSpec::new("parameters.list", "List Parameters", params_list).noundo().params("favorites?: bool (only favourites)"), - CommandSpec::new("parameters.favorite", "Favourite Parameter", params_favorite).params("name, favorite?: bool (default true)"), - CommandSpec::new("parameters.export", "Export Parameters", params_export) - .noundo() - .params("format?: csv|json (default csv), path?: write to a file"), - CommandSpec::new("parameters.import", "Import Parameters", params_import) - .params("text: CSV (name,unit,expression,comment) or JSON [{name, expression, unit?, comment?}] | path"), - CommandSpec::new("ChangeParameterCommand", "Change Parameters", change_param) - .at("SOLID", "MODIFY") - .icon("params") - .params("name, expression (number or text), unit?: mm|deg|\"\", comment?; or delete: name"), CommandSpec::new("FusionComputeAllCommand", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(), CommandSpec::new("FusionDeleteCommand", "Delete", delete) .at("SOLID", "MODIFY") @@ -69,29 +58,6 @@ fn redo(s: &mut Session, _p: &Value) -> Result { Ok(json!({"redone": label})) } -fn change_param(s: &mut Session, p: &Value) -> Result { - let cmd = "ChangeParameterCommand"; - if let Some(n) = str_(p, "delete") { - s.doc_mut().remove_param(n)?; - return Ok(json!({"deleted": n})); - } - let name = str_(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?; - let e = match p.get("expression").or_else(|| p.get("value")) { - Some(Value::String(x)) => x.clone(), - Some(Value::Number(n)) => n.to_string(), - _ => return Err(bad(cmd, "`expression` must be a number or text")), - }; - let unit = str_(p, "unit"); - let comment = str_(p, "comment"); - s.doc_mut().change_param(name, &e, unit, comment)?; - s.refresh(); - let (vals, _) = s.doc.param_values(); - let v = vals.get(name).map(|v| v.v); - // Report features that now fail. - let errors: Vec = s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e}))).collect(); - Ok(json!({"name": name, "expression": e, "value": v, "recomputed": s.model.last_recomputed, "errors": errors})) -} - fn compute_all(s: &mut Session, _p: &Value) -> Result { s.model = solvecraft_doc::Model::new(); s.refresh(); @@ -340,57 +306,3 @@ fn dependents(s: &mut Session, p: &Value) -> Result { let broken: Vec = newly_broken(&s.model, &after).into_iter().map(|(n, _)| n).collect(); Ok(json!({"deleted_with_it": names(&gone), "would_fail": broken})) } - -fn params_list(s: &mut Session, p: &Value) -> Result { - let only_fav = bool_(p, "favorites").unwrap_or(false); - let rows: Vec = s.doc.param_rows().into_iter().filter(|r| !only_fav || r.favorite).collect(); - let cycles = solvecraft_doc::cycles(&s.doc.all_param_exprs()); - Ok(json!({"parameters": rows, "cycles": cycles})) -} - -fn params_favorite(s: &mut Session, p: &Value) -> Result { - let cmd = "parameters.favorite"; - let name = str_(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?; - let on = bool_(p, "favorite").unwrap_or(true); - s.doc_mut().set_favorite(name, on)?; - Ok(json!({"name": name, "favorite": on})) -} - -fn params_export(s: &mut Session, p: &Value) -> Result { - let cmd = "parameters.export"; - let text = match str_(p, "format").unwrap_or("csv") { - "csv" => s.doc.params_csv(), - "json" => { - let rows: Vec = s - .doc - .param_rows() - .into_iter() - .filter(|r| r.source == "user") - .map(|r| json!({"name": r.name, "expression": r.expression, "unit": r.unit, "value": r.value, "comment": r.comment, "favorite": r.favorite})) - .collect(); - serde_json::to_string_pretty(&rows).map_err(|e| bad(cmd, e.to_string()))? - } - other => return Err(bad(cmd, format!("unknown format `{other}`"))), - }; - if let Some(path) = str_(p, "path") { - std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - } - Ok(json!({ "text": text })) -} - -fn params_import(s: &mut Session, p: &Value) -> Result { - let cmd = "parameters.import"; - let text = match (str_(p, "text"), str_(p, "path")) { - (Some(t), _) => t.to_string(), - (None, Some(path)) => { - let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - if meta.len() > 16 << 20 { - return Err(bad(cmd, "file too large")); - } - std::fs::read_to_string(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))? - } - _ => return Err(bad(cmd, "give `text` or `path`")), - }; - let names = s.doc_mut().import_params(&text)?; - Ok(json!({ "imported": names })) -} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 54b97cd0..f678a4be 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -11,6 +11,7 @@ mod file; mod inspect; mod measure_sel; pub(crate) use measure_sel::measure_items; +mod parameters; mod sketch; mod sketch_constraints; mod sketch_create; @@ -121,6 +122,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(features::COMMANDS.iter()); v.extend(component::COMMANDS.iter()); v.extend(edit::COMMANDS.iter()); + v.extend(parameters::COMMANDS.iter()); v.extend(file::COMMANDS.iter()); v.extend(inspect::COMMANDS.iter()); v diff --git a/crates/engine/src/cmd/parameters.rs b/crates/engine/src/cmd/parameters.rs new file mode 100644 index 00000000..1ca10f9e --- /dev/null +++ b/crates/engine/src/cmd/parameters.rs @@ -0,0 +1,326 @@ +//! Parameters: Change Parameters (add, edit, rename, delete) and the `parameters.*` commands +//! behind the parameters dialog — list, add, rename, favourite, comment, users, dependency +//! graph, CSV/JSON import and export, expression evaluation and autocomplete. + +use serde_json::{Value, json}; +use solvecraft_doc::expr::{self, Kind}; + +use super::CommandSpec; +use crate::params::{bad, bool_, str_}; +use crate::{EngineError, Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("ChangeParameterCommand", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params( + "name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\" (new parameters default to their value's unit), comment?, \ + favorite?: bool, new_name?: rename (references follow); or delete: name", + ), + CommandSpec::new("parameters.list", "List Parameters", list).noundo().params( + "favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \ + source, feature/input, comment, favorite, uses, used_by, error) and cycles", + ), + CommandSpec::new("parameters.add", "Add User Parameter", add).params("name, expression, unit?, comment?, favorite?: bool"), + CommandSpec::new("parameters.rename", "Rename Parameter", rename) + .params("name, new_name (expressions, feature inputs and dimensions that use it follow)"), + CommandSpec::new("parameters.delete", "Delete Parameter", delete).params("name (refused while something uses it; the error lists the users)"), + CommandSpec::new("parameters.favorite", "Favourite Parameter", favorite).params("name, favorite?: bool (default true)"), + CommandSpec::new("parameters.comment", "Comment Parameter", comment).params("name, comment"), + CommandSpec::new("parameters.users", "Parameter Users", users) + .noundo() + .params("name → users (parameters, feature inputs, dimensions) and the features it drives"), + CommandSpec::new("parameters.graph", "Parameter Graph", graph) + .noundo() + .params("→ nodes, edges [user, used] (users include Feature:), cycles, errors"), + CommandSpec::new("parameters.export", "Export Parameters", export) + .noundo() + .params("format?: csv|json (default csv, or from the path), path?: write to a file → text"), + CommandSpec::new("parameters.import", "Import Parameters", import) + .params("text: CSV (header with name, expression, unit?, comment?) or JSON [{name, expression, unit?, comment?}] | path; all or nothing"), + CommandSpec::new("expr.evaluate", "Evaluate Expression", evaluate) + .noundo() + .params("expression, kind?: length|angle|unitless (or unit?: mm|in|deg…) → ok, value (mm/rad), display_value, references, error"), + CommandSpec::new("parameters.complete", "Complete Expression", complete) + .noundo() + .params("prefix?: the text before the cursor → word, suggestions [{text, kind: parameter|function|unit|constant, detail}]"), +]; + +fn expr_arg(p: &Value, k: &str) -> Option { + match p.get(k)? { + Value::String(x) => Some(x.clone()), + Value::Number(n) => n.as_f64().filter(|v| v.is_finite()).map(|v| format!("{v}")), + _ => None, + } +} + +fn param_name<'a>(cmd: &str, p: &'a Value) -> Result<&'a str> { + str_(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "`name` is required")) +} + +/// Features that fail (name and error). +fn failing(s: &Session) -> Vec { + s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e}))).collect() +} + +fn known(s: &Session, name: &str) -> bool { + s.doc.param(name).is_some() || s.doc.features.iter().any(|f| f.param_names.iter().any(|n| n == name)) +} + +fn change_param(s: &mut Session, p: &Value) -> Result { + let cmd = "ChangeParameterCommand"; + if let Some(n) = str_(p, "delete") { + s.doc_mut().remove_param(n.trim())?; + return Ok(json!({"deleted": n.trim()})); + } + let mut name = param_name(cmd, p)?.to_string(); + let mut out = json!({}); + if let Some(new) = str_(p, "new_name") { + let n = s.doc_mut().rename_param(&name, new)?; + out["renamed"] = json!({"from": name, "to": new.trim(), "references_updated": n}); + name = new.trim().to_string(); + } + let has_expr = p.get("expression").is_some() || p.get("value").is_some(); + let e = if has_expr { + Some(expr_arg(p, "expression").or_else(|| expr_arg(p, "value")).ok_or_else(|| bad(cmd, "`expression` must be a number or text"))?) + } else if out.get("renamed").is_some() || p.get("favorite").is_some() || p.get("comment").is_some() { + None + } else { + return Err(bad(cmd, "`expression` must be a number or text")); + }; + let (unit, comment) = (str_(p, "unit"), str_(p, "comment")); + match &e { + Some(e) => s.doc_mut().change_param(&name, e, unit, comment)?, + None => { + if !known(s, &name) { + return Err(bad(cmd, format!("no parameter `{name}`"))); + } + if let Some(c) = comment { + s.doc_mut().set_param_comment(&name, c)?; + } + } + } + if let Some(f) = bool_(p, "favorite") { + s.doc_mut().set_favorite(&name, f)?; + } + s.refresh(); + let (vals, _) = s.doc.param_values(); + out["name"] = json!(name); + out["expression"] = json!(e); + out["value"] = json!(vals.get(&name).map(|v| v.v)); + out["display_value"] = json!(s.doc.param_display_value(&vals, &name)); + out["recomputed"] = json!(s.model.last_recomputed); + out["errors"] = json!(failing(s)); + Ok(out) +} + +fn list(s: &mut Session, p: &Value) -> Result { + let filter = match (bool_(p, "favorites"), str_(p, "filter")) { + (Some(true), _) => "favorites".to_string(), + (_, Some(f)) => f.to_ascii_lowercase(), + _ => "all".to_string(), + }; + let mut rows: Vec = s + .doc + .param_rows() + .into_iter() + .filter(|r| match filter.as_str() { + "favorites" | "favourites" | "favorite" => r.favorite, + "user" | "sketch" | "feature" => r.source == filter, + "model" => r.source != "user", + _ => true, + }) + .collect(); + // Favourites first; otherwise the table order (user, sketch, feature). + rows.sort_by_key(|r| !r.favorite); + let cycles = solvecraft_doc::cycles(&s.doc.all_param_exprs()); + Ok(json!({"parameters": rows, "count": rows.len(), "cycles": cycles})) +} + +fn add(s: &mut Session, p: &Value) -> Result { + let cmd = "parameters.add"; + let name = param_name(cmd, p)?.to_string(); + let e = expr_arg(p, "expression").or_else(|| expr_arg(p, "value")).ok_or_else(|| bad(cmd, "`expression` must be a number or text"))?; + s.doc_mut().add_user_param(&name, &e, str_(p, "unit"), str_(p, "comment"))?; + if let Some(f) = bool_(p, "favorite") { + s.doc_mut().set_favorite(&name, f)?; + } + let (vals, _) = s.doc.param_values(); + Ok( + json!({"name": name, "unit": s.doc.param(&name).map(|x| x.unit.clone()), "value": vals.get(&name).map(|v| v.v), "display_value": s.doc.param_display_value(&vals, &name)}), + ) +} + +fn rename(s: &mut Session, p: &Value) -> Result { + let cmd = "parameters.rename"; + let name = param_name(cmd, p)?.to_string(); + let new = str_(p, "new_name").ok_or_else(|| bad(cmd, "`new_name` is required"))?; + let n = s.doc_mut().rename_param(&name, new)?; + Ok(json!({"from": name, "to": new.trim(), "references_updated": n})) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + let name = param_name("parameters.delete", p)?.to_string(); + s.doc_mut().remove_param(&name)?; + Ok(json!({"deleted": name})) +} + +fn favorite(s: &mut Session, p: &Value) -> Result { + let name = param_name("parameters.favorite", p)?.to_string(); + let on = bool_(p, "favorite").unwrap_or(true); + s.doc_mut().set_favorite(&name, on)?; + Ok(json!({"name": name, "favorite": on})) +} + +fn comment(s: &mut Session, p: &Value) -> Result { + let cmd = "parameters.comment"; + let name = param_name(cmd, p)?.to_string(); + let c = str_(p, "comment").ok_or_else(|| bad(cmd, "`comment` must be text"))?; + s.doc_mut().set_param_comment(&name, c)?; + Ok(json!({"name": name, "comment": c})) +} + +fn users(s: &mut Session, p: &Value) -> Result { + let cmd = "parameters.users"; + let name = param_name(cmd, p)?; + if !known(s, name) { + return Err(bad(cmd, format!("no parameter `{name}`"))); + } + let users = s.doc.param_users(name); + let drives: Vec = + s.doc.features_using_param(name).iter().filter_map(|id| s.doc.feature(*id)).map(|f| json!({"id": f.id, "name": f.name})).collect(); + Ok(json!({"name": name, "users": users, "drives": drives})) +} + +fn graph(s: &mut Session, _p: &Value) -> Result { + let (_, errs) = s.doc.param_values(); + let nodes: Vec = s.doc.param_defs().into_iter().map(|d| d.name).collect(); + let edges: Vec<[String; 2]> = s.doc.param_graph().into_iter().map(|(a, b)| [a, b]).collect(); + let cycles = solvecraft_doc::cycles(&s.doc.all_param_exprs()); + Ok(json!({"nodes": nodes, "edges": edges, "cycles": cycles, "errors": errs})) +} + +fn export(s: &mut Session, p: &Value) -> Result { + let cmd = "parameters.export"; + let path = str_(p, "path"); + let format = str_(p, "format").map(str::to_ascii_lowercase).unwrap_or_else(|| match path { + Some(x) if x.to_ascii_lowercase().ends_with(".json") => "json".into(), + _ => "csv".into(), + }); + let text = match format.as_str() { + "csv" => s.doc.params_csv(), + "json" => { + let rows: Vec = s + .doc + .param_rows() + .into_iter() + .filter(|r| r.source == "user") + .map(|r| json!({"name": r.name, "expression": r.expression, "unit": r.unit, "value": r.value, "comment": r.comment, "favorite": r.favorite})) + .collect(); + serde_json::to_string_pretty(&rows).map_err(|e| bad(cmd, e.to_string()))? + } + other => return Err(bad(cmd, format!("unknown format `{other}` (csv or json)"))), + }; + if let Some(path) = path { + std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + } + Ok(json!({"format": format, "path": path, "text": text})) +} + +fn import(s: &mut Session, p: &Value) -> Result { + let cmd = "parameters.import"; + let text = match (str_(p, "text"), str_(p, "path")) { + (Some(t), _) => t.to_string(), + (None, Some(path)) => { + let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if meta.len() > 16 << 20 { + return Err(bad(cmd, "file too large")); + } + std::fs::read_to_string(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))? + } + _ => return Err(bad(cmd, "give `text` or `path`")), + }; + let names = s.doc_mut().import_params(&text)?; + s.refresh(); + Ok(json!({"imported": names, "errors": failing(s)})) +} + +fn evaluate(s: &mut Session, p: &Value) -> Result { + let cmd = "expr.evaluate"; + let e = expr_arg(p, "expression").ok_or_else(|| bad(cmd, "`expression` must be text or a number"))?; + let unit = str_(p, "unit"); + if let Some(u) = unit + && expr::unit_info(u).is_none() + { + return Err(bad(cmd, format!("unknown unit `{u}`"))); + } + let kind = match (str_(p, "kind"), unit) { + (Some("length"), _) => Some(Kind::Length), + (Some("angle"), _) => Some(Kind::Angle), + (Some("unitless" | "number"), _) => Some(Kind::Unitless), + (Some(other), _) => return Err(bad(cmd, format!("unknown kind `{other}` (length, angle or unitless)"))), + (None, Some(u)) => Some(Kind::from_unit(u)), + (None, None) => None, + }; + let (vals, _) = s.doc.param_values(); + let defaults = expr::Defaults::for_unit(unit.unwrap_or(""), 1.0); + let refs = expr::references(&e); + let lookup = |n: &str| vals.get(n).copied().ok_or_else(|| solvecraft_doc::DocError::Expr(format!("unknown parameter `{n}`"))); + let r = expr::eval_with_defaults(&e, &lookup, defaults).and_then(|v| match kind { + Some(k) => v.to_kind_in(k, defaults).map(|x| (x, k)), + None => v.kind().map(|k| (v.v, k)).ok_or_else(|| solvecraft_doc::DocError::Expr(format!("the result is {}", v.describe()))), + }); + Ok(match r { + Ok((v, k)) => { + let value = match k { + Kind::Length => expr::Value::length(v), + Kind::Angle => expr::Value::angle(v), + Kind::Unitless => expr::Value::num(v), + }; + let shown = match (unit, k) { + (Some(u), _) => expr::value_in_unit(value, u), + (None, Kind::Angle) => v.to_degrees(), + (None, _) => v, + }; + json!({"ok": true, "value": v, "kind": k, "display_value": shown, "references": refs}) + } + Err(err) => json!({"ok": false, "error": err.to_string().trim_start_matches("expression: ").to_string(), "references": refs}), + }) +} + +fn complete(s: &mut Session, p: &Value) -> Result { + let text = str_(p, "prefix").unwrap_or(""); + // The identifier being typed: trailing letters, digits and underscores. + let word: String = text.chars().rev().take_while(|c| c.is_alphanumeric() || *c == '_').collect::>().into_iter().rev().collect(); + let starts = |n: &str| word.is_empty() || n.to_ascii_lowercase().starts_with(&word.to_ascii_lowercase()); + let mut out: Vec = Vec::new(); + let mut rows: Vec = s.doc.param_rows().into_iter().filter(|r| starts(&r.name)).collect(); + // Favourites, then user parameters, then the rest. + rows.sort_by_key(|r| (!r.favorite, r.source != "user")); + for r in rows.into_iter().take(200) { + let v = r.value.map(|v| format!("{v:.4} {}", r.unit).trim().to_string()).unwrap_or_else(|| "error".into()); + let mut detail = format!("{} = {v}", r.expression); + if let (Some(f), Some(i)) = (&r.feature, r.input) { + detail.push_str(&format!(" ({f} {i})")); + } + if !r.comment.is_empty() { + detail.push_str(&format!(" — {}", r.comment)); + } + out.push(json!({"text": r.name, "kind": "parameter", "detail": detail})); + } + for (f, sig) in expr::FUNCTIONS { + if starts(f) { + out.push(json!({"text": format!("{f}("), "kind": "function", "detail": sig})); + } + } + if starts("pi") { + out.push(json!({"text": "pi", "kind": "constant", "detail": "3.14159…"})); + } + for (u, k, _) in expr::UNITS { + if !word.is_empty() && starts(u) { + out.push(json!({"text": u, "kind": "unit", "detail": k})); + } + } + Ok(json!({"word": word, "suggestions": out})) +} + +#[cfg(test)] +#[path = "parameters_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/parameters_tests.rs b/crates/engine/src/cmd/parameters_tests.rs new file mode 100644 index 00000000..f5dd4cc4 --- /dev/null +++ b/crates/engine/src/cmd/parameters_tests.rs @@ -0,0 +1,226 @@ +use serde_json::{Value, json}; + +use crate::{EngineError, Session}; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn err(s: &mut Session, id: &str, p: Value) -> String { + match s.execute(id, &p) { + Ok(v) => panic!("{id} {p} should fail, gave {v}"), + Err(e) => e.to_string(), + } +} + +fn volume(s: &mut Session) -> f64 { + run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN) +} + +fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs().max(1e-12) +} + +fn row<'a>(list: &'a Value, name: &str) -> &'a Value { + list["parameters"].as_array().and_then(|a| a.iter().find(|p| p["name"] == name)).unwrap_or(&Value::Null) +} + +fn expr_of(s: &mut Session, name: &str) -> String { + let l = run(s, "parameters.list", json!({})); + row(&l, name)["expression"].as_str().unwrap_or_default().to_string() +} + +/// A sketched block 40 x (len / 2) x 10 whose width is an expression of a user parameter. +fn block(s: &mut Session) { + run(s, "parameters.add", json!({"name": "len", "expression": "60 mm", "comment": "overall length", "favorite": true})); + run(s, "SketchCreate", json!({"plane": "XY", "name": "Base"})); + run(s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]})); + run(s, "ConstraintCoincident", json!({"a": "p1", "b": "origin"})); + run(s, "SketchDimension", json!({"entities": ["l1"], "value": 40})); + run(s, "SketchDimension", json!({"entities": ["l2"], "value": "len/2"})); + run(s, "SketchStop", json!({})); + run(s, "Extrude", json!({"distance": 10, "body_name": "Block"})); +} + +#[test] +fn width_follows_an_expression() { + let mut s = Session::default(); + block(&mut s); + assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9); + let r = run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "100 mm"})); + assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}"); + assert!(rel(volume(&mut s), 40.0 * 50.0 * 10.0) < 1e-9); + // Units convert: 4 in = 101.6 mm. + run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "4 in"})); + assert!(rel(volume(&mut s), 40.0 * 50.8 * 10.0) < 1e-9); + // A parameter in inches reads bare numbers as inches. + run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "5", "unit": "in"})); + assert!(rel(volume(&mut s), 40.0 * 63.5 * 10.0) < 1e-9); + let l = run(&mut s, "parameters.list", json!({})); + assert_eq!(row(&l, "len")["value"], 5.0, "{l}"); + assert_eq!(row(&l, "len")["unit"], "in"); + // A failing parameter fails the features using it, with its error. + let e = err(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "5 deg"})); + assert!(e.contains("expected a length, got an angle"), "{e}"); +} + +#[test] +fn feature_inputs_are_named_and_editable() { + let mut s = Session::default(); + block(&mut s); + let l = run(&mut s, "parameters.list", json!({"filter": "feature"})); + let dist = l["parameters"].as_array().unwrap().iter().find(|r| r["input"] == "Distance").unwrap().clone(); + assert_eq!(dist["feature"], "Extrude1", "{l}"); + let d = dist["name"].as_str().unwrap().to_string(); + run(&mut s, "ChangeParameterCommand", json!({"name": d, "expression": "len / 3"})); + assert!(rel(volume(&mut s), 40.0 * 30.0 * 20.0) < 1e-9); + // Editing the feature keeps its parameter name. + run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "12 mm"}}})); + assert!(rel(volume(&mut s), 40.0 * 30.0 * 12.0) < 1e-9); + assert_eq!(expr_of(&mut s, &d), "12 mm"); + // Another feature can use it. + run(&mut s, "PrimitiveBox", json!({"length": d, "width": 5, "height": 5, "corner": [100, 0, 0]})); + assert!(rel(volume(&mut s), 40.0 * 30.0 * 12.0 + 12.0 * 25.0) < 1e-9); + let u = run(&mut s, "parameters.users", json!({"name": d})); + assert_eq!(u["users"].as_array().map(Vec::len), Some(1), "{u}"); + assert_eq!(u["drives"].as_array().map(Vec::len), Some(2), "{u}"); + // Feature inputs can't be deleted on their own. + assert!(err(&mut s, "parameters.delete", json!({"name": d})).contains("feature's input")); +} + +#[test] +fn unit_errors_and_cycles_are_refused() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "a", "expression": "10 mm"})); + let e = err(&mut s, "parameters.add", json!({"name": "bad", "expression": "a + 5 deg"})); + assert!(e.contains("cannot add a length and an angle"), "{e}"); + let e = err(&mut s, "parameters.add", json!({"name": "ang", "expression": "a", "unit": "deg"})); + assert!(e.contains("expected an angle, got a length"), "{e}"); + assert!(err(&mut s, "parameters.add", json!({"name": "x", "expression": "1", "unit": "furlong"})).contains("unknown unit")); + run(&mut s, "parameters.add", json!({"name": "b", "expression": "a * 2"})); + let e = err(&mut s, "ChangeParameterCommand", json!({"name": "a", "expression": "b + 1"})); + assert!(e.contains("circular reference: a → b → a"), "{e}"); + assert!(err(&mut s, "parameters.add", json!({"name": "selfref", "expression": "selfref"})).contains("circular")); + assert!(err(&mut s, "parameters.add", json!({"name": "a", "expression": "1"})).contains("already")); + assert!(err(&mut s, "parameters.add", json!({"name": "sin", "expression": "1"})).contains("function")); + assert!(err(&mut s, "parameters.add", json!({"name": "2x", "expression": "1"})).contains("not a valid parameter name")); + // Units are inferred for new parameters. + run(&mut s, "parameters.add", json!({"name": "tilt", "expression": "30 deg"})); + run(&mut s, "parameters.add", json!({"name": "n", "expression": "b / a"})); + assert_eq!(s.doc.param("tilt").map(|p| p.unit.as_str()), Some("deg")); + assert_eq!(s.doc.param("n").map(|p| p.unit.as_str()), Some("")); + let r = run(&mut s, "expr.evaluate", json!({"expression": "atan2(b; a) + tilt", "kind": "angle"})); + assert!((r["display_value"].as_f64().unwrap_or(0.0) - (2.0f64.atan().to_degrees() + 30.0)).abs() < 1e-9, "{r}"); + let r = run(&mut s, "expr.evaluate", json!({"expression": "a + tilt"})); + assert_eq!(r["ok"], false); + assert!(r["error"].as_str().unwrap_or("").contains("cannot add"), "{r}"); + let r = run(&mut s, "expr.evaluate", json!({"expression": "2", "unit": "in"})); + assert!((r["value"].as_f64().unwrap_or(0.0) - 50.8).abs() < 1e-9, "{r}"); +} + +#[test] +fn rename_updates_every_reference() { + let mut s = Session::default(); + block(&mut s); + run(&mut s, "parameters.add", json!({"name": "twice", "expression": "len * 2"})); + let r = run(&mut s, "parameters.rename", json!({"name": "len", "new_name": "length"})); + assert_eq!(r["references_updated"], 2, "{r}"); + assert_eq!(expr_of(&mut s, "twice"), "length * 2"); + let l = run(&mut s, "parameters.list", json!({"favorites": true})); + assert_eq!(l["count"], 1, "the favourite follows the rename"); + assert_eq!(row(&l, "length")["comment"], "overall length"); + run(&mut s, "ChangeParameterCommand", json!({"name": "length", "expression": "80"})); + assert!(rel(volume(&mut s), 40.0 * 40.0 * 10.0) < 1e-9); + // Model and feature parameters can be renamed too: dimensions and features follow. + let dim = s.doc.features.first().and_then(|f| match &f.kind { + solvecraft_doc::FeatureKind::Sketch { sketch, .. } => sketch.constraints.iter().filter_map(|c| c.param.clone()).next(), + _ => None, + }); + let dim = dim.unwrap_or_default(); + run(&mut s, "ChangeParameterCommand", json!({"name": dim, "new_name": "base_w", "expression": "50"})); + assert!(rel(volume(&mut s), 50.0 * 40.0 * 10.0) < 1e-9); + let ext = s.doc.features.get(1).and_then(|f| f.param_names.first().cloned()).unwrap_or_default(); + run(&mut s, "parameters.rename", json!({"name": ext, "new_name": "thick"})); + run(&mut s, "ChangeParameterCommand", json!({"name": "thick", "expression": "base_w / 10"})); + assert!(rel(volume(&mut s), 50.0 * 40.0 * 5.0) < 1e-9); + assert!(err(&mut s, "parameters.rename", json!({"name": "twice", "new_name": "length"})).contains("already")); + assert!(err(&mut s, "parameters.rename", json!({"name": "nope", "new_name": "x"})).contains("unknown")); + // Undo puts the old name back. + run(&mut s, "UndoCommand", json!({})); + run(&mut s, "UndoCommand", json!({})); + assert!(s.doc.features.get(1).is_some_and(|f| f.param_names.first() == Some(&ext))); +} + +#[test] +fn deleting_a_used_parameter_lists_its_users() { + let mut s = Session::default(); + block(&mut s); + run(&mut s, "parameters.add", json!({"name": "spare", "expression": "len + 1"})); + let e = err(&mut s, "parameters.delete", json!({"name": "len"})); + assert!(e.contains("parameter spare") && e.contains("parameter d"), "{e}"); + let u = run(&mut s, "parameters.users", json!({"name": "len"})); + assert_eq!(u["users"].as_array().map(Vec::len), Some(2), "{u}"); + assert_eq!(u["drives"][0]["name"], "Base", "{u}"); + let dim = u["users"].as_array().unwrap().iter().find(|x| x["name"] != "spare").unwrap()["name"].as_str().unwrap().to_string(); + let e = err(&mut s, "ChangeParameterCommand", json!({"delete": dim})); + assert!(e.contains("Base dimension"), "{e}"); + run(&mut s, "parameters.delete", json!({"name": "spare"})); + assert!(s.doc.param("spare").is_none()); + let g = run(&mut s, "parameters.graph", json!({})); + assert!(g["edges"].as_array().unwrap().iter().any(|e| e[0] == dim.as_str() && e[1] == "len"), "{g}"); + assert!(g["edges"].as_array().unwrap().iter().any(|e| e[0] == "Feature:Base" && e[1] == dim.as_str()), "{g}"); +} + +#[test] +fn favorites_comments_and_completion() { + let mut s = Session::default(); + block(&mut s); + run(&mut s, "parameters.favorite", json!({"name": "len", "favorite": false})); + assert_eq!(run(&mut s, "parameters.list", json!({"favorites": true}))["count"], 0); + let ext = s.doc.features.get(1).and_then(|f| f.param_names.first().cloned()).unwrap_or_default(); + run(&mut s, "parameters.comment", json!({"name": ext, "comment": "plate thickness"})); + let l = run(&mut s, "parameters.list", json!({})); + assert_eq!(row(&l, &ext)["comment"], "plate thickness"); + let c = run(&mut s, "parameters.complete", json!({"prefix": "2 * le"})); + assert_eq!(c["word"], "le"); + assert_eq!(c["suggestions"][0]["text"], "len", "{c}"); + let c = run(&mut s, "parameters.complete", json!({"prefix": "at"})); + assert!(c["suggestions"].as_array().unwrap().iter().any(|x| x["text"] == "atan2("), "{c}"); +} + +#[test] +fn csv_and_json_round_trip() { + let mut s = Session::default(); + block(&mut s); + run(&mut s, "parameters.add", json!({"name": "note", "expression": "len / 4", "comment": "has, a \"comma\""})); + let csv = run(&mut s, "parameters.export", json!({"format": "csv"}))["text"].as_str().unwrap_or_default().to_string(); + assert!(csv.contains("note,mm,len / 4,15,\"has, a \"\"comma\"\"\",false"), "{csv}"); + let json_text = run(&mut s, "parameters.export", json!({"format": "json"}))["text"].as_str().unwrap_or_default().to_string(); + + let mut t = Session::default(); + run(&mut t, "parameters.import", json!({"text": csv})); + assert_eq!(t.doc.param("note").map(|p| p.comment.as_str()), Some("has, a \"comma\"")); + let mut u = Session::default(); + run(&mut u, "parameters.import", json!({"text": json_text})); + assert_eq!(u.doc.param("note").map(|p| p.expr.as_str()), Some("len / 4")); + + // Importing into the design updates values and rebuilds; all or nothing. + run(&mut s, "parameters.import", json!({"text": "name,expression\nlen,80 mm\nextra,3\n"})); + assert!(rel(volume(&mut s), 40.0 * 40.0 * 10.0) < 1e-9); + assert!(s.execute("parameters.import", &json!({"text": "name,expression\nlen,90 mm\nbroken,len + 1 deg\n"})).is_err()); + assert_eq!(s.doc.param("len").map(|p| p.expr.as_str()), Some("80 mm")); + assert!(matches!(s.execute("parameters.import", &json!({})), Err(EngineError::BadParams { .. }))); + + let dir = std::env::temp_dir().join(format!("solvecraft-params-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("p.json"); + let r = run(&mut s, "parameters.export", json!({"path": path.to_string_lossy()})); + assert_eq!(r["format"], "json"); + let mut v = Session::default(); + run(&mut v, "parameters.import", json!({"path": path.to_string_lossy()})); + assert_eq!(v.doc.param("len").map(|p| p.expr.as_str()), Some("80 mm")); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dc5f14d3..03c4a8e8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -288,6 +288,12 @@ fn hostile_params_never_panic() { "datum", "tolerance", "face", + "new_name", + "favorite", + "favorites", + "filter", + "kind", + "prefix", ]; let mut internal = Vec::new(); for spec in command_specs() { @@ -299,6 +305,9 @@ fn hostile_params_never_panic() { } for v in hostile_values() { for k in keys { + if k == "path" && spec.id.starts_with("parameters.") { + continue; // parameter files: covered by their own tests (no stray files) + } let mut s = Session::default(); // A model with a sketch, a body and an active sketch, so most commands get far. let _ = s.execute("PrimitiveBox", &json!({"length": 10, "width": 10, "height": 10}));