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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 23:23:50 +00:00
1 parent 6f349b79f5
commit f0cfeba59b
8 files changed
+665 -14

No files matched your search

+24 -12
View File
@@ -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<String>,
/// 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<String>,
/// 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<crate::Occurrence>,
/// Favourite parameters (by name).
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
pub favorites: std::collections::BTreeSet<String>,
/// Comments of feature-input parameters (by name).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub param_comments: std::collections::BTreeMap<String, String>,
/// 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<String, String>,
@@ -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<Document> {
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<u64> = 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<String, expr::Value>, std::collections::BTreeMap<String, String>) {
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.
+42 -1
View File
@@ -73,7 +73,11 @@ fn unit(name: &str) -> Option<Value> {
"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, DocError> {
}
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 }
+2
View File
@@ -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;
+431
View File
@@ -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<f64>,
pub error: Option<String>,
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<String>,
#[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<String>,
pub used_by: Vec<String>,
}
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<ParamRow> {
let (vals, errs) = self.param_values();
let all = self.all_param_exprs();
let refs: BTreeMap<String, Vec<String>> = all.iter().map(|(n, e, _)| (n.clone(), expr::references(e))).collect();
let used_by = |n: &str| -> Vec<String> { 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<Vec<String>> {
let rows: Vec<(String, String, Option<String>, Option<String>)> = 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::<Result<_>>()?
} else {
let mut lines = text.lines();
let header: Vec<String> = 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<usize>| 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<String> {
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<String> {
let refs: BTreeMap<&str, Vec<String>> = 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
}
+66
View File
@@ -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::<Vec<_>>().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<_>>(), 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"));
}
+62 -1
View File
@@ -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<Value> {
};
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<Value> {
let broken: Vec<String> = 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<Value> {
let only_fav = bool_(p, "favorites").unwrap_or(false);
let rows: Vec<solvecraft_doc::ParamRow> = 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<Value> {
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<Value> {
let cmd = "parameters.export";
let text = match str_(p, "format").unwrap_or("csv") {
"csv" => s.doc.params_csv(),
"json" => {
let rows: Vec<Value> = 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<Value> {
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 }))
}
+1
View File
@@ -240,6 +240,7 @@ fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Result<Value> {
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)
+37
View File
@@ -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);
}