From f0cfeba59b1a4a9b60a7bcc75e8e70713353546d Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 23:20:51 +0000 Subject: [PATCH] Parameters engine: feature inputs as named model parameters (dN, editable, referenceable), parameters table with sources, values, dependencies (uses/used by) and cycles, favourites, comments, CSV/JSON export and import; more units (inch, mil, yd, km, nm) and functions (exp, ln, log, sign, pow, hypot) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/document.rs | 36 ++- crates/doc/src/expr.rs | 43 ++- crates/doc/src/lib.rs | 2 + crates/doc/src/params.rs | 431 ++++++++++++++++++++++++++++++ crates/doc/src/tests.rs | 66 +++++ crates/engine/src/cmd/edit.rs | 63 ++++- crates/engine/src/cmd/features.rs | 1 + crates/engine/src/tests.rs | 37 +++ 8 files changed, 665 insertions(+), 14 deletions(-) create mode 100644 crates/doc/src/params.rs diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index ef0c951a..cadfb502 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -605,6 +605,9 @@ pub struct Feature { /// Names for the bodies this feature creates (in order); missing ones get `BodyN`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub body_names: Vec, + /// Parameter names of the feature's inputs (see `FeatureKind::inputs`), e.g. `d3`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub param_names: Vec, /// The component the feature (and what it makes) belongs to; 0 = the root. #[serde(default, skip_serializing_if = "is_root")] pub component: u64, @@ -641,6 +644,12 @@ pub struct Document { /// Placements of components in their parents (each non-root component has at least one). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub occurrences: Vec, + /// Favourite parameters (by name). + #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")] + pub favorites: std::collections::BTreeSet, + /// Comments of feature-input parameters (by name). + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub param_comments: std::collections::BTreeMap, /// Physical material per body (by body name); others use the default. #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub materials: std::collections::BTreeMap, @@ -676,6 +685,8 @@ impl Document { occurrences: Vec::new(), body_components: Default::default(), materials: Default::default(), + favorites: Default::default(), + param_comments: Default::default(), } } @@ -724,6 +735,12 @@ impl Document { pub fn from_json(s: &str) -> Result { let d: Document = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("document: {e}")))?; + // Feature inputs get parameter names (designs from before they had them). + let mut d = d; + let ids: Vec = d.features.iter().map(|f| f.id).collect(); + for id in ids { + d.name_feature_inputs(id); + } // Designs from before occurrences: every component gets one, in place. let mut d = d; let missing: Vec<(u64, u64)> = @@ -781,7 +798,7 @@ impl Document { Some(n) if !n.trim().is_empty() => n.trim().to_string(), _ => self.unique_name(kind.base_name()), }; - let f = Feature { id, name, suppressed: false, body_names: Vec::new(), component: 0, kind }; + let f = Feature { id, name, suppressed: false, body_names: Vec::new(), param_names: Vec::new(), component: 0, kind }; match self.marker { Some(m) if m < self.features.len() => { self.features.insert(m, f); @@ -845,7 +862,7 @@ impl Document { 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" | "ft" | "deg" | "rad" | "pi" | "PI") + && !matches!(name, "mm" | "cm" | "m" | "um" | "in" | "inch" | "mil" | "yd" | "km" | "nm" | "ft" | "deg" | "rad" | "pi" | "PI") && name.len() <= 64 } @@ -857,6 +874,9 @@ impl Document { if expr_s.len() > 4096 { return Err(DocError::Expr("expression 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(); match next.params.iter_mut().find(|p| p.name == name) { Some(p) => { @@ -891,14 +911,7 @@ impl Document { /// Create the next free model parameter `dN` with the given expression and unit. pub fn new_model_param(&mut self, expr_s: &str, unit: &str) -> String { - let mut n = 1; - let name = loop { - let c = format!("d{n}"); - if self.param(&c).is_none() { - break c; - } - n += 1; - }; + let name = self.next_d_name(); self.params.push(Param { name: name.clone(), expr: expr_s.into(), unit: unit.into(), comment: String::new(), model: true }); name } @@ -919,8 +932,7 @@ impl Document { /// Evaluate all parameters: values (mm / rad / unit-less) and errors by name. pub fn param_values(&self) -> (std::collections::BTreeMap, std::collections::BTreeMap) { - let list: Vec<(String, String, Kind)> = self.params.iter().map(|p| (p.name.clone(), p.expr.clone(), p.kind())).collect(); - expr::eval_params(&list) + expr::eval_params(&self.all_param_exprs()) } /// Evaluate an expression against the document's parameters. diff --git a/crates/doc/src/expr.rs b/crates/doc/src/expr.rs index cb4a72e2..cc891c36 100644 --- a/crates/doc/src/expr.rs +++ b/crates/doc/src/expr.rs @@ -73,7 +73,11 @@ fn unit(name: &str) -> Option { "cm" => Value::length(10.0), "m" => Value::length(1000.0), "um" => Value::length(0.001), - "in" => Value::length(25.4), + "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), @@ -328,6 +332,43 @@ fn call(f: &str, args: &[Value]) -> Result { } 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 } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 57303f85..02d2188a 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -9,11 +9,13 @@ mod assembly; mod document; mod eval; pub mod expr; +mod params; pub mod project; 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 solvecraft_kernel as kernel; pub use solvecraft_sketch as sketch; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs new file mode 100644 index 00000000..7b74e6f8 --- /dev/null +++ b/crates/doc/src/params.rs @@ -0,0 +1,431 @@ +//! Parameters: user parameters, model parameters of sketch dimensions, and feature inputs +//! (extrude distance, fillet radius…) which also appear as model parameters `dN` without being +//! stored twice: the feature keeps its expression and the parameter name refers to it. Every +//! parameter can be referenced by name from any expression; the dependency graph and cycles +//! come from the expression references. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; + +use crate::expr::{self, Kind}; +use crate::{DocError, Document, FeatureKind, HoleKind, PatternKind, PlaneRef, Result}; + +fn plane_inputs_mut<'a>(p: &'a mut PlaneRef, v: &mut Vec<(&'static str, &'a mut String, Kind)>) { + match p { + PlaneRef::Offset { base, distance } => { + v.push(("Offset", distance, Kind::Length)); + plane_inputs_mut(base, v); + } + PlaneRef::AtAngle { base, angle, .. } => { + v.push(("Angle", angle, Kind::Angle)); + plane_inputs_mut(base, v); + } + _ => {} + } +} + +impl FeatureKind { + /// The feature's numeric inputs: (label, expression, kind), in a fixed order. + pub fn inputs_mut(&mut self) -> Vec<(&'static str, &mut String, Kind)> { + use Kind::{Angle as A, Length as L, Unitless as U}; + let mut v: Vec<(&'static str, &mut String, Kind)> = Vec::new(); + match self { + FeatureKind::Sketch { plane, .. } + | FeatureKind::ConstructionPlane { plane } + | FeatureKind::Split { plane, .. } + | FeatureKind::Mirror { plane, .. } => plane_inputs_mut(plane, &mut v), + FeatureKind::Extrude { extent, .. } => { + v.push(("Distance", &mut extent.distance, L)); + if let Some(d) = &mut extent.distance2 { + v.push(("Distance 2", d, L)); + } + if let Some(d) = &mut extent.start_offset { + v.push(("Start offset", d, L)); + } + if let Some(d) = &mut extent.taper { + v.push(("Taper angle", d, A)); + } + } + FeatureKind::Revolve { angle, .. } => v.push(("Angle", angle, A)), + FeatureKind::Fillet { radius, .. } => v.push(("Radius", radius, L)), + FeatureKind::Chamfer { distance, .. } => v.push(("Distance", distance, L)), + FeatureKind::Box { length, width, height, .. } => { + v.push(("Length", length, L)); + v.push(("Width", width, L)); + v.push(("Height", height, L)); + } + FeatureKind::Cylinder { radius, height, .. } => { + v.push(("Radius", radius, L)); + v.push(("Height", height, L)); + } + FeatureKind::Sphere { radius, .. } => v.push(("Radius", radius, L)), + FeatureKind::Torus { major, minor, .. } => { + v.push(("Major radius", major, L)); + v.push(("Minor radius", minor, L)); + } + FeatureKind::Pattern { pattern, .. } => match pattern { + PatternKind::Rectangular { count1, spacing1, count2, spacing2, .. } => { + v.push(("Quantity", count1, U)); + v.push(("Spacing", spacing1, L)); + if let Some(c) = count2 { + v.push(("Quantity 2", c, U)); + } + if let Some(sp) = spacing2 { + v.push(("Spacing 2", sp, L)); + } + } + PatternKind::Circular { count, angle, .. } => { + v.push(("Quantity", count, U)); + v.push(("Total angle", angle, A)); + } + }, + FeatureKind::Shell { thickness, .. } => v.push(("Thickness", thickness, L)), + FeatureKind::Draft { angle, neutral, .. } => { + v.push(("Angle", angle, A)); + plane_inputs_mut(neutral, &mut v); + } + FeatureKind::Hole { diameter, depth, hole, .. } => { + v.push(("Diameter", diameter, L)); + if let Some(d) = depth { + v.push(("Depth", d, L)); + } + match hole { + HoleKind::Simple => {} + HoleKind::Drilled { tip_angle } => v.push(("Tip angle", tip_angle, A)), + HoleKind::Counterbore { cb_diameter, cb_depth } => { + v.push(("Counterbore diameter", cb_diameter, L)); + v.push(("Counterbore depth", cb_depth, L)); + } + HoleKind::Countersink { cs_diameter, cs_angle } => { + v.push(("Countersink diameter", cs_diameter, L)); + v.push(("Countersink angle", cs_angle, A)); + } + } + } + FeatureKind::Thread { length, .. } => { + if let Some(l) = length { + v.push(("Length", l, L)); + } + } + FeatureKind::Scale { factor, factors, .. } => { + v.push(("Scale factor", factor, U)); + if let Some([x, y, z]) = factors { + v.push(("X scale", x, U)); + v.push(("Y scale", y, U)); + v.push(("Z scale", z, U)); + } + } + FeatureKind::OffsetFace { distance, .. } => v.push(("Distance", distance, L)), + FeatureKind::BoundingSolid { margin, .. } => v.push(("Margin", margin, L)), + FeatureKind::Pipe { diameter, wall, .. } => { + v.push(("Diameter", diameter, L)); + if let Some(w) = wall { + v.push(("Wall", w, L)); + } + } + FeatureKind::Move { translate, angle, .. } => { + let [x, y, z] = translate; + v.push(("X distance", x, L)); + v.push(("Y distance", y, L)); + v.push(("Z distance", z, L)); + if let Some(a) = angle { + v.push(("Angle", a, A)); + } + } + FeatureKind::Combine { .. } + | FeatureKind::Loft { .. } + | FeatureKind::Sweep { .. } + | FeatureKind::Import { .. } + | FeatureKind::MeshImport { .. } => {} + } + v + } + + /// Read-only view of [`FeatureKind::inputs_mut`]: (label, expression, kind). + pub fn inputs(&self) -> Vec<(&'static str, String, Kind)> { + let mut c = self.clone(); + c.inputs_mut().into_iter().map(|(l, e, k)| (l, e.clone(), k)).collect() + } +} + +/// One row of the parameters table. +#[derive(Clone, Debug, Serialize)] +pub struct ParamRow { + pub name: String, + pub expression: String, + pub unit: String, + pub value: Option, + pub error: Option, + pub comment: String, + /// "user", "sketch" (a dimension) or "feature". + pub source: &'static str, + /// For feature inputs: the feature and the input's label. + #[serde(skip_serializing_if = "Option::is_none")] + pub feature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option<&'static str>, + pub favorite: bool, + /// Names this one's expression uses, and names whose expressions use this one. + pub uses: Vec, + pub used_by: Vec, +} + +fn unit_of(k: Kind) -> &'static str { + match k { + Kind::Length => "mm", + Kind::Angle => "deg", + Kind::Unitless => "", + } +} + +impl Document { + /// Every (name, expression, kind) the expressions can refer to: stored parameters and the + /// named feature inputs. + pub fn all_param_exprs(&self) -> Vec<(String, String, Kind)> { + let mut v: Vec<(String, String, Kind)> = self.params.iter().map(|p| (p.name.clone(), p.expr.clone(), p.kind())).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((name.clone(), e, k)); + } + } + } + v + } + + fn name_taken(&self, n: &str) -> bool { + self.param(n).is_some() || self.features.iter().any(|f| f.param_names.iter().any(|x| x == n)) + } + + /// The next free `dN` name. + pub fn next_d_name(&self) -> String { + let mut n = 1; + loop { + let c = format!("d{n}"); + if !self.name_taken(&c) { + return c; + } + n += 1; + } + } + + /// Give a feature's inputs parameter names (keeping existing names in place). + pub fn name_feature_inputs(&mut self, id: u64) { + let Some(idx) = self.feature_index(id) else { return }; + let n = self.features.get(idx).map(|f| f.kind.inputs().len()).unwrap_or(0); + for k in 0..n { + let have = self.features.get(idx).and_then(|f| f.param_names.get(k)).is_some_and(|x| !x.is_empty()); + if have { + continue; + } + let name = self.next_d_name(); + if let Some(f) = self.features.get_mut(idx) { + while f.param_names.len() <= k { + f.param_names.push(String::new()); + } + if let Some(slot) = f.param_names.get_mut(k) { + *slot = name; + } + } + } + if let Some(f) = self.features.get_mut(idx) { + f.param_names.truncate(n); + } + } + + /// Change a parameter: a stored one, or a feature input by its name. + 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) }; + if expr_s.len() > 4096 { + return Err(DocError::Expr("expression too long".into())); + } + let mut next = self.clone(); + if let Some(f) = next.features.get_mut(i) + && let Some((_, e, _)) = f.kind.inputs_mut().into_iter().nth(k) + { + *e = expr_s.to_string(); + } + if let Some(c) = comment { + next.param_comments.insert(name.to_string(), c.to_string()); + } + let (_, errors) = next.param_values(); + if let Some(e) = errors.get(name) { + return Err(DocError::Expr(e.clone())); + } + *self = next; + Ok(()) + } + + /// The parameters table (user, sketch dimensions, feature inputs). + pub fn param_rows(&self) -> Vec { + let (vals, errs) = self.param_values(); + let all = self.all_param_exprs(); + let refs: BTreeMap> = all.iter().map(|(n, e, _)| (n.clone(), expr::references(e))).collect(); + let used_by = |n: &str| -> Vec { refs.iter().filter(|(_, r)| r.iter().any(|x| x == n)).map(|(k, _)| k.clone()).collect() }; + let value = |n: &str, k: Kind| vals.get(n).and_then(|v| v.to_kind(k).ok()).map(|x| if k == Kind::Angle { x.to_degrees() } else { x }); + let mut rows = Vec::new(); + for p in &self.params { + rows.push(ParamRow { + name: p.name.clone(), + expression: p.expr.clone(), + unit: p.unit.clone(), + value: value(&p.name, p.kind()), + error: errs.get(&p.name).cloned(), + comment: p.comment.clone(), + source: if p.model { "sketch" } else { "user" }, + feature: None, + input: None, + favorite: self.favorites.contains(&p.name), + uses: refs.get(&p.name).cloned().unwrap_or_default(), + used_by: used_by(&p.name), + }); + } + for f in &self.features { + for (name, (label, e, k)) in f.param_names.iter().zip(f.kind.inputs()) { + if name.is_empty() { + continue; + } + rows.push(ParamRow { + name: name.clone(), + expression: e, + unit: unit_of(k).into(), + value: value(name, k), + error: errs.get(name).cloned(), + comment: self.param_comments.get(name).cloned().unwrap_or_default(), + source: "feature", + feature: Some(f.name.clone()), + input: Some(label), + favorite: self.favorites.contains(name), + uses: refs.get(name).cloned().unwrap_or_default(), + used_by: used_by(name), + }); + } + } + rows + } + + pub fn set_favorite(&mut self, name: &str, on: bool) -> Result<()> { + if !self.name_taken(name) { + return Err(DocError::Unknown(format!("parameter `{name}`"))); + } + if on { + self.favorites.insert(name.to_string()); + } else { + self.favorites.remove(name); + } + Ok(()) + } + + /// User parameters as CSV (`name,unit,expression,value,comment`). + pub fn params_csv(&self) -> String { + let q = |s: &str| if s.contains([',', '"', '\n']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; + let mut out = String::from("name,unit,expression,value,comment,favorite\n"); + for r in self.param_rows().into_iter().filter(|r| r.source == "user") { + out += &format!( + "{},{},{},{},{},{}\n", + q(&r.name), + q(&r.unit), + q(&r.expression), + r.value.map(|v| format!("{v}")).unwrap_or_default(), + q(&r.comment), + r.favorite + ); + } + out + } + + /// Import user parameters (CSV with a header, or JSON `[{name, expression, unit?, comment?}]`): + /// existing ones are updated, new ones added, all checked before anything changes. + pub fn import_params(&mut self, text: &str) -> Result> { + let rows: Vec<(String, String, Option, Option)> = if text.trim_start().starts_with('[') { + let v: serde_json::Value = serde_json::from_str(text).map_err(|e| DocError::Invalid(format!("parameters JSON: {e}")))?; + v.as_array() + .ok_or_else(|| DocError::Invalid("parameters JSON must be a list".into()))? + .iter() + .map(|r| { + let s = |k: &str| r.get(k).and_then(|x| x.as_str().map(str::to_string).or_else(|| x.as_f64().map(|n| n.to_string()))); + Ok(( + s("name").ok_or_else(|| DocError::Invalid("a parameter without `name`".into()))?, + s("expression").or_else(|| s("value")).unwrap_or_default(), + s("unit"), + s("comment"), + )) + }) + .collect::>()? + } else { + let mut lines = text.lines(); + let header: Vec = parse_csv_line(lines.next().unwrap_or_default()).into_iter().map(|h| h.trim().to_ascii_lowercase()).collect(); + let col = |n: &str| header.iter().position(|h| h == n); + let (Some(ni), Some(ei)) = (col("name"), col("expression").or(col("value"))) else { + return Err(DocError::Invalid("the CSV needs `name` and `expression` columns".into())); + }; + let (ui, ci) = (col("unit"), col("comment")); + lines + .filter(|l| !l.trim().is_empty()) + .map(|l| { + let c = parse_csv_line(l); + let get = |i: Option| i.and_then(|i| c.get(i)).map(|s| s.trim().to_string()); + (get(Some(ni)).unwrap_or_default(), get(Some(ei)).unwrap_or_default(), get(ui), get(ci)) + }) + .collect() + }; + if rows.len() > 10_000 { + return Err(DocError::Invalid("too many parameters".into())); + } + let mut next = self.clone(); + let mut names = Vec::new(); + for (n, e, u, c) in rows { + next.change_param(&n, &e, u.as_deref(), c.as_deref())?; + names.push(n); + } + *self = next; + Ok(names) + } +} + +/// One CSV line with quoted fields. +fn parse_csv_line(l: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut quoted = false; + let mut chars = l.chars().peekable(); + while let Some(c) = chars.next() { + match (c, quoted) { + ('"', true) if chars.peek() == Some(&'"') => { + cur.push('"'); + chars.next(); + } + ('"', _) => quoted = !quoted, + (',', false) => out.push(std::mem::take(&mut cur)), + (c, _) => cur.push(c), + } + } + out.push(cur); + out +} + +/// Parameters whose definitions depend on each other in a loop. +pub fn cycles(list: &[(String, String, Kind)]) -> BTreeSet { + let refs: BTreeMap<&str, Vec> = list.iter().map(|(n, e, _)| (n.as_str(), expr::references(e))).collect(); + let mut bad = BTreeSet::new(); + for (start, _, _) in list { + // Depth-first from `start`; reaching it again is a cycle. + let mut stack: Vec<(String, usize)> = refs.get(start.as_str()).map(|r| r.iter().map(|x| (x.clone(), 1)).collect()).unwrap_or_default(); + let mut seen = BTreeSet::new(); + while let Some((n, d)) = stack.pop() { + if &n == start { + bad.insert(start.clone()); + break; + } + if d > 10_000 || !seen.insert(n.clone()) { + continue; + } + if let Some(r) = refs.get(n.as_str()) { + stack.extend(r.iter().map(|x| (x.clone(), d + 1))); + } + } + } + bad +} diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index f557e152..99f27c31 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -195,3 +195,69 @@ fn primitives_and_combine() { assert_eq!(st.bodies.len(), 1); assert!(rel(measure(&st.bodies[0].body).unwrap().volume, 1000.0 - std::f64::consts::PI * 40.0) < 1e-3); } + +#[test] +fn hostile_expressions_never_panic() { + // A deterministic stream of nasty inputs: random tokens, deep nesting, huge numbers. + let toks = [ + "(", + ")", + "+", + "-", + "*", + "/", + "^", + ",", + "1e308", + "0", + "-0", + "mm", + "deg", + "x", + "sqrt", + "pow", + "min", + "max", + "°", + "1.2.3", + "e", + "pi", + "in", + "nan", + "9999999999999999999999", + ]; + let mut state: u64 = 0x9e3779b97f4a7c15; + let mut next = || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + for _ in 0..5000 { + let n = (next() % 24) as usize; + let s: String = (0..n).map(|_| toks[(next() % toks.len() as u64) as usize]).collect::>().join(" "); + let _ = crate::expr::eval_with(&s, &|n| { + if n == "x" { Ok(crate::expr::Value::length(2.0)) } else { Err(crate::DocError::Expr("unknown".into())) } + }); + } + let deep = format!("{}1{}", "(".repeat(5000), ")".repeat(5000)); + assert!(crate::expr::eval_with(&deep, &|_| Err(crate::DocError::Expr("x".into()))).is_err()); + assert!(crate::expr::eval_with("pow(2 mm, 3)", &|_| Err(crate::DocError::Expr("x".into()))).is_ok()); + assert!(crate::expr::eval_with("pow(2 mm, 300)", &|_| Err(crate::DocError::Expr("x".into()))).is_err()); +} + +#[test] +fn parameter_cycles_are_found() { + use crate::expr::Kind; + let list = vec![ + ("a".to_string(), "b + 1".to_string(), Kind::Length), + ("b".to_string(), "c * 2".to_string(), Kind::Length), + ("c".to_string(), "a".to_string(), Kind::Length), + ("d".to_string(), "5".to_string(), Kind::Length), + ("e".to_string(), "d + e".to_string(), Kind::Length), + ]; + let c = crate::cycles(&list); + assert_eq!(c.into_iter().collect::>(), vec!["a", "b", "c", "e"]); + let (vals, errs) = crate::expr::eval_params(&list); + assert!(vals.contains_key("d") && errs.contains_key("a") && errs.contains_key("e")); +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 098c9521..896349e9 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -10,6 +10,13 @@ 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") @@ -76,7 +83,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result { }; let unit = str_(p, "unit"); let comment = str_(p, "comment"); - s.doc_mut().set_param(name, &e, unit, 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); @@ -333,3 +340,57 @@ 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/features.rs b/crates/engine/src/cmd/features.rs index 7b63862b..cc893412 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -240,6 +240,7 @@ fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Result { let mut kind = kind; super::component::to_active_frame(s, &mut kind); let id = s.doc_mut().add_feature(kind, name)?; + s.doc_mut().name_feature_inputs(id); let comp = s.active_component; if comp != 0 && let Some(f) = s.doc_mut().feature_mut(id) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b3e5877b..3440c228 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -804,3 +804,40 @@ fn components_occurrences_and_world_placement() { let migrated = solvecraft_doc::Document::from_json(&old.to_string()).unwrap(); assert_eq!(migrated.occurrences.len(), migrated.components.len()); } + +#[test] +fn feature_inputs_are_parameters() { + let mut s = Session::default(); + run(&mut s, "ChangeParameterCommand", json!({"name": "w", "expression": "15 mm", "comment": "width"})); + run(&mut s, "PrimitiveBox", json!({"length": 10, "width": "w", "height": "1 in", "name": "B"})); + let l = run(&mut s, "parameters.list", json!({})); + let rows = l["parameters"].as_array().unwrap(); + let feat: Vec<&Value> = rows.iter().filter(|r| r["source"] == "feature").collect(); + assert_eq!(feat.len(), 3, "{l}"); + let height = feat.iter().find(|r| r["input"] == "Height").unwrap(); + assert!((height["value"].as_f64().unwrap() - 25.4).abs() < 1e-9); + let length_name = feat.iter().find(|r| r["input"] == "Length").unwrap()["name"].as_str().unwrap().to_string(); + // A feature parameter changes the feature; other expressions can use it. + run(&mut s, "ChangeParameterCommand", json!({"name": length_name, "expression": "2 * w"})); + assert!(rel(volume(&mut s), 30.0 * 15.0 * 25.4) < 1e-9); + run(&mut s, "ChangeParameterCommand", json!({"name": "t", "expression": format!("{length_name} / 3")})); + let t = run(&mut s, "parameters.list", json!({}))["parameters"].as_array().unwrap().iter().find(|r| r["name"] == "t").unwrap()["value"] + .as_f64() + .unwrap(); + assert!((t - 10.0).abs() < 1e-9); + // Cycles are refused. + assert!(s.execute("ChangeParameterCommand", &json!({"name": "w", "expression": "t"})).is_err()); + // Favourites, export and import. + run(&mut s, "parameters.favorite", json!({"name": "w"})); + assert_eq!(run(&mut s, "parameters.list", json!({"favorites": true}))["parameters"].as_array().unwrap().len(), 1); + let csv = run(&mut s, "parameters.export", json!({}))["text"].as_str().unwrap().to_string(); + assert!(csv.contains("w,mm,15 mm,15,width,true"), "{csv}"); + run(&mut s, "parameters.import", json!({"text": "name,unit,expression,comment\nw,mm,20 mm,wider\nnew_p,mm,\"w + 1\","})); + assert!(rel(volume(&mut s), 40.0 * 20.0 * 25.4) < 1e-9); + run(&mut s, "parameters.import", json!({"text": "[{\"name\": \"w\", \"expression\": \"12\"}]"})); + assert!(rel(volume(&mut s), 24.0 * 12.0 * 25.4) < 1e-9); + assert!(s.execute("parameters.import", &json!({"text": "name,expression\nw,1/"})).is_err()); + // Saved and reloaded, the names stay. + let back = solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + assert_eq!(back.features[0].param_names, s.doc.features[0].param_names); +}