geop_ops_model/parameters/definition.rs
1//! [`Parameters`]: the named values a program's design is given by —
2//! numbers, tables of variants, the part's colour and what it is made of.
3//!
4//! A parameter is defined once, in the program, and read by name wherever
5//! a number is typed ([`NumericInput`](geop_core_program::numeric::NumericInput)):
6//! a sketch dimension of `width / 2`, a number parameter of `2 * wall`. A
7//! table is a family of variants — screw sizes, say — of which one row is
8//! selected: `screw.diameter` is that row's value in the column
9//! `diameter`.
10//!
11//! What a parameter is defined as is the program's own, in a step of it —
12//! a number in [`crate::parameters::steps::DefineParameters`], a table in a
13//! [`crate::parameters::steps::LookupTable`] of its own; what it is *built with* may be set
14//! by a program placing the part, by the same name: the run's input of
15//! that name (see [`geop_core_program::values::Settings`]). So a placed screw is made M5 by the
16//! program placing it, without touching the screw's own file.
17
18use geop_core_math::geop_error::{GeopError, GeopResult};
19use geop_core_program::numeric::FUNCTION_NAMES;
20use serde::{Deserialize, Serialize};
21
22/// The name the part's colour is set by.
23pub const COLOR: &str = "color";
24
25/// The parameter a program's configurations are looked up by: the table of
26/// its configurations is the one looked up by it (see
27/// [`crate::parameters::configurations`]).
28pub const CONFIGURATION: &str = "configuration";
29
30/// What a part is made of: a name, and its density in kg/m³ — what its
31/// mass and inertia are computed with (see `geop-ops-inspect`). Lengths are
32/// millimetres, so a part of `V` mm³ weighs `density · V · 1e-9` kg.
33#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
34pub struct Material {
35 pub name: String,
36 pub density: f64,
37}
38
39/// Materials an engineer reaches for, by name, with their densities in
40/// kg/m³ — what a material is picked from; any other is given by its
41/// density.
42const MATERIALS: [(&str, f64); 10] = [
43 ("Aluminium 6061", 2700.0),
44 ("Steel", 7850.0),
45 ("Stainless steel 304", 8000.0),
46 ("Brass", 8500.0),
47 ("Titanium Ti-6Al-4V", 4430.0),
48 ("PLA", 1240.0),
49 ("ABS", 1050.0),
50 ("PETG", 1270.0),
51 ("Nylon PA12", 1010.0),
52 ("Acetal (POM)", 1410.0),
53];
54
55impl Material {
56 /// The materials of [`MATERIALS`].
57 pub fn listed() -> impl Iterator<Item = Self> {
58 (MATERIALS.iter()).map(|&(name, density)| Self {
59 name: name.into(),
60 density,
61 })
62 }
63
64 /// The material of [`MATERIALS`] called `name`, if there is one.
65 pub fn named(name: &str) -> Option<Self> {
66 Self::listed().find(|material| material.name == name)
67 }
68}
69
70/// One row of a table ([`ParameterKind::Table`],
71/// [`crate::parameters::steps::LookupTableArgs`]): a variant, by name, with a value per
72/// column.
73#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
74pub struct Row {
75 pub name: String,
76 pub values: Vec<f64>,
77}
78
79/// What a parameter of a program is, as an editor lists them all: a number
80/// its [`crate::parameters::steps::DefineParameters`] step defines, or a table a
81/// [`crate::parameters::steps::LookupTable`] step of its own looks up.
82#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
83#[serde(tag = "type", rename_all = "snake_case")]
84pub enum ParameterKind {
85 /// A number, given as a formula of the parameters defined before it —
86 /// `12`, `width / 2`. `min` and `max` are what a slider offers when the
87 /// part is placed, not what is valid.
88 Number {
89 expression: String,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 min: Option<f64>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 max: Option<f64>,
94 },
95 /// A family of variants, one per row, of which `selected` is the one
96 /// built: its name is the parameter's value, and `name.column` its
97 /// value in `column`.
98 Table {
99 columns: Vec<String>,
100 rows: Vec<Row>,
101 selected: String,
102 },
103}
104
105/// A named parameter.
106#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
107pub struct Parameter {
108 pub name: String,
109 #[serde(flatten)]
110 pub kind: ParameterKind,
111}
112
113/// A number parameter, as its [`crate::parameters::steps::DefineParameters`] step holds
114/// it: a formula of the parameters defined before it — `12`, `width / 2`.
115/// `min` and `max` are what a slider offers when the part is placed, not
116/// what is valid.
117#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
118pub struct NumberParameter {
119 pub name: String,
120 pub expression: String,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub min: Option<f64>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub max: Option<f64>,
125}
126
127/// A program's parameters and properties, as an editor lists and edits them
128/// together: the part's colour and material, what it is called and ordered
129/// as ([`crate::parameters::steps::PartProperties`]), and the values it is designed
130/// with ([`crate::parameters::steps::DefineParameters`]), in order.
131#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
132pub struct Parameters {
133 /// The part's colour, `#rrggbb`; none for the viewer's own.
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub color: Option<String>,
136 /// What the part is made of; none for a part whose material is not
137 /// given, which has a volume and a centroid but no mass.
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub material: Option<Material>,
140 /// What the part is, in words — `ISO 4762 socket head cap screw` —
141 /// where it is listed: a bill of materials, a drawing's. None for a
142 /// part known by its file's name.
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub title: Option<String>,
145 /// What the part is ordered as, before the values it is built with
146 /// (see [`Parameters::designate`]): a norm — `ISO 4762`, for `ISO 4762
147 /// M4x12` — or a catalogue or part number. None for a part that is
148 /// made rather than bought.
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub designation: Option<String>,
151 #[serde(default, skip_serializing_if = "Vec::is_empty")]
152 pub values: Vec<Parameter>,
153}
154
155/// Whether `name` can name a parameter: a letter or `_`, then letters,
156/// digits and `_` — so that a formula reads it as one, and `name.column`
157/// names a table's column without ambiguity.
158pub fn validate_name(name: &str) -> GeopResult<()> {
159 let mut chars = name.chars();
160 let ok = chars
161 .next()
162 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
163 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
164 if !ok {
165 return Err(GeopError::new(format!(
166 "{name:?} is no parameter name: use letters, digits and _, starting with a letter"
167 )));
168 }
169 if FUNCTION_NAMES.contains(&name) || name == "pi" || name == COLOR {
170 return Err(GeopError::new(format!("{name:?} is a reserved name")));
171 }
172 Ok(())
173}
174
175/// Whether `color` is a colour, `#rrggbb`.
176pub fn validate_color(color: &str) -> GeopResult<()> {
177 let hex = color.strip_prefix('#').unwrap_or("");
178 if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
179 return Err(GeopError::new(format!(
180 "{color:?} is no colour of the form #rrggbb"
181 )));
182 }
183 Ok(())
184}
185
186impl NumberParameter {
187 /// The number parameter `name`, `expression` by default — a formula of
188 /// those before it, or a value — offered from `min` to `max`.
189 pub fn new(name: &str, expression: &str, min: f64, max: f64) -> Self {
190 Self {
191 name: name.into(),
192 expression: expression.into(),
193 min: Some(min),
194 max: Some(max),
195 }
196 }
197}
198
199impl Parameter {
200 /// The number parameter `name`, `expression` by default — a formula of
201 /// those before it, or a value — offered from `min` to `max`.
202 pub fn number(name: &str, expression: &str, min: f64, max: f64) -> Self {
203 Self {
204 name: name.into(),
205 kind: ParameterKind::Number {
206 expression: expression.into(),
207 min: Some(min),
208 max: Some(max),
209 },
210 }
211 }
212}
213
214impl Parameters {
215 pub fn is_empty(&self) -> bool {
216 self.color.is_none()
217 && self.material.is_none()
218 && self.title.is_none()
219 && self.designation.is_none()
220 && self.values.is_empty()
221 }
222
223 /// The parameter `name`.
224 pub fn get(&self, name: &str) -> Option<&Parameter> {
225 self.values.iter().find(|p| p.name == name)
226 }
227
228 /// Checks every name — valid, and unique — every table, and the colour.
229 /// Formulas are checked as they are resolved: one that fails is said
230 /// for that parameter, not for the program.
231 pub fn validate(&self) -> GeopResult<()> {
232 if let Some(color) = &self.color {
233 validate_color(color)?;
234 }
235 if let Some(Material { name, density }) = &self.material
236 && !(density.is_finite() && *density > 0.0)
237 {
238 return Err(GeopError::new(format!(
239 "the material {name:?} has a density of {density} kg/m³: it must be a positive number"
240 )));
241 }
242 for (i, p) in self.values.iter().enumerate() {
243 validate_name(&p.name)?;
244 if self.values[..i].iter().any(|q| q.name == p.name) {
245 return Err(GeopError::new(format!(
246 "there is more than one parameter {:?}",
247 p.name
248 )));
249 }
250 if let ParameterKind::Table {
251 columns,
252 rows,
253 selected,
254 } = &p.kind
255 {
256 for c in columns {
257 validate_name(c)
258 .map_err(|e| e.with_context(format!("column of the table {:?}", p.name)))?;
259 }
260 for row in rows {
261 if row.values.len() != columns.len() {
262 return Err(GeopError::new(format!(
263 "row {:?} of the table {:?} has {} values for {} columns",
264 row.name,
265 p.name,
266 row.values.len(),
267 columns.len()
268 )));
269 }
270 }
271 if !rows.iter().any(|r| &r.name == selected) {
272 return Err(GeopError::new(format!(
273 "the table {:?} has no row {selected:?} to select",
274 p.name
275 )));
276 }
277 }
278 }
279 Ok(())
280 }
281}