1use crate::model::{Clip, ClipBody, Drive, Motor, Shape, DEFAULT_ACCEL};
10use serde_json::{json, Map, Value};
11
12#[derive(Debug, Clone, PartialEq)]
14pub struct MateChoice {
15 pub id: String,
17 pub kind: String,
19 pub value: f64,
21}
22
23#[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
35pub const CAMERA_VIEW_SLOTS: usize = 4;
37pub const NO_VIEW: &str = "(none)";
39
40pub 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 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
67pub 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
95pub 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
117pub 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
131pub 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
175pub 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
192pub 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
204pub 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
252pub 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
283pub 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
291pub 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