Skip to main content

brep_simulation/
forms.rs

1//! The Simulation pane's forms: a motor's and a clip's fields as the app's
2//! schema-driven form engine reads them (an `inputParamsSchema` of `number`,
3//! `options`, `boolean` and `reference_selection` fields), a FLAT view of the
4//! item for the form to edit (the engine binds top-level keys), which fields
5//! hide for the item's current mode, and the way back — the patch
6//! `simulation_patch` merges into the item. Pure functions: the pane draws,
7//! this decides what it draws and what an edit means.
8
9use crate::model::{Clip, ClipBody, Drive, Motor, Shape, DEFAULT_ACCEL};
10use serde_json::{json, Map, Value};
11
12/// A mate a motor can drive, as the form offers it.
13#[derive(Debug, Clone, PartialEq)]
14pub struct MateChoice {
15    /// The constraint id (`DIST2`).
16    pub id: String,
17    /// `distance` or `angle`.
18    pub kind: String,
19    /// Its document value, what a profile starts from.
20    pub value: f64,
21}
22
23/// What the forms' option lists are built from: the assembly's mates, every
24/// constraint (a suppress clip's), the PMI views with a camera, the PMI views
25/// with a section, the exploded views.
26#[derive(Debug, Clone, Default, PartialEq)]
27pub struct FormChoices {
28    pub mates: Vec<MateChoice>,
29    pub constraints: Vec<String>,
30    pub camera_views: Vec<String>,
31    pub section_views: Vec<String>,
32    pub exploded_views: Vec<String>,
33}
34
35/// A camera clip offers this many view slots (`view1`…), in play order.
36pub const CAMERA_VIEW_SLOTS: usize = 4;
37/// The option an empty camera-view slot shows.
38pub const NO_VIEW: &str = "(none)";
39
40/// The field key a parts list binds to (the reference field a pick writes).
41pub const OCCURRENCES: &str = "occurrences";
42
43fn options(ids: impl IntoIterator<Item = String>, current: Option<&str>) -> Vec<String> {
44    let mut list: Vec<String> = ids.into_iter().collect();
45    if let Some(current) = current.filter(|c| !c.is_empty()) {
46        if !list.iter().any(|id| id == current) {
47            // A reference the document no longer has still shows as what it is.
48            list.push(current.to_string());
49        }
50    }
51    list
52}
53
54fn number(params: &Value, key: &str) -> Result<f64, String> {
55    match params.get(key) {
56        Some(Value::Number(n)) => n.as_f64().ok_or_else(|| format!("{key}: a number")),
57        Some(Value::String(text)) => text.trim().parse::<f64>().map_err(|_| format!("{key}: a number, not '{text}'")),
58        None | Some(Value::Null) => Err(format!("{key}: a number")),
59        Some(other) => Err(format!("{key}: a number, not {other}")),
60    }
61}
62
63fn text<'a>(params: &'a Value, key: &str) -> &'a str {
64    params.get(key).and_then(Value::as_str).unwrap_or("")
65}
66
67// ---------------------------------------------------------------------------
68// Motors
69// ---------------------------------------------------------------------------
70
71/// The motor form's schema: the mate it drives, its span, the drive mode and
72/// the mode's own fields (speed, or a profile's shape, start, end and the
73/// trapezoid's acceleration share).
74pub fn motor_schema(motor: &Motor, choices: &FormChoices) -> Value {
75    let unit = choices.mates.iter().find(|m| m.id == motor.constraint).map_or("mm", |m| if m.kind == "angle" { "°" } else { "mm" });
76    json!({
77        "type": "simulationMotor",
78        "shortName": "MOTOR",
79        "longName": "Motor",
80        "inputParamsSchema": {
81            "constraint": { "type": "options", "label": "Mate", "options": options(choices.mates.iter().map(|m| m.id.clone()), Some(&motor.constraint)),
82                "hint": "The distance or angle mate the motor drives" },
83            "from": { "type": "number", "label": "From (s)", "default_value": 0.0 },
84            "to": { "type": "number", "label": "To (s)", "default_value": 1.0, "hint": "The motor runs inside its span and holds its last value after" },
85            "mode": { "type": "options", "label": "Drive", "options": ["speed", "profile"], "default_value": "speed" },
86            "speed": { "type": "number", "label": format!("Speed ({unit}/s)"), "default_value": 10.0, "hint": "From the mate's own value, at a constant rate; negative runs backwards" },
87            "shape": { "type": "options", "label": "Profile", "options": ["linear", "ease", "trapezoid"], "default_value": "linear" },
88            "start": { "type": "number", "label": format!("Start ({unit})"), "default_value": 0.0 },
89            "end": { "type": "number", "label": format!("End ({unit})"), "default_value": 20.0 },
90            "accel": { "type": "number", "label": "Acceleration share", "default_value": DEFAULT_ACCEL, "hint": "Trapezoid: the share of the span spent accelerating, and again decelerating (0–0.5)" }
91        }
92    })
93}
94
95/// The motor as the form edits it: its fields flat, the drive's under
96/// `mode`, `speed`, `shape`, `start`, `end`, `accel`.
97pub fn motor_params(motor: &Motor) -> Value {
98    let mut params = json!({ "constraint": motor.constraint, "from": motor.from, "to": motor.to });
99    match &motor.drive {
100        Drive::Speed { speed } => {
101            params["mode"] = json!("speed");
102            params["speed"] = json!(speed);
103        }
104        Drive::Profile { shape, start, end, accel } => {
105            params["mode"] = json!("profile");
106            params["shape"] = json!(shape_key(*shape));
107            params["start"] = json!(start);
108            params["end"] = json!(end);
109            if let Some(accel) = accel {
110                params["accel"] = json!(accel);
111            }
112        }
113    }
114    params
115}
116
117/// The motor fields the current mode has no use for.
118pub fn motor_hidden(params: &Value) -> Vec<String> {
119    let mut hidden = Vec::new();
120    if text(params, "mode") == "profile" {
121        hidden.push("speed".to_string());
122        if text(params, "shape") != "trapezoid" {
123            hidden.push("accel".to_string());
124        }
125    } else {
126        hidden.extend(["shape", "start", "end", "accel"].map(str::to_string));
127    }
128    hidden
129}
130
131/// The patch an edited motor form means: `constraint`, `from`, `to` and the
132/// `drive` rebuilt from the mode. A mode just switched has no fields of its
133/// own yet, so they are seeded as the pane always did: a speed of 10 mm/s or
134/// 90°/s, a profile from the mate's value 20 mm or 360° on, a trapezoid's
135/// default acceleration share.
136pub fn motor_patch(params: &Value, choices: &FormChoices) -> Result<Value, String> {
137    let constraint = text(params, "constraint").to_string();
138    let mate = choices.mates.iter().find(|m| m.id == constraint);
139    let angle = mate.is_some_and(|m| m.kind == "angle");
140    let drive = match text(params, "mode") {
141        "profile" => {
142            let shape = match text(params, "shape") {
143                "" | "linear" => Shape::Linear,
144                "ease" => Shape::Ease,
145                "trapezoid" => Shape::Trapezoid,
146                other => return Err(format!("shape: linear, ease or trapezoid, not '{other}'")),
147            };
148            let base = mate.map_or(0.0, |m| m.value);
149            let start = number(params, "start").unwrap_or(base);
150            let end = number(params, "end").unwrap_or(base + if angle { 360.0 } else { 20.0 });
151            let accel = match shape {
152                Shape::Trapezoid => Some(number(params, "accel").unwrap_or(DEFAULT_ACCEL)),
153                _ => None,
154            };
155            let mut drive = json!({ "mode": "profile", "shape": shape_key(shape), "start": start, "end": end });
156            if let Some(accel) = accel {
157                drive["accel"] = json!(accel);
158            }
159            drive
160        }
161        "" | "speed" => json!({ "mode": "speed", "speed": number(params, "speed").unwrap_or(if angle { 90.0 } else { 10.0 }) }),
162        other => return Err(format!("mode: speed or profile, not '{other}'")),
163    };
164    Ok(json!({ "constraint": constraint, "from": number(params, "from")?, "to": number(params, "to")?, "drive": drive }))
165}
166
167fn shape_key(shape: Shape) -> &'static str {
168    match shape {
169        Shape::Linear => "linear",
170        Shape::Ease => "ease",
171        Shape::Trapezoid => "trapezoid",
172    }
173}
174
175// ---------------------------------------------------------------------------
176// Clips
177// ---------------------------------------------------------------------------
178
179/// The clip kind's word in the schema (`suppress`, `camera`, …), the same
180/// `kind` the block stores.
181pub fn clip_kind(clip: &Clip) -> &'static str {
182    match clip.body {
183        ClipBody::Suppress { .. } => "suppress",
184        ClipBody::Camera { .. } => "camera",
185        ClipBody::Visibility { .. } => "visibility",
186        ClipBody::Transparency { .. } => "transparency",
187        ClipBody::Section { .. } => "section",
188        ClipBody::Explode { .. } => "explode",
189    }
190}
191
192/// The clip form's title word.
193pub fn clip_title(clip: &Clip) -> &'static str {
194    match clip.body {
195        ClipBody::Suppress { .. } => "Suppress a mate",
196        ClipBody::Camera { .. } => "Camera path",
197        ClipBody::Visibility { .. } => "Hide parts",
198        ClipBody::Transparency { .. } => "Fade parts",
199        ClipBody::Section { .. } => "Section",
200        ClipBody::Explode { .. } => "Exploded view",
201    }
202}
203
204/// The clip form's schema: the span, then the kind's own fields — the mate
205/// (suppress), the parts as a reference field picked in the viewport
206/// (visibility, transparency: solids or components), the fade's end
207/// opacities, the view slots (camera), the section view and its sweep, the
208/// exploded view and Collapse.
209pub fn clip_schema(clip: &Clip, choices: &FormChoices) -> Value {
210    let mut fields = Map::new();
211    fields.insert("from".into(), json!({ "type": "number", "label": "From (s)", "default_value": 0.0 }));
212    fields.insert("to".into(), json!({ "type": "number", "label": "To (s)", "default_value": 1.0 }));
213    let parts = json!({
214        "type": "reference_selection", "label": "Parts", "selectionFilter": ["SOLID", "COMPONENT"], "multiple": true,
215        "minSelections": 0, "maxSelections": 256, "default_value": [],
216        "hint": "Click parts in the view; a solid stands for the component that holds it"
217    });
218    match &clip.body {
219        ClipBody::Suppress { constraint } => {
220            fields.insert("constraint".into(), json!({ "type": "options", "label": "Mate", "options": options(choices.constraints.iter().cloned(), Some(constraint)), "hint": "Off (suppressed) inside the span" }));
221        }
222        ClipBody::Camera { views } => {
223            for slot in 0..CAMERA_VIEW_SLOTS {
224                let mut list = vec![NO_VIEW.to_string()];
225                list.extend(options(choices.camera_views.iter().cloned(), views.get(slot).map(String::as_str)));
226                fields.insert(format!("view{}", slot + 1), json!({ "type": "options", "label": format!("View {}", slot + 1), "options": list, "default_value": NO_VIEW,
227                    "hint": "The camera moves through the views in this order, evenly over the span" }));
228            }
229        }
230        ClipBody::Visibility { .. } => {
231            fields.insert(OCCURRENCES.into(), parts);
232        }
233        ClipBody::Transparency { .. } => {
234            fields.insert(OCCURRENCES.into(), parts);
235            fields.insert("opacityFrom".into(), json!({ "type": "number", "label": "Opacity at start", "default_value": 1.0, "hint": "1 opaque, 0 invisible" }));
236            fields.insert("opacityTo".into(), json!({ "type": "number", "label": "Opacity at end", "default_value": 0.2 }));
237        }
238        ClipBody::Section { view, .. } => {
239            fields.insert("view".into(), json!({ "type": "options", "label": "Section view", "options": options(choices.section_views.iter().cloned(), Some(view)), "hint": "A PMI view with a section: its cut is on inside the span" }));
240            fields.insert("sweepEnabled".into(), json!({ "type": "boolean", "label": "Sweep the planes", "default_value": false }));
241            fields.insert("sweepFrom".into(), json!({ "type": "number", "label": "Sweep from (mm)", "default_value": 0.0, "hint": "Along the planes' normals, over the span" }));
242            fields.insert("sweepTo".into(), json!({ "type": "number", "label": "Sweep to (mm)", "default_value": 0.0 }));
243        }
244        ClipBody::Explode { view, .. } => {
245            fields.insert("view".into(), json!({ "type": "options", "label": "Exploded view", "options": options(choices.exploded_views.iter().cloned(), Some(view)) }));
246            fields.insert("collapse".into(), json!({ "type": "boolean", "label": "Collapse", "default_value": false, "hint": "Play the steps backwards: the view comes together" }));
247        }
248    }
249    json!({ "type": format!("simulationClip:{}", clip_kind(clip)), "shortName": "CLIP", "longName": clip_title(clip), "inputParamsSchema": Value::Object(fields) })
250}
251
252/// The clip as the form edits it, flat: the span and the kind's fields.
253pub fn clip_params(clip: &Clip) -> Value {
254    let mut params = json!({ "from": clip.from, "to": clip.to });
255    match &clip.body {
256        ClipBody::Suppress { constraint } => params["constraint"] = json!(constraint),
257        ClipBody::Camera { views } => {
258            for slot in 0..CAMERA_VIEW_SLOTS {
259                params[format!("view{}", slot + 1)] = json!(views.get(slot).cloned().unwrap_or_else(|| NO_VIEW.to_string()));
260            }
261        }
262        ClipBody::Visibility { occurrences } => params[OCCURRENCES] = json!(occurrences),
263        ClipBody::Transparency { occurrences, opacity } => {
264            params[OCCURRENCES] = json!(occurrences);
265            params["opacityFrom"] = json!(opacity[0]);
266            params["opacityTo"] = json!(opacity[1]);
267        }
268        ClipBody::Section { view, sweep } => {
269            params["view"] = json!(view);
270            params["sweepEnabled"] = json!(sweep.is_some());
271            let sweep = sweep.unwrap_or([0.0, 0.0]);
272            params["sweepFrom"] = json!(sweep[0]);
273            params["sweepTo"] = json!(sweep[1]);
274        }
275        ClipBody::Explode { view, collapse } => {
276            params["view"] = json!(view);
277            params["collapse"] = json!(collapse);
278        }
279    }
280    params
281}
282
283/// The clip fields the current state has no use for (a sweep that is off).
284pub fn clip_hidden(clip: &Clip, params: &Value) -> Vec<String> {
285    match clip.body {
286        ClipBody::Section { .. } if params.get("sweepEnabled") != Some(&Value::Bool(true)) => vec!["sweepFrom".into(), "sweepTo".into()],
287        _ => Vec::new(),
288    }
289}
290
291/// The patch an edited clip form means for `clip`'s kind.
292pub fn clip_patch(clip: &Clip, params: &Value) -> Result<Value, String> {
293    let mut patch = json!({ "from": number(params, "from")?, "to": number(params, "to")? });
294    match &clip.body {
295        ClipBody::Suppress { .. } => patch["constraint"] = json!(text(params, "constraint")),
296        ClipBody::Camera { .. } => {
297            let views: Vec<String> = (0..CAMERA_VIEW_SLOTS)
298                .map(|slot| text(params, &format!("view{}", slot + 1)).to_string())
299                .filter(|view| !view.is_empty() && view != NO_VIEW)
300                .collect();
301            patch["views"] = json!(views);
302        }
303        ClipBody::Visibility { .. } => patch[OCCURRENCES] = occurrences(params),
304        ClipBody::Transparency { .. } => {
305            patch[OCCURRENCES] = occurrences(params);
306            patch["opacity"] = json!([number(params, "opacityFrom")?, number(params, "opacityTo")?]);
307        }
308        ClipBody::Section { .. } => {
309            patch["view"] = json!(text(params, "view"));
310            patch["sweep"] = if params.get("sweepEnabled") == Some(&Value::Bool(true)) {
311                json!([number(params, "sweepFrom")?, number(params, "sweepTo")?])
312            } else {
313                Value::Null
314            };
315        }
316        ClipBody::Explode { .. } => {
317            patch["view"] = json!(text(params, "view"));
318            patch["collapse"] = json!(params.get("collapse") == Some(&Value::Bool(true)));
319        }
320    }
321    Ok(patch)
322}
323
324fn occurrences(params: &Value) -> Value {
325    let list: Vec<String> = params
326        .get(OCCURRENCES)
327        .and_then(Value::as_array)
328        .map(|items| items.iter().filter_map(Value::as_str).map(str::to_string).filter(|s| !s.is_empty()).collect())
329        .unwrap_or_default();
330    json!(list)
331}
332