Parameters: unit-aware parameters (an inch parameter reads bare numbers as inches), atan2/mod/trunc and clearer unit errors, cycle paths (a → b → a), rename with every reference following (expressions, feature inputs, dimensions, favourites, comments), deletes refused with the list of users, parameter errors shown on the features using them, parameters.add/rename/delete/comment/users/graph/complete and expr.evaluate; ChangeParameterCommand renames and favourites; hostile test no longer writes stray files
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
a88b8eb6b3
commit
dd7485503a
13 files changed
+1470
-322
No files matched your search
+35
-31
@@ -866,31 +866,54 @@ impl Document {
|
||||
self.params.iter().find(|p| p.name == name)
|
||||
}
|
||||
|
||||
fn valid_name(name: &str) -> bool {
|
||||
/// Why a name cannot be a parameter name (None = fine).
|
||||
pub fn param_name_problem(name: &str) -> Option<String> {
|
||||
let mut cs = name.chars();
|
||||
matches!(cs.next(), Some(c) if c.is_alphabetic() || c == '_')
|
||||
&& cs.all(|c| c.is_alphanumeric() || c == '_')
|
||||
&& !matches!(name, "mm" | "cm" | "m" | "um" | "in" | "inch" | "mil" | "yd" | "km" | "nm" | "ft" | "deg" | "rad" | "pi" | "PI")
|
||||
&& name.len() <= 64
|
||||
if !matches!(cs.next(), Some(c) if c.is_alphabetic() || c == '_') || !cs.all(|c| c.is_alphanumeric() || c == '_') {
|
||||
return Some(format!("`{name}` is not a valid parameter name (letters, digits and _, starting with a letter)"));
|
||||
}
|
||||
if name.len() > 64 {
|
||||
return Some("parameter names are at most 64 characters".into());
|
||||
}
|
||||
if expr::is_reserved(name) {
|
||||
return Some(format!("`{name}` is a unit, constant or function name"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Add or change a parameter. Fails if the result would not evaluate.
|
||||
/// Add or change a parameter. Fails if the result would not evaluate. A new parameter
|
||||
/// without a unit takes the unit of its value (mm, deg or none).
|
||||
pub fn set_param(&mut self, name: &str, expr_s: &str, unit: Option<&str>, comment: Option<&str>) -> Result<()> {
|
||||
if !Self::valid_name(name) {
|
||||
return Err(DocError::Invalid(format!("`{name}` is not a valid parameter name")));
|
||||
if let Some(p) = Self::param_name_problem(name) {
|
||||
return Err(DocError::Invalid(p));
|
||||
}
|
||||
if expr_s.len() > 4096 {
|
||||
return Err(DocError::Expr("expression too long".into()));
|
||||
}
|
||||
if expr_s.trim().is_empty() {
|
||||
return Err(DocError::Expr("empty expression".into()));
|
||||
}
|
||||
if let Some(u) = unit
|
||||
&& expr::unit_info(u).is_none()
|
||||
{
|
||||
return Err(DocError::Expr(format!("unknown unit `{u}` (mm, cm, m, in, ft, deg, rad or none)")));
|
||||
}
|
||||
if comment.is_some_and(|c| c.len() > 4096) {
|
||||
return Err(DocError::Invalid("comment too long".into()));
|
||||
}
|
||||
if self.features.iter().any(|f| f.param_names.iter().any(|n| n == name)) {
|
||||
return Err(DocError::Invalid(format!("`{name}` is a feature's parameter")));
|
||||
}
|
||||
let mut next = self.clone();
|
||||
let inferred = match unit {
|
||||
Some(u) => u.trim().to_string(),
|
||||
None => next.infer_unit(expr_s),
|
||||
};
|
||||
match next.params.iter_mut().find(|p| p.name == name) {
|
||||
Some(p) => {
|
||||
p.expr = expr_s.to_string();
|
||||
p.expr = expr_s.trim().to_string();
|
||||
if let Some(u) = unit {
|
||||
p.unit = u.to_string();
|
||||
p.unit = u.trim().to_string();
|
||||
}
|
||||
if let Some(c) = comment {
|
||||
p.comment = c.to_string();
|
||||
@@ -902,8 +925,8 @@ impl Document {
|
||||
}
|
||||
next.params.push(Param {
|
||||
name: name.into(),
|
||||
expr: expr_s.into(),
|
||||
unit: unit.unwrap_or("mm").into(),
|
||||
expr: expr_s.trim().into(),
|
||||
unit: inferred,
|
||||
comment: comment.unwrap_or_default().into(),
|
||||
model: false,
|
||||
})
|
||||
@@ -924,25 +947,6 @@ impl Document {
|
||||
name
|
||||
}
|
||||
|
||||
/// Remove a user parameter (fails while something references it).
|
||||
pub fn remove_param(&mut self, name: &str) -> Result<()> {
|
||||
if self.param(name).is_none() {
|
||||
return Err(DocError::Unknown(format!("parameter `{name}`")));
|
||||
}
|
||||
let mut probe = self.clone();
|
||||
probe.params.retain(|p| p.name != name);
|
||||
if probe.referenced_names().iter().any(|n| n == name) {
|
||||
return Err(DocError::Invalid(format!("`{name}` is still used")));
|
||||
}
|
||||
*self = probe;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Evaluate all parameters: values (mm / rad / unit-less) and errors by name.
|
||||
pub fn param_values(&self) -> (std::collections::BTreeMap<String, expr::Value>, std::collections::BTreeMap<String, String>) {
|
||||
expr::eval_params(&self.all_param_exprs())
|
||||
}
|
||||
|
||||
/// Evaluate an expression against the document's parameters.
|
||||
pub fn eval(&self, e: &str, kind: Kind) -> Result<f64> {
|
||||
let (vals, _) = self.param_values();
|
||||
|
||||
+19
-9
@@ -221,18 +221,23 @@ pub struct Model {
|
||||
pub last_recomputed: usize,
|
||||
}
|
||||
|
||||
fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_back: bool) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
prev.hash(&mut h);
|
||||
rolled_back.hash(&mut h);
|
||||
serde_json::to_string(f).unwrap_or_default().hash(&mut h);
|
||||
/// Parameters a feature uses directly (inputs and sketch dimensions), sorted.
|
||||
fn param_names(f: &Feature) -> Vec<String> {
|
||||
let mut names: Vec<String> = f.kind.expressions().iter().flat_map(|e| expr::references(e)).collect();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
|
||||
}
|
||||
names.sort();
|
||||
names.dedup();
|
||||
for n in names {
|
||||
names
|
||||
}
|
||||
|
||||
fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_back: bool) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
prev.hash(&mut h);
|
||||
rolled_back.hash(&mut h);
|
||||
serde_json::to_string(f).unwrap_or_default().hash(&mut h);
|
||||
for n in param_names(f) {
|
||||
n.hash(&mut h);
|
||||
match vals.get(&n) {
|
||||
Some(v) => {
|
||||
@@ -313,9 +318,14 @@ impl Model {
|
||||
let t0 = now();
|
||||
let mut next = (*state).clone();
|
||||
let mut warning = None;
|
||||
let r = kernel::guard(&f.name, || {
|
||||
eval_feature(doc, &vals, f, &mut next, &mut warning).map_err(|e| kernel::KernelError::Failed(e.to_string()))
|
||||
});
|
||||
// A parameter with an error fails the features using it, with that error.
|
||||
let bad_param = param_names(f).into_iter().find_map(|n| self.param_errors.get(&n).map(|e| format!("parameter `{n}`: {e}")));
|
||||
let r = match bad_param {
|
||||
Some(e) => Err(kernel::KernelError::Failed(e)),
|
||||
None => kernel::guard(&f.name, || {
|
||||
eval_feature(doc, &vals, f, &mut next, &mut warning).map_err(|e| kernel::KernelError::Failed(e.to_string()))
|
||||
}),
|
||||
};
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
let error = match r {
|
||||
Ok(()) => {
|
||||
|
||||
+584
-185
File diff suppressed because it is too large.
Load diff
@@ -16,7 +16,7 @@ mod project3d;
|
||||
pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, mat_inverse, mat_mul, rigid};
|
||||
pub use document::*;
|
||||
pub use eval::{FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, parse_metric_thread, world_state};
|
||||
pub use params::{ParamRow, cycles};
|
||||
pub use params::{ParamRow, ParamUser, cycles};
|
||||
pub use solvecraft_kernel as kernel;
|
||||
pub use solvecraft_sketch as sketch;
|
||||
|
||||
|
||||
+263
-4
@@ -6,10 +6,10 @@
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::expr::{self, Kind};
|
||||
use crate::{DocError, Document, FeatureKind, HoleKind, PatternKind, PlaneRef, Result};
|
||||
use crate::expr::{self, Kind, ParamDef, Value};
|
||||
use crate::{DocError, Document, FeatureKind, HoleKind, MAX_PARAMS, PatternKind, PlaneRef, Result};
|
||||
|
||||
fn plane_inputs_mut<'a>(p: &'a mut PlaneRef, v: &mut Vec<(&'static str, &'a mut String, Kind)>) {
|
||||
match p {
|
||||
@@ -144,6 +144,10 @@ impl FeatureKind {
|
||||
|
||||
/// Read-only view of [`FeatureKind::inputs_mut`]: (label, expression, kind).
|
||||
pub fn inputs(&self) -> Vec<(&'static str, String, Kind)> {
|
||||
// Imports have no inputs (and their STEP text is large: don't clone it).
|
||||
if matches!(self, FeatureKind::Import { .. } | FeatureKind::MeshImport { .. }) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut c = self.clone();
|
||||
c.inputs_mut().into_iter().map(|(l, e, k)| (l, e.clone(), k)).collect()
|
||||
}
|
||||
@@ -271,7 +275,7 @@ impl Document {
|
||||
name: p.name.clone(),
|
||||
expression: p.expr.clone(),
|
||||
unit: p.unit.clone(),
|
||||
value: value(&p.name, p.kind()),
|
||||
value: self.param_display_value(&vals, &p.name),
|
||||
error: errs.get(&p.name).cloned(),
|
||||
comment: p.comment.clone(),
|
||||
source: if p.model { "sketch" } else { "user" },
|
||||
@@ -385,6 +389,261 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Something that uses a parameter.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ParamUser {
|
||||
/// Another parameter's expression (a stored one or a feature input).
|
||||
Parameter { name: String },
|
||||
/// A feature input (`input` is its label, e.g. `Distance`).
|
||||
Feature { feature: u64, name: String, input: String },
|
||||
/// A sketch dimension (`constraint` is its id).
|
||||
Dimension { feature: u64, name: String, constraint: String },
|
||||
}
|
||||
|
||||
impl ParamUser {
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
ParamUser::Parameter { name } => format!("parameter {name}"),
|
||||
ParamUser::Feature { name, input, .. } => format!("{name} ({input})"),
|
||||
ParamUser::Dimension { name, constraint, .. } => format!("{name} dimension {constraint}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Length unit of the document in mm (bare numbers of unit-less parameters next to lengths).
|
||||
fn doc_len(units: &str) -> f64 {
|
||||
match expr::unit_info(units) {
|
||||
Some((Kind::Length, s)) => s,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Every parameter with its unit, for evaluation: stored parameters and named feature
|
||||
/// inputs (mm / deg / none by their kind).
|
||||
pub fn param_defs(&self) -> Vec<ParamDef> {
|
||||
let mut v: Vec<ParamDef> =
|
||||
self.params.iter().map(|p| ParamDef { name: p.name.clone(), expr: p.expr.clone(), unit: p.unit.clone() }).collect();
|
||||
for f in &self.features {
|
||||
for (name, (_, e, k)) in f.param_names.iter().zip(f.kind.inputs()) {
|
||||
if !name.is_empty() {
|
||||
v.push(ParamDef { name: name.clone(), expr: e, unit: unit_of(k).into() });
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Evaluate all parameters: values (mm / rad / unit-less) and errors by name. Errors name
|
||||
/// the problem (`circular reference: a → b → a`, `cannot add a length and an angle`).
|
||||
pub fn param_values(&self) -> (BTreeMap<String, Value>, BTreeMap<String, String>) {
|
||||
expr::eval_params(&self.param_defs(), doc_len(&self.units))
|
||||
}
|
||||
|
||||
/// A parameter's value in its own unit (e.g. 2 for a 50.8 mm parameter in inches).
|
||||
pub fn param_display_value(&self, vals: &BTreeMap<String, Value>, name: &str) -> Option<f64> {
|
||||
let v = vals.get(name)?;
|
||||
let unit = match self.param(name) {
|
||||
Some(p) => p.unit.clone(),
|
||||
None => v.kind().map(|k| unit_of(k).to_string()).unwrap_or_default(),
|
||||
};
|
||||
Some(expr::value_in_unit(*v, &unit))
|
||||
}
|
||||
|
||||
/// The unit for a new parameter with this expression: that of its value; a bare number is
|
||||
/// a length (mm).
|
||||
pub(crate) fn infer_unit(&self, e: &str) -> String {
|
||||
let (vals, _) = self.param_values();
|
||||
let v = expr::eval_with(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))));
|
||||
match v.ok().and_then(|v| v.kind()) {
|
||||
Some(Kind::Unitless) if !expr::references(e).is_empty() => String::new(),
|
||||
Some(Kind::Angle) => "deg".into(),
|
||||
_ => "mm".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything that uses a parameter: other parameters (and feature inputs) whose
|
||||
/// expressions refer to it, and the sketch dimensions it drives.
|
||||
pub fn param_users(&self, name: &str) -> Vec<ParamUser> {
|
||||
let mut out = Vec::new();
|
||||
for p in &self.params {
|
||||
if p.name != name && expr::references(&p.expr).iter().any(|r| r == name) {
|
||||
out.push(ParamUser::Parameter { name: p.name.clone() });
|
||||
}
|
||||
}
|
||||
for f in &self.features {
|
||||
for (k, (label, e, _)) in f.kind.inputs().into_iter().enumerate() {
|
||||
if expr::references(&e).iter().any(|r| r == name) {
|
||||
match f.param_names.get(k).filter(|n| !n.is_empty()) {
|
||||
Some(n) => out.push(ParamUser::Parameter { name: n.clone() }),
|
||||
None => out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: label.to_string() }),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
for c in &sketch.constraints {
|
||||
if c.param.as_deref() == Some(name) {
|
||||
out.push(ParamUser::Dimension { feature: f.id, name: f.name.clone(), constraint: c.id.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Remove a stored parameter; refused while something uses it (the error lists the users).
|
||||
pub fn remove_param(&mut self, name: &str) -> Result<()> {
|
||||
if self.param(name).is_none() {
|
||||
if self.features.iter().any(|f| f.param_names.iter().any(|n| n == name)) {
|
||||
return Err(DocError::Invalid(format!("`{name}` is a feature's input: delete or edit the feature instead")));
|
||||
}
|
||||
return Err(DocError::Unknown(format!("parameter `{name}`")));
|
||||
}
|
||||
let users = self.param_users(name);
|
||||
if !users.is_empty() {
|
||||
let list: Vec<String> = users.iter().map(ParamUser::describe).collect();
|
||||
return Err(DocError::Invalid(format!("`{name}` is used by {}", list.join(", "))));
|
||||
}
|
||||
self.params.retain(|p| p.name != name);
|
||||
self.favorites.remove(name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename a parameter (stored or a feature input); every expression, feature input,
|
||||
/// dimension, favourite and comment that refers to it follows. Returns how many
|
||||
/// references were updated.
|
||||
pub fn rename_param(&mut self, old: &str, new: &str) -> Result<usize> {
|
||||
let new = new.trim();
|
||||
if !self.name_taken(old) {
|
||||
return Err(DocError::Unknown(format!("parameter `{old}`")));
|
||||
}
|
||||
if old == new {
|
||||
return Ok(0);
|
||||
}
|
||||
if let Some(p) = Self::param_name_problem(new) {
|
||||
return Err(DocError::Invalid(p));
|
||||
}
|
||||
if self.name_taken(new) {
|
||||
return Err(DocError::Invalid(format!("there is already a parameter `{new}`")));
|
||||
}
|
||||
let mut n = 0;
|
||||
for p in &mut self.params {
|
||||
if p.name == old {
|
||||
p.name = new.to_string();
|
||||
}
|
||||
let r = expr::rename_reference(&p.expr, old, new);
|
||||
if r != p.expr {
|
||||
p.expr = r;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for f in &mut self.features {
|
||||
for x in &mut f.param_names {
|
||||
if x == old {
|
||||
*x = new.to_string();
|
||||
}
|
||||
}
|
||||
for (_, e, _) in f.kind.inputs_mut() {
|
||||
let r = expr::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
*e = r;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if let FeatureKind::Sketch { sketch, .. } = &mut f.kind {
|
||||
for c in &mut sketch.constraints {
|
||||
if c.param.as_deref() == Some(old) {
|
||||
c.param = Some(new.to_string());
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.favorites.remove(old) {
|
||||
self.favorites.insert(new.to_string());
|
||||
}
|
||||
if let Some(c) = self.param_comments.remove(old) {
|
||||
self.param_comments.insert(new.to_string(), c);
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Set a parameter's comment (stored parameters keep it, feature inputs in `param_comments`).
|
||||
pub fn set_param_comment(&mut self, name: &str, comment: &str) -> Result<()> {
|
||||
if comment.len() > 4096 {
|
||||
return Err(DocError::Invalid("comment too long".into()));
|
||||
}
|
||||
if let Some(p) = self.params.iter_mut().find(|p| p.name == name) {
|
||||
p.comment = comment.to_string();
|
||||
return Ok(());
|
||||
}
|
||||
if !self.name_taken(name) {
|
||||
return Err(DocError::Unknown(format!("parameter `{name}`")));
|
||||
}
|
||||
if comment.is_empty() {
|
||||
self.param_comments.remove(name);
|
||||
} else {
|
||||
self.param_comments.insert(name.to_string(), comment.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dependency edges: (user, parameter used). Users are parameter names, or
|
||||
/// `Feature:<name>` for a feature whose inputs or dimensions use the parameter.
|
||||
pub fn param_graph(&self) -> Vec<(String, String)> {
|
||||
let mut out = expr::param_edges(&self.param_defs());
|
||||
for f in &self.features {
|
||||
let mut names: BTreeSet<String> = f.param_names.iter().filter(|n| !n.is_empty()).cloned().collect();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
|
||||
}
|
||||
out.extend(names.into_iter().map(|n| (format!("Feature:{}", f.name), n)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Features whose result depends on a parameter (directly or through other parameters).
|
||||
pub fn features_using_param(&self, name: &str) -> Vec<u64> {
|
||||
let defs = self.param_defs();
|
||||
let mut affected: BTreeSet<String> = BTreeSet::from([name.to_string()]);
|
||||
for _ in 0..defs.len().min(MAX_PARAMS * 2) {
|
||||
let before = affected.len();
|
||||
for d in &defs {
|
||||
if expr::references(&d.expr).iter().any(|r| affected.contains(r)) {
|
||||
affected.insert(d.name.clone());
|
||||
}
|
||||
}
|
||||
if affected.len() == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for f in &self.features {
|
||||
let mut hit = f.param_names.iter().any(|n| affected.contains(n));
|
||||
hit |= f.kind.expressions().iter().any(|e| expr::references(e).iter().any(|r| affected.contains(r)));
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
hit |= sketch.constraints.iter().any(|c| c.param.as_ref().is_some_and(|p| affected.contains(p)));
|
||||
}
|
||||
if hit {
|
||||
out.push(f.id);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Add a user parameter (refused when the name exists).
|
||||
pub fn add_user_param(&mut self, name: &str, expr_s: &str, unit: Option<&str>, comment: Option<&str>) -> Result<()> {
|
||||
if self.name_taken(name.trim()) {
|
||||
return Err(DocError::Invalid(format!("there is already a parameter `{}`", name.trim())));
|
||||
}
|
||||
if self.params.len() >= MAX_PARAMS {
|
||||
return Err(DocError::Invalid("too many parameters".into()));
|
||||
}
|
||||
self.set_param(name.trim(), expr_s, unit, comment)
|
||||
}
|
||||
}
|
||||
|
||||
/// One CSV line with quoted fields.
|
||||
fn parse_csv_line(l: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -258,6 +258,9 @@ fn parameter_cycles_are_found() {
|
||||
];
|
||||
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);
|
||||
let defs: Vec<crate::expr::ParamDef> =
|
||||
list.iter().map(|(n, e, _)| crate::expr::ParamDef { name: n.clone(), expr: e.clone(), unit: "mm".into() }).collect();
|
||||
let (vals, errs) = crate::expr::eval_params(&defs, 1.0);
|
||||
assert!(errs["a"].contains("circular reference: a → b → c → a"), "{}", errs["a"]);
|
||||
assert!(vals.contains_key("d") && errs.contains_key("a") && errs.contains_key("e"));
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
name,unit,expression,value,comment,favorite
|
||||
@@ -1 +0,0 @@
|
||||
name,unit,expression,value,comment,favorite
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Undo/redo, parameters, the timeline, deletion and selection.
|
||||
//! Undo/redo, the timeline, deletion and selection.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{Feature, FeatureKind};
|
||||
@@ -10,17 +10,6 @@ use crate::{EngineError, Result, Sel, Session, Snapshot};
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("UndoCommand", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
|
||||
CommandSpec::new("RedoCommand", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
|
||||
CommandSpec::new("parameters.list", "List Parameters", params_list).noundo().params("favorites?: bool (only favourites)"),
|
||||
CommandSpec::new("parameters.favorite", "Favourite Parameter", params_favorite).params("name, favorite?: bool (default true)"),
|
||||
CommandSpec::new("parameters.export", "Export Parameters", params_export)
|
||||
.noundo()
|
||||
.params("format?: csv|json (default csv), path?: write to a file"),
|
||||
CommandSpec::new("parameters.import", "Import Parameters", params_import)
|
||||
.params("text: CSV (name,unit,expression,comment) or JSON [{name, expression, unit?, comment?}] | path"),
|
||||
CommandSpec::new("ChangeParameterCommand", "Change Parameters", change_param)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("params")
|
||||
.params("name, expression (number or text), unit?: mm|deg|\"\", comment?; or delete: name"),
|
||||
CommandSpec::new("FusionComputeAllCommand", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
|
||||
CommandSpec::new("FusionDeleteCommand", "Delete", delete)
|
||||
.at("SOLID", "MODIFY")
|
||||
@@ -69,29 +58,6 @@ fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"redone": label}))
|
||||
}
|
||||
|
||||
fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "ChangeParameterCommand";
|
||||
if let Some(n) = str_(p, "delete") {
|
||||
s.doc_mut().remove_param(n)?;
|
||||
return Ok(json!({"deleted": n}));
|
||||
}
|
||||
let name = str_(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?;
|
||||
let e = match p.get("expression").or_else(|| p.get("value")) {
|
||||
Some(Value::String(x)) => x.clone(),
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
_ => return Err(bad(cmd, "`expression` must be a number or text")),
|
||||
};
|
||||
let unit = str_(p, "unit");
|
||||
let comment = str_(p, "comment");
|
||||
s.doc_mut().change_param(name, &e, unit, comment)?;
|
||||
s.refresh();
|
||||
let (vals, _) = s.doc.param_values();
|
||||
let v = vals.get(name).map(|v| v.v);
|
||||
// Report features that now fail.
|
||||
let errors: Vec<Value> = s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e}))).collect();
|
||||
Ok(json!({"name": name, "expression": e, "value": v, "recomputed": s.model.last_recomputed, "errors": errors}))
|
||||
}
|
||||
|
||||
fn compute_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.model = solvecraft_doc::Model::new();
|
||||
s.refresh();
|
||||
@@ -340,57 +306,3 @@ 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 }))
|
||||
}
|
||||
@@ -11,6 +11,7 @@ mod file;
|
||||
mod inspect;
|
||||
mod measure_sel;
|
||||
pub(crate) use measure_sel::measure_items;
|
||||
mod parameters;
|
||||
mod sketch;
|
||||
mod sketch_constraints;
|
||||
mod sketch_create;
|
||||
@@ -121,6 +122,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(features::COMMANDS.iter());
|
||||
v.extend(component::COMMANDS.iter());
|
||||
v.extend(edit::COMMANDS.iter());
|
||||
v.extend(parameters::COMMANDS.iter());
|
||||
v.extend(file::COMMANDS.iter());
|
||||
v.extend(inspect::COMMANDS.iter());
|
||||
v
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
//! Parameters: Change Parameters (add, edit, rename, delete) and the `parameters.*` commands
|
||||
//! behind the parameters dialog — list, add, rename, favourite, comment, users, dependency
|
||||
//! graph, CSV/JSON import and export, expression evaluation and autocomplete.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::expr::{self, Kind};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, str_};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("ChangeParameterCommand", "Change Parameters", change_param).at("SOLID", "MODIFY").icon("params").params(
|
||||
"name, expression (number or text), unit?: mm|cm|m|in|ft|deg|rad|\"\" (new parameters default to their value's unit), comment?, \
|
||||
favorite?: bool, new_name?: rename (references follow); or delete: name",
|
||||
),
|
||||
CommandSpec::new("parameters.list", "List Parameters", list).noundo().params(
|
||||
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \
|
||||
source, feature/input, comment, favorite, uses, used_by, error) and cycles",
|
||||
),
|
||||
CommandSpec::new("parameters.add", "Add User Parameter", add).params("name, expression, unit?, comment?, favorite?: bool"),
|
||||
CommandSpec::new("parameters.rename", "Rename Parameter", rename)
|
||||
.params("name, new_name (expressions, feature inputs and dimensions that use it follow)"),
|
||||
CommandSpec::new("parameters.delete", "Delete Parameter", delete).params("name (refused while something uses it; the error lists the users)"),
|
||||
CommandSpec::new("parameters.favorite", "Favourite Parameter", favorite).params("name, favorite?: bool (default true)"),
|
||||
CommandSpec::new("parameters.comment", "Comment Parameter", comment).params("name, comment"),
|
||||
CommandSpec::new("parameters.users", "Parameter Users", users)
|
||||
.noundo()
|
||||
.params("name → users (parameters, feature inputs, dimensions) and the features it drives"),
|
||||
CommandSpec::new("parameters.graph", "Parameter Graph", graph)
|
||||
.noundo()
|
||||
.params("→ nodes, edges [user, used] (users include Feature:<name>), cycles, errors"),
|
||||
CommandSpec::new("parameters.export", "Export Parameters", export)
|
||||
.noundo()
|
||||
.params("format?: csv|json (default csv, or from the path), path?: write to a file → text"),
|
||||
CommandSpec::new("parameters.import", "Import Parameters", import)
|
||||
.params("text: CSV (header with name, expression, unit?, comment?) or JSON [{name, expression, unit?, comment?}] | path; all or nothing"),
|
||||
CommandSpec::new("expr.evaluate", "Evaluate Expression", evaluate)
|
||||
.noundo()
|
||||
.params("expression, kind?: length|angle|unitless (or unit?: mm|in|deg…) → ok, value (mm/rad), display_value, references, error"),
|
||||
CommandSpec::new("parameters.complete", "Complete Expression", complete)
|
||||
.noundo()
|
||||
.params("prefix?: the text before the cursor → word, suggestions [{text, kind: parameter|function|unit|constant, detail}]"),
|
||||
];
|
||||
|
||||
fn expr_arg(p: &Value, k: &str) -> Option<String> {
|
||||
match p.get(k)? {
|
||||
Value::String(x) => Some(x.clone()),
|
||||
Value::Number(n) => n.as_f64().filter(|v| v.is_finite()).map(|v| format!("{v}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn param_name<'a>(cmd: &str, p: &'a Value) -> Result<&'a str> {
|
||||
str_(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "`name` is required"))
|
||||
}
|
||||
|
||||
/// Features that fail (name and error).
|
||||
fn failing(s: &Session) -> Vec<Value> {
|
||||
s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e}))).collect()
|
||||
}
|
||||
|
||||
fn known(s: &Session, name: &str) -> bool {
|
||||
s.doc.param(name).is_some() || s.doc.features.iter().any(|f| f.param_names.iter().any(|n| n == name))
|
||||
}
|
||||
|
||||
fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "ChangeParameterCommand";
|
||||
if let Some(n) = str_(p, "delete") {
|
||||
s.doc_mut().remove_param(n.trim())?;
|
||||
return Ok(json!({"deleted": n.trim()}));
|
||||
}
|
||||
let mut name = param_name(cmd, p)?.to_string();
|
||||
let mut out = json!({});
|
||||
if let Some(new) = str_(p, "new_name") {
|
||||
let n = s.doc_mut().rename_param(&name, new)?;
|
||||
out["renamed"] = json!({"from": name, "to": new.trim(), "references_updated": n});
|
||||
name = new.trim().to_string();
|
||||
}
|
||||
let has_expr = p.get("expression").is_some() || p.get("value").is_some();
|
||||
let e = if has_expr {
|
||||
Some(expr_arg(p, "expression").or_else(|| expr_arg(p, "value")).ok_or_else(|| bad(cmd, "`expression` must be a number or text"))?)
|
||||
} else if out.get("renamed").is_some() || p.get("favorite").is_some() || p.get("comment").is_some() {
|
||||
None
|
||||
} else {
|
||||
return Err(bad(cmd, "`expression` must be a number or text"));
|
||||
};
|
||||
let (unit, comment) = (str_(p, "unit"), str_(p, "comment"));
|
||||
match &e {
|
||||
Some(e) => s.doc_mut().change_param(&name, e, unit, comment)?,
|
||||
None => {
|
||||
if !known(s, &name) {
|
||||
return Err(bad(cmd, format!("no parameter `{name}`")));
|
||||
}
|
||||
if let Some(c) = comment {
|
||||
s.doc_mut().set_param_comment(&name, c)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(f) = bool_(p, "favorite") {
|
||||
s.doc_mut().set_favorite(&name, f)?;
|
||||
}
|
||||
s.refresh();
|
||||
let (vals, _) = s.doc.param_values();
|
||||
out["name"] = json!(name);
|
||||
out["expression"] = json!(e);
|
||||
out["value"] = json!(vals.get(&name).map(|v| v.v));
|
||||
out["display_value"] = json!(s.doc.param_display_value(&vals, &name));
|
||||
out["recomputed"] = json!(s.model.last_recomputed);
|
||||
out["errors"] = json!(failing(s));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let filter = match (bool_(p, "favorites"), str_(p, "filter")) {
|
||||
(Some(true), _) => "favorites".to_string(),
|
||||
(_, Some(f)) => f.to_ascii_lowercase(),
|
||||
_ => "all".to_string(),
|
||||
};
|
||||
let mut rows: Vec<solvecraft_doc::ParamRow> = s
|
||||
.doc
|
||||
.param_rows()
|
||||
.into_iter()
|
||||
.filter(|r| match filter.as_str() {
|
||||
"favorites" | "favourites" | "favorite" => r.favorite,
|
||||
"user" | "sketch" | "feature" => r.source == filter,
|
||||
"model" => r.source != "user",
|
||||
_ => true,
|
||||
})
|
||||
.collect();
|
||||
// Favourites first; otherwise the table order (user, sketch, feature).
|
||||
rows.sort_by_key(|r| !r.favorite);
|
||||
let cycles = solvecraft_doc::cycles(&s.doc.all_param_exprs());
|
||||
Ok(json!({"parameters": rows, "count": rows.len(), "cycles": cycles}))
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.add";
|
||||
let name = param_name(cmd, p)?.to_string();
|
||||
let e = expr_arg(p, "expression").or_else(|| expr_arg(p, "value")).ok_or_else(|| bad(cmd, "`expression` must be a number or text"))?;
|
||||
s.doc_mut().add_user_param(&name, &e, str_(p, "unit"), str_(p, "comment"))?;
|
||||
if let Some(f) = bool_(p, "favorite") {
|
||||
s.doc_mut().set_favorite(&name, f)?;
|
||||
}
|
||||
let (vals, _) = s.doc.param_values();
|
||||
Ok(
|
||||
json!({"name": name, "unit": s.doc.param(&name).map(|x| x.unit.clone()), "value": vals.get(&name).map(|v| v.v), "display_value": s.doc.param_display_value(&vals, &name)}),
|
||||
)
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.rename";
|
||||
let name = param_name(cmd, p)?.to_string();
|
||||
let new = str_(p, "new_name").ok_or_else(|| bad(cmd, "`new_name` is required"))?;
|
||||
let n = s.doc_mut().rename_param(&name, new)?;
|
||||
Ok(json!({"from": name, "to": new.trim(), "references_updated": n}))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = param_name("parameters.delete", p)?.to_string();
|
||||
s.doc_mut().remove_param(&name)?;
|
||||
Ok(json!({"deleted": name}))
|
||||
}
|
||||
|
||||
fn favorite(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = param_name("parameters.favorite", p)?.to_string();
|
||||
let on = bool_(p, "favorite").unwrap_or(true);
|
||||
s.doc_mut().set_favorite(&name, on)?;
|
||||
Ok(json!({"name": name, "favorite": on}))
|
||||
}
|
||||
|
||||
fn comment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.comment";
|
||||
let name = param_name(cmd, p)?.to_string();
|
||||
let c = str_(p, "comment").ok_or_else(|| bad(cmd, "`comment` must be text"))?;
|
||||
s.doc_mut().set_param_comment(&name, c)?;
|
||||
Ok(json!({"name": name, "comment": c}))
|
||||
}
|
||||
|
||||
fn users(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.users";
|
||||
let name = param_name(cmd, p)?;
|
||||
if !known(s, name) {
|
||||
return Err(bad(cmd, format!("no parameter `{name}`")));
|
||||
}
|
||||
let users = s.doc.param_users(name);
|
||||
let drives: Vec<Value> =
|
||||
s.doc.features_using_param(name).iter().filter_map(|id| s.doc.feature(*id)).map(|f| json!({"id": f.id, "name": f.name})).collect();
|
||||
Ok(json!({"name": name, "users": users, "drives": drives}))
|
||||
}
|
||||
|
||||
fn graph(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let (_, errs) = s.doc.param_values();
|
||||
let nodes: Vec<String> = s.doc.param_defs().into_iter().map(|d| d.name).collect();
|
||||
let edges: Vec<[String; 2]> = s.doc.param_graph().into_iter().map(|(a, b)| [a, b]).collect();
|
||||
let cycles = solvecraft_doc::cycles(&s.doc.all_param_exprs());
|
||||
Ok(json!({"nodes": nodes, "edges": edges, "cycles": cycles, "errors": errs}))
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.export";
|
||||
let path = str_(p, "path");
|
||||
let format = str_(p, "format").map(str::to_ascii_lowercase).unwrap_or_else(|| match path {
|
||||
Some(x) if x.to_ascii_lowercase().ends_with(".json") => "json".into(),
|
||||
_ => "csv".into(),
|
||||
});
|
||||
let text = match format.as_str() {
|
||||
"csv" => s.doc.params_csv(),
|
||||
"json" => {
|
||||
let rows: Vec<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}` (csv or json)"))),
|
||||
};
|
||||
if let Some(path) = path {
|
||||
std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
}
|
||||
Ok(json!({"format": format, "path": path, "text": text}))
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<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)?;
|
||||
s.refresh();
|
||||
Ok(json!({"imported": names, "errors": failing(s)}))
|
||||
}
|
||||
|
||||
fn evaluate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "expr.evaluate";
|
||||
let e = expr_arg(p, "expression").ok_or_else(|| bad(cmd, "`expression` must be text or a number"))?;
|
||||
let unit = str_(p, "unit");
|
||||
if let Some(u) = unit
|
||||
&& expr::unit_info(u).is_none()
|
||||
{
|
||||
return Err(bad(cmd, format!("unknown unit `{u}`")));
|
||||
}
|
||||
let kind = match (str_(p, "kind"), unit) {
|
||||
(Some("length"), _) => Some(Kind::Length),
|
||||
(Some("angle"), _) => Some(Kind::Angle),
|
||||
(Some("unitless" | "number"), _) => Some(Kind::Unitless),
|
||||
(Some(other), _) => return Err(bad(cmd, format!("unknown kind `{other}` (length, angle or unitless)"))),
|
||||
(None, Some(u)) => Some(Kind::from_unit(u)),
|
||||
(None, None) => None,
|
||||
};
|
||||
let (vals, _) = s.doc.param_values();
|
||||
let defaults = expr::Defaults::for_unit(unit.unwrap_or(""), 1.0);
|
||||
let refs = expr::references(&e);
|
||||
let lookup = |n: &str| vals.get(n).copied().ok_or_else(|| solvecraft_doc::DocError::Expr(format!("unknown parameter `{n}`")));
|
||||
let r = expr::eval_with_defaults(&e, &lookup, defaults).and_then(|v| match kind {
|
||||
Some(k) => v.to_kind_in(k, defaults).map(|x| (x, k)),
|
||||
None => v.kind().map(|k| (v.v, k)).ok_or_else(|| solvecraft_doc::DocError::Expr(format!("the result is {}", v.describe()))),
|
||||
});
|
||||
Ok(match r {
|
||||
Ok((v, k)) => {
|
||||
let value = match k {
|
||||
Kind::Length => expr::Value::length(v),
|
||||
Kind::Angle => expr::Value::angle(v),
|
||||
Kind::Unitless => expr::Value::num(v),
|
||||
};
|
||||
let shown = match (unit, k) {
|
||||
(Some(u), _) => expr::value_in_unit(value, u),
|
||||
(None, Kind::Angle) => v.to_degrees(),
|
||||
(None, _) => v,
|
||||
};
|
||||
json!({"ok": true, "value": v, "kind": k, "display_value": shown, "references": refs})
|
||||
}
|
||||
Err(err) => json!({"ok": false, "error": err.to_string().trim_start_matches("expression: ").to_string(), "references": refs}),
|
||||
})
|
||||
}
|
||||
|
||||
fn complete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_(p, "prefix").unwrap_or("");
|
||||
// The identifier being typed: trailing letters, digits and underscores.
|
||||
let word: String = text.chars().rev().take_while(|c| c.is_alphanumeric() || *c == '_').collect::<Vec<_>>().into_iter().rev().collect();
|
||||
let starts = |n: &str| word.is_empty() || n.to_ascii_lowercase().starts_with(&word.to_ascii_lowercase());
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
let mut rows: Vec<solvecraft_doc::ParamRow> = s.doc.param_rows().into_iter().filter(|r| starts(&r.name)).collect();
|
||||
// Favourites, then user parameters, then the rest.
|
||||
rows.sort_by_key(|r| (!r.favorite, r.source != "user"));
|
||||
for r in rows.into_iter().take(200) {
|
||||
let v = r.value.map(|v| format!("{v:.4} {}", r.unit).trim().to_string()).unwrap_or_else(|| "error".into());
|
||||
let mut detail = format!("{} = {v}", r.expression);
|
||||
if let (Some(f), Some(i)) = (&r.feature, r.input) {
|
||||
detail.push_str(&format!(" ({f} {i})"));
|
||||
}
|
||||
if !r.comment.is_empty() {
|
||||
detail.push_str(&format!(" — {}", r.comment));
|
||||
}
|
||||
out.push(json!({"text": r.name, "kind": "parameter", "detail": detail}));
|
||||
}
|
||||
for (f, sig) in expr::FUNCTIONS {
|
||||
if starts(f) {
|
||||
out.push(json!({"text": format!("{f}("), "kind": "function", "detail": sig}));
|
||||
}
|
||||
}
|
||||
if starts("pi") {
|
||||
out.push(json!({"text": "pi", "kind": "constant", "detail": "3.14159…"}));
|
||||
}
|
||||
for (u, k, _) in expr::UNITS {
|
||||
if !word.is_empty() && starts(u) {
|
||||
out.push(json!({"text": u, "kind": "unit", "detail": k}));
|
||||
}
|
||||
}
|
||||
Ok(json!({"word": word, "suggestions": out}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "parameters_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,226 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn err(s: &mut Session, id: &str, p: Value) -> String {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => panic!("{id} {p} should fail, gave {v}"),
|
||||
Err(e) => e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn volume(s: &mut Session) -> f64 {
|
||||
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs().max(1e-12)
|
||||
}
|
||||
|
||||
fn row<'a>(list: &'a Value, name: &str) -> &'a Value {
|
||||
list["parameters"].as_array().and_then(|a| a.iter().find(|p| p["name"] == name)).unwrap_or(&Value::Null)
|
||||
}
|
||||
|
||||
fn expr_of(s: &mut Session, name: &str) -> String {
|
||||
let l = run(s, "parameters.list", json!({}));
|
||||
row(&l, name)["expression"].as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// A sketched block 40 x (len / 2) x 10 whose width is an expression of a user parameter.
|
||||
fn block(s: &mut Session) {
|
||||
run(s, "parameters.add", json!({"name": "len", "expression": "60 mm", "comment": "overall length", "favorite": true}));
|
||||
run(s, "SketchCreate", json!({"plane": "XY", "name": "Base"}));
|
||||
run(s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
run(s, "ConstraintCoincident", json!({"a": "p1", "b": "origin"}));
|
||||
run(s, "SketchDimension", json!({"entities": ["l1"], "value": 40}));
|
||||
run(s, "SketchDimension", json!({"entities": ["l2"], "value": "len/2"}));
|
||||
run(s, "SketchStop", json!({}));
|
||||
run(s, "Extrude", json!({"distance": 10, "body_name": "Block"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_follows_an_expression() {
|
||||
let mut s = Session::default();
|
||||
block(&mut s);
|
||||
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
|
||||
let r = run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "100 mm"}));
|
||||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
assert!(rel(volume(&mut s), 40.0 * 50.0 * 10.0) < 1e-9);
|
||||
// Units convert: 4 in = 101.6 mm.
|
||||
run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "4 in"}));
|
||||
assert!(rel(volume(&mut s), 40.0 * 50.8 * 10.0) < 1e-9);
|
||||
// A parameter in inches reads bare numbers as inches.
|
||||
run(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "5", "unit": "in"}));
|
||||
assert!(rel(volume(&mut s), 40.0 * 63.5 * 10.0) < 1e-9);
|
||||
let l = run(&mut s, "parameters.list", json!({}));
|
||||
assert_eq!(row(&l, "len")["value"], 5.0, "{l}");
|
||||
assert_eq!(row(&l, "len")["unit"], "in");
|
||||
// A failing parameter fails the features using it, with its error.
|
||||
let e = err(&mut s, "ChangeParameterCommand", json!({"name": "len", "expression": "5 deg"}));
|
||||
assert!(e.contains("expected a length, got an angle"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_inputs_are_named_and_editable() {
|
||||
let mut s = Session::default();
|
||||
block(&mut s);
|
||||
let l = run(&mut s, "parameters.list", json!({"filter": "feature"}));
|
||||
let dist = l["parameters"].as_array().unwrap().iter().find(|r| r["input"] == "Distance").unwrap().clone();
|
||||
assert_eq!(dist["feature"], "Extrude1", "{l}");
|
||||
let d = dist["name"].as_str().unwrap().to_string();
|
||||
run(&mut s, "ChangeParameterCommand", json!({"name": d, "expression": "len / 3"}));
|
||||
assert!(rel(volume(&mut s), 40.0 * 30.0 * 20.0) < 1e-9);
|
||||
// Editing the feature keeps its parameter name.
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "12 mm"}}}));
|
||||
assert!(rel(volume(&mut s), 40.0 * 30.0 * 12.0) < 1e-9);
|
||||
assert_eq!(expr_of(&mut s, &d), "12 mm");
|
||||
// Another feature can use it.
|
||||
run(&mut s, "PrimitiveBox", json!({"length": d, "width": 5, "height": 5, "corner": [100, 0, 0]}));
|
||||
assert!(rel(volume(&mut s), 40.0 * 30.0 * 12.0 + 12.0 * 25.0) < 1e-9);
|
||||
let u = run(&mut s, "parameters.users", json!({"name": d}));
|
||||
assert_eq!(u["users"].as_array().map(Vec::len), Some(1), "{u}");
|
||||
assert_eq!(u["drives"].as_array().map(Vec::len), Some(2), "{u}");
|
||||
// Feature inputs can't be deleted on their own.
|
||||
assert!(err(&mut s, "parameters.delete", json!({"name": d})).contains("feature's input"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_errors_and_cycles_are_refused() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "a", "expression": "10 mm"}));
|
||||
let e = err(&mut s, "parameters.add", json!({"name": "bad", "expression": "a + 5 deg"}));
|
||||
assert!(e.contains("cannot add a length and an angle"), "{e}");
|
||||
let e = err(&mut s, "parameters.add", json!({"name": "ang", "expression": "a", "unit": "deg"}));
|
||||
assert!(e.contains("expected an angle, got a length"), "{e}");
|
||||
assert!(err(&mut s, "parameters.add", json!({"name": "x", "expression": "1", "unit": "furlong"})).contains("unknown unit"));
|
||||
run(&mut s, "parameters.add", json!({"name": "b", "expression": "a * 2"}));
|
||||
let e = err(&mut s, "ChangeParameterCommand", json!({"name": "a", "expression": "b + 1"}));
|
||||
assert!(e.contains("circular reference: a → b → a"), "{e}");
|
||||
assert!(err(&mut s, "parameters.add", json!({"name": "selfref", "expression": "selfref"})).contains("circular"));
|
||||
assert!(err(&mut s, "parameters.add", json!({"name": "a", "expression": "1"})).contains("already"));
|
||||
assert!(err(&mut s, "parameters.add", json!({"name": "sin", "expression": "1"})).contains("function"));
|
||||
assert!(err(&mut s, "parameters.add", json!({"name": "2x", "expression": "1"})).contains("not a valid parameter name"));
|
||||
// Units are inferred for new parameters.
|
||||
run(&mut s, "parameters.add", json!({"name": "tilt", "expression": "30 deg"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "n", "expression": "b / a"}));
|
||||
assert_eq!(s.doc.param("tilt").map(|p| p.unit.as_str()), Some("deg"));
|
||||
assert_eq!(s.doc.param("n").map(|p| p.unit.as_str()), Some(""));
|
||||
let r = run(&mut s, "expr.evaluate", json!({"expression": "atan2(b; a) + tilt", "kind": "angle"}));
|
||||
assert!((r["display_value"].as_f64().unwrap_or(0.0) - (2.0f64.atan().to_degrees() + 30.0)).abs() < 1e-9, "{r}");
|
||||
let r = run(&mut s, "expr.evaluate", json!({"expression": "a + tilt"}));
|
||||
assert_eq!(r["ok"], false);
|
||||
assert!(r["error"].as_str().unwrap_or("").contains("cannot add"), "{r}");
|
||||
let r = run(&mut s, "expr.evaluate", json!({"expression": "2", "unit": "in"}));
|
||||
assert!((r["value"].as_f64().unwrap_or(0.0) - 50.8).abs() < 1e-9, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_updates_every_reference() {
|
||||
let mut s = Session::default();
|
||||
block(&mut s);
|
||||
run(&mut s, "parameters.add", json!({"name": "twice", "expression": "len * 2"}));
|
||||
let r = run(&mut s, "parameters.rename", json!({"name": "len", "new_name": "length"}));
|
||||
assert_eq!(r["references_updated"], 2, "{r}");
|
||||
assert_eq!(expr_of(&mut s, "twice"), "length * 2");
|
||||
let l = run(&mut s, "parameters.list", json!({"favorites": true}));
|
||||
assert_eq!(l["count"], 1, "the favourite follows the rename");
|
||||
assert_eq!(row(&l, "length")["comment"], "overall length");
|
||||
run(&mut s, "ChangeParameterCommand", json!({"name": "length", "expression": "80"}));
|
||||
assert!(rel(volume(&mut s), 40.0 * 40.0 * 10.0) < 1e-9);
|
||||
// Model and feature parameters can be renamed too: dimensions and features follow.
|
||||
let dim = s.doc.features.first().and_then(|f| match &f.kind {
|
||||
solvecraft_doc::FeatureKind::Sketch { sketch, .. } => sketch.constraints.iter().filter_map(|c| c.param.clone()).next(),
|
||||
_ => None,
|
||||
});
|
||||
let dim = dim.unwrap_or_default();
|
||||
run(&mut s, "ChangeParameterCommand", json!({"name": dim, "new_name": "base_w", "expression": "50"}));
|
||||
assert!(rel(volume(&mut s), 50.0 * 40.0 * 10.0) < 1e-9);
|
||||
let ext = s.doc.features.get(1).and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
|
||||
run(&mut s, "parameters.rename", json!({"name": ext, "new_name": "thick"}));
|
||||
run(&mut s, "ChangeParameterCommand", json!({"name": "thick", "expression": "base_w / 10"}));
|
||||
assert!(rel(volume(&mut s), 50.0 * 40.0 * 5.0) < 1e-9);
|
||||
assert!(err(&mut s, "parameters.rename", json!({"name": "twice", "new_name": "length"})).contains("already"));
|
||||
assert!(err(&mut s, "parameters.rename", json!({"name": "nope", "new_name": "x"})).contains("unknown"));
|
||||
// Undo puts the old name back.
|
||||
run(&mut s, "UndoCommand", json!({}));
|
||||
run(&mut s, "UndoCommand", json!({}));
|
||||
assert!(s.doc.features.get(1).is_some_and(|f| f.param_names.first() == Some(&ext)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_used_parameter_lists_its_users() {
|
||||
let mut s = Session::default();
|
||||
block(&mut s);
|
||||
run(&mut s, "parameters.add", json!({"name": "spare", "expression": "len + 1"}));
|
||||
let e = err(&mut s, "parameters.delete", json!({"name": "len"}));
|
||||
assert!(e.contains("parameter spare") && e.contains("parameter d"), "{e}");
|
||||
let u = run(&mut s, "parameters.users", json!({"name": "len"}));
|
||||
assert_eq!(u["users"].as_array().map(Vec::len), Some(2), "{u}");
|
||||
assert_eq!(u["drives"][0]["name"], "Base", "{u}");
|
||||
let dim = u["users"].as_array().unwrap().iter().find(|x| x["name"] != "spare").unwrap()["name"].as_str().unwrap().to_string();
|
||||
let e = err(&mut s, "ChangeParameterCommand", json!({"delete": dim}));
|
||||
assert!(e.contains("Base dimension"), "{e}");
|
||||
run(&mut s, "parameters.delete", json!({"name": "spare"}));
|
||||
assert!(s.doc.param("spare").is_none());
|
||||
let g = run(&mut s, "parameters.graph", json!({}));
|
||||
assert!(g["edges"].as_array().unwrap().iter().any(|e| e[0] == dim.as_str() && e[1] == "len"), "{g}");
|
||||
assert!(g["edges"].as_array().unwrap().iter().any(|e| e[0] == "Feature:Base" && e[1] == dim.as_str()), "{g}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favorites_comments_and_completion() {
|
||||
let mut s = Session::default();
|
||||
block(&mut s);
|
||||
run(&mut s, "parameters.favorite", json!({"name": "len", "favorite": false}));
|
||||
assert_eq!(run(&mut s, "parameters.list", json!({"favorites": true}))["count"], 0);
|
||||
let ext = s.doc.features.get(1).and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
|
||||
run(&mut s, "parameters.comment", json!({"name": ext, "comment": "plate thickness"}));
|
||||
let l = run(&mut s, "parameters.list", json!({}));
|
||||
assert_eq!(row(&l, &ext)["comment"], "plate thickness");
|
||||
let c = run(&mut s, "parameters.complete", json!({"prefix": "2 * le"}));
|
||||
assert_eq!(c["word"], "le");
|
||||
assert_eq!(c["suggestions"][0]["text"], "len", "{c}");
|
||||
let c = run(&mut s, "parameters.complete", json!({"prefix": "at"}));
|
||||
assert!(c["suggestions"].as_array().unwrap().iter().any(|x| x["text"] == "atan2("), "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_and_json_round_trip() {
|
||||
let mut s = Session::default();
|
||||
block(&mut s);
|
||||
run(&mut s, "parameters.add", json!({"name": "note", "expression": "len / 4", "comment": "has, a \"comma\""}));
|
||||
let csv = run(&mut s, "parameters.export", json!({"format": "csv"}))["text"].as_str().unwrap_or_default().to_string();
|
||||
assert!(csv.contains("note,mm,len / 4,15,\"has, a \"\"comma\"\"\",false"), "{csv}");
|
||||
let json_text = run(&mut s, "parameters.export", json!({"format": "json"}))["text"].as_str().unwrap_or_default().to_string();
|
||||
|
||||
let mut t = Session::default();
|
||||
run(&mut t, "parameters.import", json!({"text": csv}));
|
||||
assert_eq!(t.doc.param("note").map(|p| p.comment.as_str()), Some("has, a \"comma\""));
|
||||
let mut u = Session::default();
|
||||
run(&mut u, "parameters.import", json!({"text": json_text}));
|
||||
assert_eq!(u.doc.param("note").map(|p| p.expr.as_str()), Some("len / 4"));
|
||||
|
||||
// Importing into the design updates values and rebuilds; all or nothing.
|
||||
run(&mut s, "parameters.import", json!({"text": "name,expression\nlen,80 mm\nextra,3\n"}));
|
||||
assert!(rel(volume(&mut s), 40.0 * 40.0 * 10.0) < 1e-9);
|
||||
assert!(s.execute("parameters.import", &json!({"text": "name,expression\nlen,90 mm\nbroken,len + 1 deg\n"})).is_err());
|
||||
assert_eq!(s.doc.param("len").map(|p| p.expr.as_str()), Some("80 mm"));
|
||||
assert!(matches!(s.execute("parameters.import", &json!({})), Err(EngineError::BadParams { .. })));
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-params-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join("p.json");
|
||||
let r = run(&mut s, "parameters.export", json!({"path": path.to_string_lossy()}));
|
||||
assert_eq!(r["format"], "json");
|
||||
let mut v = Session::default();
|
||||
run(&mut v, "parameters.import", json!({"path": path.to_string_lossy()}));
|
||||
assert_eq!(v.doc.param("len").map(|p| p.expr.as_str()), Some("80 mm"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -288,6 +288,12 @@ fn hostile_params_never_panic() {
|
||||
"datum",
|
||||
"tolerance",
|
||||
"face",
|
||||
"new_name",
|
||||
"favorite",
|
||||
"favorites",
|
||||
"filter",
|
||||
"kind",
|
||||
"prefix",
|
||||
];
|
||||
let mut internal = Vec::new();
|
||||
for spec in command_specs() {
|
||||
@@ -299,6 +305,9 @@ fn hostile_params_never_panic() {
|
||||
}
|
||||
for v in hostile_values() {
|
||||
for k in keys {
|
||||
if k == "path" && spec.id.starts_with("parameters.") {
|
||||
continue; // parameter files: covered by their own tests (no stray files)
|
||||
}
|
||||
let mut s = Session::default();
|
||||
// A model with a sketch, a body and an active sketch, so most commands get far.
|
||||
let _ = s.execute("PrimitiveBox", &json!({"length": 10, "width": 10, "height": 10}));
|
||||
|
||||
Reference in new issue
Block a user