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:
1 parent
6f349b79f5
commit
f0cfeba59b
8 files changed
+665
-14
No files matched your search
+24
-12
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user