Skip to main content

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}