Skip to main content

brep_render/engine_state/
simulation_ops.rs

1//! Motion studies on the engine: an assembly document keeps its studies in
2//! its own `simulation` block (`brep_simulation::model`), and a study drives
3//! that document's mates.
4//!
5//! What this module adds on top of the crate:
6//! * [`EngineState::simulation`] / [`EngineState::simulation_edit`]: read the
7//!   block, and read-modify-write it as one undo step (the block is started
8//!   on the first edit);
9//! * [`EngineState::simulation_motor_mates`]: the mates a motor can drive
10//!   (this document's distance and angle constraints) with their values;
11//! * [`EngineState::simulation_patch`]: merge fields into a study, motor or
12//!   clip, so every editor (pane, automation) writes through one door.
13
14use super::EngineState;
15use brep_simulation::model::{Simulation, BLOCK_KEY};
16use serde_json::{Map, Value};
17
18/// A mate a motor can drive.
19#[derive(Debug, Clone, PartialEq, serde::Serialize)]
20#[serde(rename_all = "camelCase")]
21pub struct MotorMate {
22    /// The constraint id (`DIST2`).
23    pub id: String,
24    /// `distance` or `angle`.
25    pub kind: String,
26    /// Its document value (model units or degrees); a mate that adopts its
27    /// value on first solve and has not solved yet reads 0.
28    pub value: f64,
29    pub enabled: bool,
30}
31
32impl MotorMate {
33    /// The unit a motor on it runs in, per second.
34    pub fn rate_unit(&self) -> &'static str {
35        if self.kind == "angle" { "°/s" } else { "mm/s" }
36    }
37}
38
39impl EngineState {
40    /// The document's simulation block; `Ok(None)` when it has none.
41    pub fn simulation(&self) -> Result<Option<Simulation>, String> {
42        match self.history.document_block(BLOCK_KEY) {
43            None | Some(Value::Null) => Ok(None),
44            Some(block) => Simulation::from_value(block).map(Some),
45        }
46    }
47
48    /// Whether the document has an assembly whose mates a study can drive.
49    pub fn simulation_available(&self) -> bool {
50        self.history_has_assembly()
51    }
52
53    /// Read the block (a new one when the document has none), apply `edit`,
54    /// validate and write it back as ONE undo step (`coalesce` groups a typing
55    /// run). Nothing is written on a refusal.
56    pub fn simulation_edit<T>(
57        &mut self,
58        coalesce: Option<&str>,
59        edit: impl FnOnce(&mut Simulation) -> Result<T, String>,
60    ) -> Result<T, String> {
61        if !self.simulation_available() {
62            return Err("open an assembly: a study drives its mates".into());
63        }
64        let mut block = self.simulation()?.unwrap_or_default();
65        let out = edit(&mut block)?;
66        block.validate()?;
67        self.history.set_document_block(BLOCK_KEY, Some(block.to_value()), coalesce);
68        Ok(out)
69    }
70
71    /// This document's distance and angle constraints, in history order.
72    pub fn simulation_motor_mates(&self) -> Vec<MotorMate> {
73        let constraints = self.history.document_block("assembly").and_then(|a| a.get("constraints")).and_then(Value::as_array);
74        constraints
75            .into_iter()
76            .flatten()
77            .filter_map(|entry| {
78                let kind = entry.get("type")?.as_str()?;
79                let key = match kind {
80                    "distance" => "distance",
81                    "angle" => "angle",
82                    _ => return None,
83                };
84                let params = entry.get("inputParams")?;
85                Some(MotorMate {
86                    id: params.get("id")?.as_str()?.to_string(),
87                    kind: kind.to_string(),
88                    value: params.get(key).and_then(Value::as_f64).unwrap_or(0.0),
89                    enabled: entry.get("enabled").and_then(Value::as_bool).unwrap_or(true),
90                })
91            })
92            .collect()
93    }
94
95    /// A new study (numbered when `name` is empty), made active. One undo step.
96    pub fn simulation_add_study(&mut self, name: &str) -> Result<String, String> {
97        self.simulation_edit(None, |block| Ok(block.add_study(name)))
98    }
99
100    /// Make `study` the one the workbench shows.
101    pub fn simulation_set_active(&mut self, study: &str) -> Result<(), String> {
102        self.simulation_edit(None, |block| {
103            block.study(study).ok_or_else(|| format!("no study '{study}'"))?;
104            block.active = Some(study.to_string());
105            Ok(())
106        })
107    }
108
109    /// Add a motor on mate `constraint` (the first mate a motor can drive
110    /// when `None`) to `study` (the active one when `None`): the whole study
111    /// long at 90 °/s for an angle mate, 10 mm/s for a distance mate. Returns
112    /// `(study id, motor id)`.
113    pub fn simulation_add_motor(&mut self, study: Option<&str>, constraint: Option<&str>) -> Result<(String, String), String> {
114        let mates = self.simulation_motor_mates();
115        let mate = match constraint {
116            Some(id) => mates.iter().find(|m| m.id == id).ok_or_else(|| format!("'{id}' is not a distance or angle mate of this assembly"))?,
117            None => mates.first().ok_or("add a distance or angle mate first: a motor drives one")?,
118        };
119        let speed = if mate.kind == "angle" { 90.0 } else { 10.0 };
120        let constraint = mate.id.clone();
121        self.simulation_edit(None, |block| {
122            let study_id = match study {
123                Some(id) => id.to_string(),
124                None => match block.active_study() {
125                    Some(active) => active.id.clone(),
126                    None => block.add_study(""),
127                },
128            };
129            let id = block.next_id("MOT");
130            let target = block.study_mut(&study_id).ok_or_else(|| format!("no study '{study_id}'"))?;
131            let to = target.duration;
132            target.motors.push(brep_simulation::Motor { id: id.clone(), constraint, from: 0.0, to, drive: brep_simulation::Drive::Speed { speed } });
133            Ok((study_id, id))
134        })
135    }
136
137    /// Every constraint of the assembly block, by id, for a suppress clip to
138    /// pick from (a motor drives only distance and angle mates; a suppress
139    /// clip switches any mate off).
140    pub fn simulation_constraint_ids(&self) -> Vec<String> {
141        self.history
142            .document_block("assembly")
143            .and_then(|a| a.get("constraints"))
144            .and_then(Value::as_array)
145            .map(|rows| rows.iter().filter_map(|row| row.get("inputParams")?.get("id")?.as_str().map(str::to_string)).collect())
146            .unwrap_or_default()
147    }
148
149    /// Add a clip of `kind` to `study` (the active one when `None`; a study
150    /// is started when there is none), on `target` or the natural default:
151    /// `disable` (alias `suppress`) the first constraint, `camera` through every PMI view (or
152    /// the one named), `visibility` or `transparency` (1 → 0.3) of the
153    /// selected occurrences or the first component, `section` of the first
154    /// PMI view with a section, `explode` of the first exploded view. The
155    /// span is the whole study. Returns `(study id, clip id)`. One undo step.
156    pub fn simulation_add_clip(&mut self, study: Option<&str>, kind: &str, target: Option<&str>) -> Result<(String, String), String> {
157        use brep_simulation::ClipBody;
158        let body = match kind {
159            // "Disable a mate": `suppress` is the stored kind (file format) and
160            // stays accepted; `disable` is the user-facing word.
161            "disable" | "suppress" => {
162                let ids = self.simulation_constraint_ids();
163                let constraint = match target {
164                    Some(id) if ids.iter().any(|c| c == id) => id.to_string(),
165                    Some(id) => return Err(format!("'{id}' is not a constraint of this assembly")),
166                    None => ids.first().cloned().ok_or("add a mate first: a Disable a mate clip switches one off")?,
167                };
168                ClipBody::Suppress { constraint }
169            }
170            "camera" => {
171                let views: Vec<String> = self.pmi_state().views.iter().filter(|v| v.camera.is_some()).map(|v| v.id.clone()).collect();
172                let views = match target {
173                    Some(id) if views.iter().any(|v| v == id) => vec![id.to_string()],
174                    Some(id) => return Err(format!("'{id}' is not a PMI view with a camera")),
175                    None if views.is_empty() => return Err("capture a PMI view first: a camera clip moves between views".into()),
176                    None => views,
177                };
178                ClipBody::Camera { views }
179            }
180            "visibility" => {
181                let occurrences = match target {
182                    Some(id) => vec![id.to_string()],
183                    None => {
184                        let selected = self.selected_occurrences();
185                        if selected.is_empty() {
186                            vec![self.component_ids().first().cloned().ok_or("add a component first: a visibility clip hides one")?]
187                        } else {
188                            selected
189                        }
190                    }
191                };
192                ClipBody::Visibility { occurrences }
193            }
194            "explode" => {
195                let views = self.exploded_views();
196                let view = match target {
197                    Some(id) if views.iter().any(|v| v.id == id) => id.to_string(),
198                    Some(id) => return Err(format!("'{id}' is not an exploded view")),
199                    None => views.first().map(|v| v.id.clone()).ok_or("make an exploded view first (Assembly: Auto explode): an explode clip plays its steps")?,
200                };
201                ClipBody::Explode { view, collapse: false }
202            }
203            "transparency" => {
204                let occurrences = match target {
205                    Some(id) => vec![id.to_string()],
206                    None => {
207                        let selected = self.selected_occurrences();
208                        if selected.is_empty() {
209                            vec![self.component_ids().first().cloned().ok_or("add a component first: a transparency clip fades one")?]
210                        } else {
211                            selected
212                        }
213                    }
214                };
215                ClipBody::Transparency { occurrences, opacity: [1.0, 0.3] }
216            }
217            "section" => {
218                let views: Vec<String> = self.pmi_state().views.iter().filter(|v| v.display.section.is_some()).map(|v| v.id.clone()).collect();
219                let view = match target {
220                    Some(id) if views.iter().any(|v| v == id) => id.to_string(),
221                    Some(id) => return Err(format!("'{id}' is not a PMI view with a section")),
222                    None => views.first().cloned().ok_or("give a PMI view a section first: a section clip plays it")?,
223                };
224                ClipBody::Section { view, sweep: None }
225            }
226            other => return Err(format!("no clip kind '{other}': disable (or suppress), camera, visibility, transparency, section or explode")),
227        };
228        self.simulation_edit(None, |block| {
229            let study_id = match study {
230                Some(id) => id.to_string(),
231                None => match block.active_study() {
232                    Some(active) => active.id.clone(),
233                    None => block.add_study(""),
234                },
235            };
236            let id = block.next_id("CLIP");
237            let target = block.study_mut(&study_id).ok_or_else(|| format!("no study '{study_id}'"))?;
238            let to = target.duration;
239            target.clips.push(brep_simulation::Clip { id: id.clone(), from: 0.0, to, body });
240            Ok((study_id, id))
241        })
242    }
243
244    /// Delete the study `study`, or its motor or clip `item`. One undo step.
245    pub fn simulation_remove(&mut self, study: &str, item: Option<&str>) -> Result<(), String> {
246        self.simulation_edit(None, |block| {
247            match item {
248                None => {
249                    let before = block.studies.len();
250                    block.studies.retain(|s| s.id != study);
251                    if block.studies.len() == before {
252                        return Err(format!("no study '{study}'"));
253                    }
254                    if block.active.as_deref() == Some(study) {
255                        block.active = block.studies.first().map(|s| s.id.clone());
256                    }
257                }
258                Some(id) => {
259                    let target = block.study_mut(study).ok_or_else(|| format!("no study '{study}'"))?;
260                    let before = target.motors.len() + target.clips.len();
261                    target.motors.retain(|m| m.id != id);
262                    target.clips.retain(|c| c.id != id);
263                    if target.motors.len() + target.clips.len() == before {
264                        return Err(format!("study '{study}' has no motor or clip '{id}'"));
265                    }
266                }
267            }
268            Ok(())
269        })
270    }
271
272    /// What automation and the pane read: the block (or `null`), the mates a
273    /// motor can drive, and whether the document can hold a study.
274    pub fn simulation_state_json(&self) -> Value {
275        let block = match self.simulation() {
276            Ok(block) => block.map(|b| b.to_value()).unwrap_or(Value::Null),
277            Err(error) => serde_json::json!({ "error": error }),
278        };
279        serde_json::json!({
280            "available": self.simulation_available(),
281            "block": block,
282            "mates": self.simulation_motor_mates(),
283        })
284    }
285
286    /// Merge `patch`'s fields into the study `study`, or into its motor or
287    /// clip `item` (an object patch; `null` removes an optional field), as one
288    /// undo step. The result must still be a valid study.
289    pub fn simulation_patch(&mut self, study: &str, item: Option<&str>, patch: &Value, coalesce: Option<&str>) -> Result<(), String> {
290        let patch = patch.as_object().ok_or("a patch is an object of fields")?.clone();
291        self.simulation_edit(coalesce, |block| {
292            let target = block.study_mut(study).ok_or_else(|| format!("no study '{study}'"))?;
293            match item {
294                None => {
295                    if patch.contains_key("id") || patch.contains_key("motors") || patch.contains_key("clips") {
296                        return Err("a study patch edits its own fields; motors and clips are edited one by one".into());
297                    }
298                    *target = merged(&*target, &patch)?;
299                }
300                Some(id) => {
301                    if patch.contains_key("id") {
302                        return Err("an id cannot be patched".into());
303                    }
304                    if let Some(motor) = target.motors.iter_mut().find(|m| m.id == id) {
305                        *motor = merged(&*motor, &patch)?;
306                    } else if let Some(clip) = target.clips.iter_mut().find(|c| c.id == id) {
307                        *clip = merged(&*clip, &patch)?;
308                    } else {
309                        return Err(format!("study '{study}' has no motor or clip '{id}'"));
310                    }
311                }
312            }
313            Ok(())
314        })
315    }
316}
317
318/// `value` with `patch`'s top-level fields merged in, read back as a `T`.
319fn merged<T: serde::Serialize + serde::de::DeserializeOwned>(value: &T, patch: &Map<String, Value>) -> Result<T, String> {
320    let mut object = serde_json::to_value(value).map_err(|e| e.to_string())?;
321    let fields = object.as_object_mut().ok_or("not an object")?;
322    for (key, field) in patch {
323        if field.is_null() {
324            fields.remove(key);
325        } else {
326            fields.insert(key.clone(), field.clone());
327        }
328    }
329    serde_json::from_value(object).map_err(|e| format!("that edit does not make a valid study: {e}"))
330}