1use crate::automation::hit_keys::HitKeyDoc;
21use crate::form_view::{form_view, FormViewSpec};
22use brep_render::engine_state::{EngineState, MotorMate, RefSelectField, RefSelectTarget};
23use brep_render::features::form_fields_from_schema;
24use brep_simulation::forms::{self, FormChoices, MateChoice};
25use brep_simulation::model::{Clip, ClipBody, Drive, Study, FPS_RANGE, MAX_DURATION};
26use serde_json::{json, Value};
27use std::collections::{HashMap, HashSet};
28
29const DIALOG_HOVER_OWNER: &str = "simulation";
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum SimulationRequest {
35 NewStudy,
36 AddMotor,
37}
38
39const RULER_HEIGHT: f32 = 22.0;
41
42#[derive(Debug, Clone, PartialEq)]
45pub struct ExportRequest {
46 pub study: String,
47 pub format: String,
49 pub width: u32,
50 pub height: u32,
51 pub fps: u32,
52 pub path: String,
53}
54
55#[derive(Default)]
56pub struct SimulationPanel {
57 hits: HashMap<String, egui::Rect>,
58 request: Option<SimulationRequest>,
59 notice: Option<String>,
60 error: Option<String>,
61 in_workbench: bool,
63 export_format: String,
66 export_width: u32,
67 export_height: u32,
68 export_fps: u32,
69 export_path: String,
70 export: Option<ExportRequest>,
72 open: Option<String>,
75 hover: Option<String>,
77}
78
79enum Change {
81 Activate(String),
82 NewStudy,
83 AddMotor { study: String, mate: Option<String> },
84 Remove { study: String, item: Option<String> },
85 Patch { study: String, item: Option<String>, patch: Value, coalesce: Option<String> },
86 Play { study: String },
87 Pause,
88 Stop,
89 Loop { study: String, looping: bool },
90 Seek { study: String, time: f64 },
91 AddClip { study: String, kind: String },
92 Publish { study: String },
95 Open(Option<String>),
97 BeginRefSelect { clip: String, path: Vec<String>, label: String, filter: Vec<String>, multiple: bool, seed: Vec<String> },
99 FinishRefSelect,
101 RefSelectRemove(usize),
103}
104
105const CLIP_KINDS: [(&str, &str); 6] = [("suppress", "Suppress a mate"), ("visibility", "Hide parts"), ("transparency", "Fade parts"), ("camera", "Camera path"), ("section", "Section"), ("explode", "Exploded view")];
107
108impl SimulationPanel {
109 pub fn new() -> Self {
110 Self::default()
111 }
112
113 pub fn request(&mut self, request: SimulationRequest) {
115 self.request = Some(request);
116 }
117
118 pub fn take_export(&mut self) -> Option<ExportRequest> {
121 self.export.take()
122 }
123
124 pub fn report(&mut self, result: Result<String, String>) {
126 match result {
127 Ok(notice) => {
128 self.notice = Some(notice);
129 self.error = None;
130 }
131 Err(error) => self.error = Some(error),
132 }
133 }
134
135 fn left_workbench(&mut self, in_workbench: bool) -> bool {
139 let left = self.in_workbench && !in_workbench;
140 self.in_workbench = in_workbench;
141 left
142 }
143
144 pub fn drive_playback(&mut self, engine: &mut EngineState, pane_shown: bool, dt: f64) -> bool {
156 if self.left_workbench(pane_shown) && engine.simulation_playing_or_shown() {
157 engine.simulation_stop();
158 }
159 pane_shown && engine.simulation_tick(dt)
160 }
161
162 pub fn hits_json(&self) -> String {
163 crate::automation::hit_rects::hits_json(&self.hits)
164 }
165
166 pub fn clear_hits(&mut self) {
169 self.hits.clear();
170 }
171
172 pub fn state_json(&self) -> String {
175 json!({ "notice": self.notice, "error": self.error, "open": self.open }).to_string()
176 }
177
178 pub fn show(&mut self, ui: &mut egui::Ui, engine: &mut EngineState) {
179 self.hits.clear();
180 self.hits.insert("panel:clip".into(), ui.clip_rect());
181 if !engine.simulation_available() {
182 ui.label("Open an assembly: a study drives its distance and angle mates as motors.");
183 return;
184 }
185 if let Some(request) = self.request.take() {
186 let study = engine.simulation().ok().flatten().and_then(|b| b.active_study().map(|s| s.id.clone()));
187 let change = match request {
188 SimulationRequest::NewStudy => Change::NewStudy,
189 SimulationRequest::AddMotor => Change::AddMotor { study: study.unwrap_or_default(), mate: None },
190 };
191 self.apply(change, engine);
192 }
193 let block = match engine.simulation() {
194 Ok(block) => block.unwrap_or_default(),
195 Err(error) => {
196 ui.colored_label(ui.visuals().error_fg_color, format!("The studies cannot be read: {error}"));
197 return;
198 }
199 };
200 if let Some(error) = &self.error {
201 let label = ui.colored_label(ui.visuals().error_fg_color, error);
202 self.hits.insert("simulation:error".into(), label.rect);
203 }
204 if let Some(notice) = &self.notice {
205 let label = ui.weak(notice);
206 self.hits.insert("simulation:notice".into(), label.rect);
207 }
208 let mut change = None;
209 let active = block.active_study().map(|s| s.id.clone());
210 ui.horizontal_wrapped(|ui| {
211 ui.label("Studies");
212 for study in &block.studies {
213 let row = ui.selectable_label(active.as_deref() == Some(study.id.as_str()), &study.name);
214 self.hits.insert(format!("simulation:study:{}", study.id), row.rect);
215 if row.clicked() && active.as_deref() != Some(study.id.as_str()) {
216 change = Some(Change::Activate(study.id.clone()));
217 }
218 }
219 let new = ui.button("+ Study");
220 self.hits.insert("simulation:new_study".into(), new.rect);
221 if new.clicked() {
222 change = Some(Change::NewStudy);
223 }
224 });
225 let Some(study) = block.active_study() else {
226 ui.weak("No study yet: + Study (or New study on the toolbar) starts one.");
227 if let Some(change) = change {
228 self.apply(change, engine);
229 }
230 return;
231 };
232 ui.separator();
233 self.study_fields(ui, study, &mut change);
234 let playback = engine.simulation_playback_json();
235 self.transport(ui, study, &playback, &mut change);
236 self.ruler(ui, study, &playback, &mut change);
237 ui.separator();
238 let mates = engine.simulation_motor_mates();
239 let pmi = engine.pmi_state();
240 let choices = FormChoices {
241 mates: mates.iter().map(|m| MateChoice { id: m.id.clone(), kind: m.kind.clone(), value: m.value }).collect(),
242 constraints: engine.simulation_constraint_ids(),
243 camera_views: pmi.views.iter().filter(|v| v.camera.is_some()).map(|v| v.id.clone()).collect(),
244 section_views: pmi.views.iter().filter(|v| v.display.section.is_some()).map(|v| v.id.clone()).collect(),
245 exploded_views: engine.exploded_views().into_iter().map(|v| v.id).collect(),
246 };
247 if self.open.as_deref().is_some_and(|id| !study.motors.iter().any(|m| m.id == id) && !study.clips.iter().any(|c| c.id == id)) {
249 self.open = None;
250 }
251 self.hover = None;
252 match self.open.clone() {
253 Some(open) => {
254 let picking = engine.ref_select_field().filter(|f| f.target == RefSelectTarget::Simulation && f.subject == open);
257 self.item_form(ui, study, &open, &choices, picking.as_ref(), &mut change);
258 }
259 None => {
260 self.motors(ui, study, &mates, &mut change);
261 ui.separator();
262 self.clips(ui, study, &mut change);
263 }
264 }
265 ui.separator();
266 self.export_row(ui, study);
267 if let Some(change) = change {
268 self.apply(change, engine);
269 }
270 if let Some(field) = engine.ref_select_field() {
273 if field.target == RefSelectTarget::Simulation && self.open.as_deref() == Some(field.subject.as_str()) {
274 engine.ref_select_claim();
275 }
276 }
277 let hover_changed = match &self.hover {
279 Some(name) => engine.hover_entity_by_name(DIALOG_HOVER_OWNER, name),
280 None => engine.dialog_hover_end(DIALOG_HOVER_OWNER),
281 };
282 if hover_changed {
283 ui.ctx().request_repaint();
284 }
285 }
286
287 fn export_row(&mut self, ui: &mut egui::Ui, study: &Study) {
290 if self.export_format.is_empty() {
291 self.export_format = "gif".into();
292 }
293 if self.export_width == 0 || self.export_height == 0 {
294 (self.export_width, self.export_height) = (960, 540);
295 }
296 if self.export_fps == 0 {
297 self.export_fps = study.fps;
298 }
299 ui.strong("Export");
300 ui.horizontal_wrapped(|ui| {
301 let mut format = self.export_format.clone();
302 let combo = egui::ComboBox::from_id_salt("simulation-export-format").selected_text(format.to_uppercase()).show_ui(ui, |ui| {
303 for kind in ["gif", "mp4", "html"] {
304 let option = ui.selectable_value(&mut format, kind.to_string(), kind.to_uppercase());
305 self.hits.insert(format!("simulation:menuitem:export_format:{kind}"), option.rect);
306 }
307 });
308 self.hits.insert("simulation:export:format".into(), combo.response.rect);
309 if format != self.export_format {
310 if self.export_path.ends_with(&format!(".{}", self.export_format)) {
312 self.export_path = format!("{}.{format}", self.export_path.trim_end_matches(&format!(".{}", self.export_format)));
313 }
314 self.export_format = format;
315 }
316 for (label, value, key) in [("width", &mut self.export_width, "simulation:export:width"), ("height", &mut self.export_height, "simulation:export:height")] {
317 ui.label(label);
318 let drag = ui.add(egui::DragValue::new(value).range(16..=4096).speed(8));
319 self.hits.insert(key.into(), drag.rect);
320 }
321 ui.label("fps");
322 let drag = ui.add(egui::DragValue::new(&mut self.export_fps).range(1..=120));
323 self.hits.insert("simulation:export:fps".into(), drag.rect);
324 });
325 ui.horizontal_wrapped(|ui| {
326 if self.export_path.is_empty() {
327 self.export_path = format!("{}.{}", study.name.trim().replace(' ', "-"), self.export_format);
328 }
329 ui.label("file");
330 let field = ui.add(egui::TextEdit::singleline(&mut self.export_path).desired_width(220.0));
331 self.hits.insert("simulation:export:path".into(), field.rect);
332 let run = ui.button("Export");
333 self.hits.insert("simulation:export:run".into(), run.rect);
334 if run.clicked() {
335 self.export = Some(ExportRequest {
336 study: study.id.clone(),
337 format: self.export_format.clone(),
338 width: self.export_width,
339 height: self.export_height,
340 fps: self.export_fps,
341 path: self.export_path.clone(),
342 });
343 self.notice = Some("Exporting…".into());
344 self.error = None;
345 }
346 });
347 }
348
349 fn clips(&mut self, ui: &mut egui::Ui, study: &Study, change: &mut Option<Change>) {
352 ui.strong("Clips");
353 if study.clips.is_empty() {
354 ui.weak("None: + Clip suppresses a mate, hides or fades parts, moves the camera, cuts a section or plays an exploded view over a span.");
355 }
356 for clip in &study.clips {
357 let key = format!("simulation:clip:{}", clip.id);
358 ui.horizontal_wrapped(|ui| {
359 let row = ui.selectable_label(false, clip_label(clip)).on_hover_text("Open the clip's fields");
360 self.hits.insert(key.clone(), row.rect);
361 if row.clicked() {
362 *change = Some(Change::Open(Some(clip.id.clone())));
363 }
364 ui.weak(format!("{:.2}–{:.2} s", clip.from, clip.to));
365 let remove = ui.button("Remove");
366 self.hits.insert(format!("{key}:remove"), remove.rect);
367 if remove.clicked() {
368 *change = Some(Change::Remove { study: study.id.clone(), item: Some(clip.id.clone()) });
369 }
370 });
371 }
372 let mut kind = String::new();
373 let combo = egui::ComboBox::from_id_salt("simulation-add-clip").selected_text("+ Clip").show_ui(ui, |ui| {
374 for (id, label) in CLIP_KINDS {
375 let option = ui.selectable_value(&mut kind, id.to_string(), label);
376 self.hits.insert(format!("simulation:menuitem:clip_kind:{id}"), option.rect);
377 }
378 });
379 self.hits.insert("simulation:add_clip".into(), combo.response.rect);
380 if !kind.is_empty() {
381 *change = Some(Change::AddClip { study: study.id.clone(), kind });
382 }
383 }
384
385 fn item_form(&mut self, ui: &mut egui::Ui, study: &Study, open: &str, choices: &FormChoices, picking: Option<&RefSelectField>, change: &mut Option<Change>) {
390 let motor = study.motors.iter().find(|m| m.id == open);
391 let clip = study.clips.iter().find(|c| c.id == open);
392 let (title, subtitle, schema, mut params, hidden) = match (motor, clip) {
393 (Some(motor), _) => {
394 let params = forms::motor_params(motor);
395 let mate = choices.mates.iter().find(|m| m.id == motor.constraint).map_or_else(|| motor.constraint.clone(), |m| format!("{} ({})", m.id, m.kind));
396 (format!("Motor {}", motor.id), mate, forms::motor_schema(motor, choices), params.clone(), forms::motor_hidden(¶ms))
397 }
398 (None, Some(clip)) => {
399 let params = forms::clip_params(clip);
400 (format!("Clip {}", clip.id), forms::clip_title(clip).to_string(), forms::clip_schema(clip, choices), params.clone(), forms::clip_hidden(clip, ¶ms))
401 }
402 (None, None) => return,
403 };
404 let fields = form_fields_from_schema(&schema);
405 let hidden: HashSet<String> = hidden.into_iter().collect();
406 let spec = FormViewSpec {
407 title: &title,
408 subtitle: Some(subtitle.as_str()),
409 fields: &fields,
410 hidden: Some(&hidden),
411 banner: None,
412 trailing: None,
413 exit_label: "Return to list",
414 extra: None,
415 rollback: false,
416 hits_prefix: "simulation:item:",
417 read_only: None,
418 picking: picking.map(|f| crate::form::ActivePick { path: &f.path, names: &f.names }),
419 };
420 let before = params.clone();
421 let out = form_view(ui, &spec, &mut params, Some(&mut self.hits));
422 self.hover = out.hovered_entity;
423 if let Some(activate) = out.ref_activate {
424 *change = Some(Change::BeginRefSelect { clip: open.to_string(), path: activate.path, label: activate.label, filter: activate.filter, multiple: activate.multiple, seed: activate.seed });
425 }
426 if let Some(index) = out.pick_remove {
427 *change = Some(Change::RefSelectRemove(index));
428 }
429 if out.pick_done {
430 *change = Some(Change::FinishRefSelect);
431 }
432 if out.exit_clicked {
433 *change = Some(Change::Open(None));
434 }
435 if out.changed {
436 let field = params
438 .as_object()
439 .into_iter()
440 .flatten()
441 .find(|(key, value)| before.get(key.as_str()) != Some(value))
442 .map(|(key, _)| key.clone())
443 .unwrap_or_default();
444 let patch = match (motor, clip) {
445 (Some(_), _) => forms::motor_patch(¶ms, choices),
446 (None, Some(clip)) => forms::clip_patch(clip, ¶ms),
447 (None, None) => return,
448 };
449 match patch {
450 Ok(patch) => *change = Some(Change::Patch { study: study.id.clone(), item: Some(open.to_string()), patch, coalesce: Some(format!("simulation:{}:{open}:{field}", study.id)) }),
451 Err(error) => self.error = Some(error),
452 }
453 }
454 }
455
456 fn study_fields(&mut self, ui: &mut egui::Ui, study: &Study, change: &mut Option<Change>) {
458 let id = study.id.clone();
459 ui.horizontal_wrapped(|ui| {
460 let mut name = study.name.clone();
461 let field = ui.add(egui::TextEdit::singleline(&mut name).desired_width(140.0));
462 self.hits.insert("simulation:field:name".into(), field.rect);
463 if field.changed() && !name.trim().is_empty() {
464 *change = Some(patch(&id, None, "name", json!(name.trim()), "name"));
465 }
466 ui.label("Duration");
467 let mut duration = study.duration;
468 let field = ui.add(egui::DragValue::new(&mut duration).range(0.1..=MAX_DURATION).speed(0.1).suffix(" s"));
469 self.hits.insert("simulation:field:duration".into(), field.rect);
470 if field.changed() {
471 *change = Some(patch(&id, None, "duration", json!(duration), "duration"));
472 }
473 let mut fps = study.fps;
474 let field = ui.add(egui::DragValue::new(&mut fps).range(*FPS_RANGE.start()..=*FPS_RANGE.end()).suffix(" fps"));
475 self.hits.insert("simulation:field:fps".into(), field.rect);
476 if field.changed() {
477 *change = Some(patch(&id, None, "fps", json!(fps), "fps"));
478 }
479 let delete = ui.button("Delete study");
480 self.hits.insert("simulation:delete_study".into(), delete.rect);
481 if delete.clicked() {
482 *change = Some(Change::Remove { study: id.clone(), item: None });
483 }
484 });
485 }
486
487 fn transport(&mut self, ui: &mut egui::Ui, study: &Study, playback: &Value, change: &mut Option<Change>) {
489 let this_study = playback["study"].as_str() == Some(study.id.as_str());
490 let playing = this_study && playback["playing"] == true;
491 let looping = this_study && playback["looping"] == true;
492 let time = if this_study { playback["time"].as_f64().unwrap_or(0.0) } else { 0.0 };
493 ui.horizontal_wrapped(|ui| {
494 let play = ui.button(if playing { "Pause" } else { "▶ Play" });
495 self.hits.insert("simulation:play".into(), play.rect);
496 if play.clicked() {
497 *change = Some(if playing { Change::Pause } else { Change::Play { study: study.id.clone() } });
498 }
499 let stop = ui.add_enabled(this_study, egui::Button::new("■ Stop"));
500 self.hits.insert("simulation:stop".into(), stop.rect);
501 if stop.clicked() {
502 *change = Some(Change::Stop);
503 }
504 let loop_button = ui.selectable_label(looping, "Loop");
505 self.hits.insert("simulation:loop".into(), loop_button.rect);
506 if loop_button.clicked() {
507 *change = Some(Change::Loop { study: study.id.clone(), looping: !looping });
508 }
509 let publish = ui
510 .button(if study.motion.is_some() { "Publish again" } else { "Publish" })
511 .on_hover_text("Bake the study to the end and keep its motion with the assembly, for a manufacturing-instructions step to play");
512 self.hits.insert("simulation:publish".into(), publish.rect);
513 if publish.clicked() {
514 *change = Some(Change::Publish { study: study.id.clone() });
515 }
516 let frame = study.frame_at(time);
517 let readout = ui.monospace(format!("{time:.2} s frame {frame}/{}", study.frame_count().saturating_sub(1)));
518 self.hits.insert("simulation:time".into(), readout.rect);
519 if this_study {
520 let (baked, total) = (playback["baked"].as_f64().unwrap_or(0.0), playback["total"].as_f64().unwrap_or(1.0).max(1.0));
521 if baked < total {
522 let bar = ui.add(egui::ProgressBar::new((baked / total) as f32).desired_width(90.0).text(format!("baking {baked:.0}/{total:.0}")));
523 self.hits.insert("simulation:bake".into(), bar.rect);
524 }
525 if let Some(error) = playback["error"].as_str() {
526 let failed = playback["failed"].as_array().map_or(0, Vec::len);
527 let label = ui.colored_label(ui.visuals().warn_fg_color, format!("{failed} frame(s) the mates could not hold: {error}"));
528 self.hits.insert("simulation:failed".into(), label.rect);
529 }
530 }
531 let mut check = study.interference.enabled;
532 let box_ = ui.checkbox(&mut check, "Interference");
533 self.hits.insert("simulation:field:interference".into(), box_.rect);
534 if box_.changed() {
535 *change = Some(patch(&study.id, None, "interference", json!({ "enabled": check }), ""));
536 }
537 if this_study {
538 let clashing = playback["clashes"].as_array().map_or(0, Vec::len);
539 if clashing > 0 {
540 let shown: Vec<String> = playback["shownClashes"]
541 .as_array()
542 .into_iter()
543 .flatten()
544 .filter_map(|pair| Some(format!("{} ↔ {}", pair.get(0)?.as_str()?, pair.get(1)?.as_str()?)))
545 .collect();
546 let text = if shown.is_empty() {
547 format!("{clashing} frame(s) with interference")
548 } else {
549 format!("{clashing} frame(s) with interference; here: {}", shown.join(", "))
550 };
551 let label = ui.colored_label(ui.visuals().error_fg_color, text);
552 self.hits.insert("simulation:interference".into(), label.rect);
553 }
554 }
555 });
556 }
557
558 fn ruler(&mut self, ui: &mut egui::Ui, study: &Study, playback: &Value, change: &mut Option<Change>) {
564 let width = ui.available_width().max(120.0);
565 let (rect, response) = ui.allocate_exact_size(egui::vec2(width, RULER_HEIGHT), egui::Sense::click_and_drag());
566 self.hits.insert("simulation:ruler".into(), rect);
567 let painter = ui.painter_at(rect);
568 let visuals = ui.visuals();
569 let stroke = visuals.widgets.noninteractive.fg_stroke;
570 let x_of = |t: f64| rect.left() + rect.width() * (t / study.duration) as f32;
571 let this_study = playback["study"].as_str() == Some(study.id.as_str());
572 if this_study {
573 let baked = playback["baked"].as_u64().unwrap_or(0) as usize;
574 if baked > 0 {
575 let baked_to = study.frame_time(baked - 1);
576 painter.rect_filled(egui::Rect::from_min_max(rect.left_top(), egui::pos2(x_of(baked_to), rect.bottom())), 0.0, visuals.faint_bg_color);
577 }
578 for index in playback["clashes"].as_array().into_iter().flatten().filter_map(Value::as_u64) {
579 let x = x_of(study.frame_time(index as usize));
580 painter.line_segment([egui::pos2(x, rect.top() + 10.0), egui::pos2(x, rect.bottom())], egui::Stroke::new(2.0, visuals.error_fg_color));
581 }
582 for index in playback["failed"].as_array().into_iter().flatten().filter_map(Value::as_u64) {
583 let x = x_of(study.frame_time(index as usize));
584 painter.line_segment([egui::pos2(x, rect.top() + 4.0), egui::pos2(x, rect.bottom())], egui::Stroke::new(2.0, visuals.warn_fg_color));
585 }
586 }
587 painter.line_segment([rect.left_bottom(), rect.right_bottom()], stroke);
588 let step = if study.duration > 30.0 { 10.0 } else { 1.0 };
589 let mut t = 0.0;
590 while t <= study.duration + 1e-9 {
591 let x = x_of(t);
592 painter.line_segment([egui::pos2(x, rect.bottom() - 6.0), egui::pos2(x, rect.bottom())], stroke);
593 painter.text(egui::pos2(x + 2.0, rect.top()), egui::Align2::LEFT_TOP, format!("{t:.0}s"), egui::FontId::proportional(10.0), stroke.color);
594 t += step;
595 }
596 if this_study {
597 let x = x_of(playback["time"].as_f64().unwrap_or(0.0));
598 painter.line_segment([egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], egui::Stroke::new(2.0, visuals.selection.stroke.color));
599 }
600 if response.is_pointer_button_down_on() || response.dragged() || response.clicked() {
601 if let Some(pointer) = response.interact_pointer_pos() {
602 let u = ((pointer.x - rect.left()) / rect.width()).clamp(0.0, 1.0) as f64;
603 *change = Some(Change::Seek { study: study.id.clone(), time: u * study.duration });
604 }
605 }
606 ui.weak(format!("{} frames at {} fps", study.frame_count(), study.fps));
607 }
608
609 fn motors(&mut self, ui: &mut egui::Ui, study: &Study, mates: &[MotorMate], change: &mut Option<Change>) {
612 ui.strong("Motors");
613 if study.motors.is_empty() {
614 ui.weak("None: + Motor drives a distance or angle mate.");
615 }
616 for motor in &study.motors {
617 let key = format!("simulation:motor:{}", motor.id);
618 let mate = mates.iter().find(|m| m.id == motor.constraint);
619 let unit = mate.map_or("/s", |m| m.rate_unit());
620 let label = match (&motor.drive, mate) {
621 (Drive::Speed { speed }, Some(m)) => format!("{} ({}) at {speed} {unit}", m.id, m.kind),
622 (Drive::Speed { speed }, None) => format!("{} (missing) at {speed} {unit}", motor.constraint),
623 (Drive::Profile { start, end, .. }, Some(m)) => format!("{} ({}) {start} → {end} {}", m.id, m.kind, unit.trim_end_matches("/s")),
624 (Drive::Profile { start, end, .. }, None) => format!("{} (missing) {start} → {end}", motor.constraint),
625 };
626 ui.horizontal_wrapped(|ui| {
627 let row = ui.selectable_label(false, label).on_hover_text("Open the motor's fields");
628 self.hits.insert(key.clone(), row.rect);
629 if row.clicked() {
630 *change = Some(Change::Open(Some(motor.id.clone())));
631 }
632 ui.weak(format!("{:.2}–{:.2} s", motor.from, motor.to));
633 let remove = ui.button("Remove");
634 self.hits.insert(format!("{key}:remove"), remove.rect);
635 if remove.clicked() {
636 *change = Some(Change::Remove { study: study.id.clone(), item: Some(motor.id.clone()) });
637 }
638 });
639 }
640 let add = ui.add_enabled(!mates.is_empty(), egui::Button::new("+ Motor"));
641 self.hits.insert("simulation:add_motor".into(), add.rect);
642 if add.clicked() {
643 *change = Some(Change::AddMotor { study: study.id.clone(), mate: None });
644 }
645 }
646
647 fn apply(&mut self, change: Change, engine: &mut EngineState) {
648 self.notice = None;
649 self.error = None;
650 let result = match change {
651 Change::Activate(id) => engine.simulation_set_active(&id).map(|()| None),
652 Change::NewStudy => engine.simulation_add_study("").map(|id| Some(format!("New study {id}"))),
653 Change::AddMotor { study, mate } => {
654 let study = (!study.is_empty()).then_some(study);
655 engine.simulation_add_motor(study.as_deref(), mate.as_deref()).map(|(_, id)| {
656 self.open = Some(id.clone());
657 Some(format!("Motor {id} added"))
658 })
659 }
660 Change::Remove { study, item } => {
661 if item.is_some() && item == self.open && engine.ref_select_field().is_some_and(|f| f.target == RefSelectTarget::Simulation) {
663 engine.cancel_ref_select();
664 }
665 engine.simulation_remove(&study, item.as_deref()).map(|()| None)
666 }
667 Change::Patch { study, item, patch, coalesce } => engine.simulation_patch(&study, item.as_deref(), &patch, coalesce.as_deref()).map(|()| None),
668 Change::Play { study } => engine.simulation_play(Some(&study), None).map(|_| None),
669 Change::Pause => {
670 engine.simulation_pause();
671 Ok(None)
672 }
673 Change::Stop => {
674 engine.simulation_stop();
675 Ok(None)
676 }
677 Change::Loop { study, looping } => engine.simulation_set_looping(Some(&study), looping).map(|()| None),
678 Change::Seek { study, time } => engine.simulation_seek(Some(&study), time).map(|()| None),
679 Change::AddClip { study, kind } => engine.simulation_add_clip(Some(&study), &kind, None).map(|(_, id)| {
680 self.open = Some(id.clone());
681 Some(format!("Clip {id} added"))
682 }),
683 Change::Publish { study } => engine.simulation_publish(Some(&study)).map(|(_, frames)| Some(format!("Published: {frames} frames kept with the assembly"))),
684 Change::Open(item) => {
685 if item.is_none() && engine.ref_select_field().is_some_and(|f| f.target == RefSelectTarget::Simulation) {
687 engine.finish_ref_select();
688 }
689 self.open = item;
690 Ok(None)
691 }
692 Change::BeginRefSelect { clip, path, label, filter, multiple, seed } => {
693 if engine.ref_select_active() {
696 engine.finish_ref_select();
697 }
698 engine.begin_ref_select_for_simulation(&clip, path, label, filter, multiple, seed);
699 Ok(None)
700 }
701 Change::FinishRefSelect => {
702 engine.finish_ref_select();
703 Ok(None)
704 }
705 Change::RefSelectRemove(index) => {
706 engine.ref_select_remove(index);
707 Ok(None)
708 }
709 };
710 match result {
711 Ok(notice) => self.notice = notice,
712 Err(error) => self.error = Some(error),
713 }
714 }
715}
716
717fn clip_label(clip: &Clip) -> String {
719 match &clip.body {
720 ClipBody::Suppress { constraint } => format!("Suppress {constraint}"),
721 ClipBody::Camera { views } => format!("Camera ({} views)", views.len()),
722 ClipBody::Visibility { occurrences } => format!("Hide ({} parts)", occurrences.len()),
723 ClipBody::Transparency { occurrences, .. } => format!("Transparency ({} parts)", occurrences.len()),
724 ClipBody::Section { view, .. } => format!("Section {view}"),
725 ClipBody::Explode { collapse, .. } => if *collapse { "Collapse".to_string() } else { "Explode".to_string() },
726 }
727}
728
729fn patch(study: &str, item: Option<&str>, key: &str, value: Value, field: &str) -> Change {
731 let mut fields = serde_json::Map::new();
732 fields.insert(key.to_string(), value);
733 Change::Patch {
734 study: study.to_string(),
735 item: item.map(str::to_string),
736 patch: Value::Object(fields),
737 coalesce: (!field.is_empty()).then(|| format!("simulation:{study}:{field}")),
738 }
739}
740
741pub static HIT_KEYS: &[HitKeyDoc] = &[
742 HitKeyDoc { panel: "simulation", prefix: "panel:clip", meaning: "the visible region of the pane (scroll_into_view scrolls a simulation:* widget inside it)", command: None },
743 HitKeyDoc { panel: "simulation", prefix: "simulation:error", meaning: "why the last action was refused", command: None },
744 HitKeyDoc { panel: "simulation", prefix: "simulation:notice", meaning: "what the last action did", command: None },
745 HitKeyDoc { panel: "simulation", prefix: "simulation:study:", meaning: "a study's row, by study id: make it the active one", command: Some("simulation_update") },
746 HitKeyDoc { panel: "simulation", prefix: "simulation:new_study", meaning: "+ Study: a new study, made active", command: Some("simulation_new_study") },
747 HitKeyDoc { panel: "simulation", prefix: "simulation:delete_study", meaning: "delete the active study", command: Some("simulation_remove") },
748 HitKeyDoc { panel: "simulation", prefix: "simulation:field:", meaning: "the active study's name, duration and fps fields, and the Interference checkbox (check every frame for parts that collide)", command: Some("simulation_update") },
749 HitKeyDoc { panel: "simulation", prefix: "simulation:interference", meaning: "how many baked frames have interfering parts, and which pairs clash at the shown frame", command: Some("simulation_playback") },
750 HitKeyDoc { panel: "simulation", prefix: "simulation:play", meaning: "Play the active study from its playhead, or Pause while it plays", command: Some("simulation_play") },
751 HitKeyDoc { panel: "simulation", prefix: "simulation:stop", meaning: "Stop: every part back at its document pose, the playhead at 0", command: Some("simulation_stop") },
752 HitKeyDoc { panel: "simulation", prefix: "simulation:loop", meaning: "Loop: repeat the study at its end", command: Some("simulation_play") },
753 HitKeyDoc { panel: "simulation", prefix: "simulation:time", meaning: "the playhead's time and frame", command: Some("simulation_playback") },
754 HitKeyDoc { panel: "simulation", prefix: "simulation:bake", meaning: "the bake's progress bar, while frames are still being solved", command: Some("simulation_playback") },
755 HitKeyDoc { panel: "simulation", prefix: "simulation:failed", meaning: "how many frames the mates could not hold, and the last reason", command: Some("simulation_playback") },
756 HitKeyDoc { panel: "simulation", prefix: "simulation:ruler", meaning: "the active study's time ruler (red marks: frames with interference; amber: frames the mates could not hold): press or drag on it to scrub to a time", command: Some("simulation_seek") },
757 HitKeyDoc { panel: "simulation", prefix: "simulation:motor:", meaning: "a motor's row, by motor id: its label (the mate and the drive; click opens the motor's form) and remove", command: Some("simulation_update") },
758 HitKeyDoc { panel: "simulation", prefix: "simulation:add_motor", meaning: "+ Motor: a motor on the first distance or angle mate, the whole study long; its form opens", command: Some("simulation_add_motor") },
759 HitKeyDoc { panel: "simulation", prefix: "simulation:clip:", meaning: "a clip's row, by clip id: its label (click opens the clip's form) and remove", command: Some("simulation_update") },
760 HitKeyDoc { panel: "simulation", prefix: "simulation:add_clip", meaning: "+ Clip: the menu of clip kinds (suppress, visibility, transparency, camera, section, explode), each added over the whole study on its natural target; its form opens", command: Some("simulation_add_clip") },
761 HitKeyDoc { panel: "simulation", prefix: "simulation:menuitem:clip_kind:", meaning: "+ Clip's entries, by kind", command: Some("simulation_add_clip") },
762 HitKeyDoc { panel: "simulation", prefix: "simulation:item:", meaning: "the open motor's or clip's form (the app's schema-driven dialog): form:feature (the title), form:return (back to the rows), field:<key> (a motor: constraint, from, to, mode, speed, shape, start, end, accel; a clip: from, to, and by kind constraint | occurrences | opacityFrom, opacityTo | view1..view4 | view, sweepEnabled, sweepFrom, sweepTo | view, collapse), field:<key>#<variant> (an open option menu's entries), field:occurrences#activate (Select, or Done while the pick runs), #line<i> and #x<i> (a picked part and its remove)", command: Some("simulation_update") },
763 HitKeyDoc { panel: "simulation", prefix: "simulation:publish", meaning: "Publish (or Publish again): bake the study to the end and write its motion into the assembly's simulation block, for a manufacturing-instructions step to attach", command: Some("simulation_publish") },
764 HitKeyDoc { panel: "simulation", prefix: "simulation:export:", meaning: "the Export row: format (GIF | MP4 | HTML), width, height, fps, the file path and run (a video renders every baked frame at that size; HTML writes the interactive player page)", command: Some("simulation_export") },
765 HitKeyDoc { panel: "simulation", prefix: "simulation:menuitem:export_format:", meaning: "the export format menu's entries (gif, mp4, html)", command: Some("simulation_export") },
766];
767
768pub fn state_json(engine: &EngineState, panel: &SimulationPanel) -> String {
770 let mut state = engine.simulation_state_json();
771 state["playback"] = engine.simulation_playback_json();
772 state["pane"] = serde_json::from_str(&panel.state_json()).unwrap_or(Value::Null);
773 state.to_string()
774}
775