Skip to main content

geop_ops_model/parameters/
configurations.rs

1//! A program's configurations ([`ConfigurationsPanel`]): the variants of
2//! one design — a light and a full version of a bracket, a long and a short
3//! shaft — each setting the parameters, and which steps run.
4//!
5//! A configuration is a row of the table looked up by the parameter
6//! [`CONFIGURATION`] (a [`LookupTable`] step, as any part family's), so
7//! that each column is a parameter of the configuration built,
8//! `configuration.length`, which formulas read as any other. A step that
9//! runs in some configurations only says so by its formula of whether it
10//! runs ([`ProgramStep::active`]): `configuration.fillet1`, a column of 1
11//! where it runs and 0 where it is suppressed. Nothing else is special
12//! about a configuration: the row built is the table's own, which a
13//! program placing the part sets by name as it sets any table's
14//! (`configuration = "light"`), and the bill of materials, the drawings
15//! and everything else read the part as built.
16//!
17//! The panel shows each table a program is looked up in — its
18//! configurations first — with the row built, to switch; adds a
19//! configuration; and suppresses a step in the configuration built, or —
20//! where there are no two configurations — in all of it.
21
22use geop_core_editor::{
23    editor::{Changed, EditorContext},
24    registry::Panel,
25};
26use geop_core_math::geop_error::{GeopError, GeopResult};
27use geop_core_program::{
28    numeric::{evaluate, read_parameter},
29    program::{Program, ProgramStep},
30    reader::Reader,
31};
32use serde::{Deserialize, Serialize};
33
34use crate::parameters::{
35    definition::{CONFIGURATION, Row, validate_name},
36    steps::{LOOKUP, LookupTableArgs},
37};
38
39/// The configurations panel: see the module docs.
40pub struct ConfigurationsPanel;
41
42/// A table a program is looked up in, as the panel shows it.
43#[derive(Clone, Debug, PartialEq, Serialize)]
44pub struct TableChoice {
45    /// The step looking it up.
46    pub step: String,
47    /// The parameter it is looked up by: [`CONFIGURATION`] for the
48    /// program's configurations.
49    pub input: String,
50    /// Its rows, by name, in order.
51    pub rows: Vec<String>,
52    /// The row built.
53    pub selected: String,
54}
55
56/// What the configurations panel shows.
57#[derive(Clone, Debug, PartialEq, Serialize)]
58pub struct ConfigurationsState {
59    /// Each table the program is looked up in, its configurations first,
60    /// then the others in the program's order.
61    pub tables: Vec<TableChoice>,
62}
63
64/// What the configurations panel is sent.
65#[derive(Clone, Debug, Serialize, Deserialize)]
66#[serde(tag = "command", rename_all = "snake_case")]
67pub enum ConfigurationsCommand {
68    /// Build the row `row` of the table looked up by `input`.
69    Select { input: String, row: String },
70    /// Add a configuration, `name` — a fresh one, if none — as the one
71    /// built is, and build it. The first one added makes the table, its
72    /// first row `Default`: the program as it was.
73    Add {
74        #[serde(default)]
75        name: Option<String>,
76    },
77    /// Suppress the step `step` in the configuration built, or run it
78    /// again there, if it is suppressed; where there are no two
79    /// configurations, everywhere.
80    Suppress { step: String },
81}
82
83impl Panel for ConfigurationsPanel {
84    const KEY: &'static str = "configurations";
85    type State = ConfigurationsState;
86    type Command = ConfigurationsCommand;
87
88    fn state(&self, program: &Program, _: &Reader) -> ConfigurationsState {
89        let mut tables: Vec<TableChoice> = (program.steps.iter())
90            .filter(|step| step.operation == LOOKUP)
91            .filter_map(|step| {
92                let table = serde_json::from_value::<LookupTableArgs>(step.args.clone()).ok()?;
93                Some(TableChoice {
94                    step: step.id.clone(),
95                    input: table.input,
96                    rows: table.rows.into_iter().map(|row| row.name).collect(),
97                    selected: table.selected,
98                })
99            })
100            .collect();
101        tables.sort_by_key(|table| table.input != CONFIGURATION);
102        ConfigurationsState { tables }
103    }
104
105    fn command(
106        &self,
107        ctx: &mut EditorContext,
108        command: ConfigurationsCommand,
109    ) -> GeopResult<Changed> {
110        match command {
111            ConfigurationsCommand::Select { input, row } => {
112                ctx.edit_program(|program| select(program, &input, &row))?;
113            }
114            ConfigurationsCommand::Add { name } => {
115                ctx.idle()?;
116                ctx.edit_program(|program| add(program, name.as_deref()))?;
117            }
118            ConfigurationsCommand::Suppress { step } => {
119                let runs = runs(ctx.program(), &ctx.part(), &step)?;
120                ctx.edit_program(|program| suppress(program, &step, runs))?;
121            }
122        }
123        Ok(Changed::Program)
124    }
125}
126
127/// The step of `program` looking up the table by `input`, its position and
128/// its table.
129fn table(program: &Program, input: &str) -> Option<(usize, LookupTableArgs)> {
130    (program.steps.iter().enumerate()).find_map(|(at, step)| {
131        let table = serde_json::from_value::<LookupTableArgs>(step.args.clone()).ok()?;
132        (step.operation == LOOKUP && table.input == input).then_some((at, table))
133    })
134}
135
136/// Writes `table` as the arguments of the step at `at` of `program`.
137fn write_table(program: &mut Program, at: usize, table: &LookupTableArgs) -> GeopResult<()> {
138    let step = (program.steps.get_mut(at))
139        .ok_or_else(|| GeopError::new(format!("the program has no step at {at}")))?;
140    step.args = serde_json::to_value(table)
141        .map_err(|e| GeopError::new(format!("the table {:?}: {e}", table.input)))?;
142    Ok(())
143}
144
145/// `program` building the row `row` of the table looked up by `input`.
146fn select(program: &mut Program, input: &str, row: &str) -> GeopResult<()> {
147    let (at, mut table) = table(program, input)
148        .ok_or_else(|| GeopError::new(format!("the program looks up no table by {input:?}")))?;
149    if !table.rows.iter().any(|r| r.name == row) {
150        let rows: Vec<&str> = table.rows.iter().map(|r| r.name.as_str()).collect();
151        return Err(GeopError::new(format!(
152            "the table {input:?} has no row {row:?}: its rows are {}",
153            rows.join(", ")
154        )));
155    }
156    table.selected = row.to_string();
157    write_table(program, at, &table)
158}
159
160/// `program` with the configuration `name` added — a fresh one for none —
161/// as the configuration built is, and building it; the first one making
162/// the table of configurations, first in the program, its first row
163/// `Default`.
164fn add(program: &mut Program, name: Option<&str>) -> GeopResult<()> {
165    let (at, mut table) = match table(program, CONFIGURATION) {
166        Some(found) => found,
167        None => {
168            let table = LookupTableArgs {
169                input: CONFIGURATION.into(),
170                columns: Vec::new(),
171                rows: vec![Row {
172                    name: "Default".into(),
173                    values: Vec::new(),
174                }],
175                selected: "Default".into(),
176            };
177            let id = (std::iter::once("configurations".to_string()))
178                .chain((2..).map(|n| format!("configurations{n}")))
179                .find(|id| program.step(id).is_err())
180                .unwrap_or_default();
181            program.steps.insert(0, ProgramStep::of(id, &table)?);
182            (0, table)
183        }
184    };
185    let taken = |name: &str| table.rows.iter().any(|r| r.name == name);
186    let name = match name.map(str::trim) {
187        Some("") => return Err(GeopError::new("a configuration needs a name")),
188        Some(name) if taken(name) => {
189            return Err(GeopError::new(format!(
190                "there is a configuration {name:?} already"
191            )));
192        }
193        Some(name) => name.to_string(),
194        None => (2..)
195            .map(|n| format!("Configuration {n}"))
196            .find(|name| !taken(name))
197            .unwrap_or_default(),
198    };
199    let values = (table.rows.iter())
200        .find(|r| r.name == table.selected)
201        .map(|r| r.values.clone())
202        .unwrap_or_else(|| vec![0.0; table.columns.len()]);
203    table.rows.push(Row {
204        name: name.clone(),
205        values,
206    });
207    table.selected = name;
208    write_table(program, at, &table)
209}
210
211/// Whether the step `id` of `program` runs in the part `part` reads, as
212/// its formula says (see [`ProgramStep::active`]): a formula that comes to
213/// nothing is taken to run, so suppressing it is what the user is offered.
214fn runs(program: &Program, part: &Reader, id: &str) -> GeopResult<bool> {
215    let step = program
216        .step(id)
217        .map_err(|e| e.with_context("suppressing a step: commit it first"))?;
218    Ok(match &step.active {
219        None => true,
220        Some(formula) => {
221            !matches!(evaluate(formula, |name| read_parameter(part, name)), Ok(value) if value == 0.0)
222        }
223    })
224}
225
226/// The name of the column of the table of configurations that says where
227/// the step `id` runs: its id, in the letters a parameter's name allows.
228fn column_of(id: &str) -> String {
229    let name: String = (id.chars())
230        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
231        .collect();
232    match name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
233        true => name,
234        false => format!("_{name}"),
235    }
236}
237
238/// `program` with the step `id`, which `runs` now, suppressed — or run
239/// again, if it does not — in the configuration built: by a column of the
240/// table of configurations, 1 where it runs, which its formula reads (and
241/// none, once it runs in all of them). Where there are no two
242/// configurations, it is suppressed, or runs again, everywhere: by a
243/// formula of `0`, or none.
244fn suppress(program: &mut Program, id: &str, runs: bool) -> GeopResult<()> {
245    let at = program.index_of(id)?;
246    let configurations = table(program, CONFIGURATION).filter(|(_, table)| table.rows.len() > 1);
247    let Some((table_at, mut table)) = configurations else {
248        program.step_mut(id)?.active = (runs).then(|| "0".to_string());
249        return Ok(());
250    };
251    if table_at > at {
252        return Err(GeopError::new(format!(
253            "the configurations are looked up after the step `{id}`, by the step `{}`: move \
254             that step before it to suppress `{id}` in one configuration",
255            program
256                .steps
257                .get(table_at)
258                .map_or("", |step| step.id.as_str())
259        )));
260    }
261    // The column the step's formula reads, if it reads one; else a fresh
262    // one, of where it runs now in every configuration.
263    let read = |column: &str| format!("{CONFIGURATION}.{column}");
264    let active = program.step(id)?.active.clone();
265    let column = match (table.columns.iter()).find(|c| active.as_deref() == Some(read(c).as_str()))
266    {
267        Some(column) => column.clone(),
268        None => {
269            let base = column_of(id);
270            validate_name(&base)?;
271            let column = (std::iter::once(base.clone()))
272                .chain((2..).map(|n| format!("{base}{n}")))
273                .find(|c| !table.columns.contains(c))
274                .unwrap_or(base);
275            table.columns.push(column.clone());
276            for row in &mut table.rows {
277                row.values.push(f64::from(u8::from(runs)));
278            }
279            column
280        }
281    };
282    let k = (table.columns.iter().position(|c| *c == column))
283        .ok_or_else(|| GeopError::new(format!("the table has no column {column:?}")))?;
284    let selected = table.selected.clone();
285    if let Some(row) = table.rows.iter_mut().find(|r| r.name == selected)
286        && let Some(value) = row.values.get_mut(k)
287    {
288        *value = f64::from(u8::from(!runs));
289    }
290    // A step that runs in every configuration needs no column.
291    let everywhere = (table.rows.iter()).all(|r| r.values.get(k).is_some_and(|v| *v != 0.0));
292    let active = match everywhere {
293        true => {
294            table.columns.retain(|c| *c != column);
295            for row in &mut table.rows {
296                row.values = (row.values.iter().enumerate())
297                    .filter(|(j, _)| *j != k)
298                    .map(|(_, v)| *v)
299                    .collect();
300            }
301            None
302        }
303        false => Some(read(&column)),
304    };
305    write_table(program, table_at, &table)?;
306    program.step_mut(id)?.active = active;
307    Ok(())
308}