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", "Disable 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 choices = [("gif".to_string(), "gif", "GIF"), ("mp4".to_string(), "mp4", "MP4"), ("html".to_string(), "html", "HTML")];
303 crate::form::enum_dropdown(ui, "simulation:export:format", &choices, &mut format, None, Some(&mut self.hits));
304 if format != self.export_format {
305 if self.export_path.ends_with(&format!(".{}", self.export_format)) {
307 self.export_path = format!("{}.{format}", self.export_path.trim_end_matches(&format!(".{}", self.export_format)));
308 }
309 self.export_format = format;
310 }
311 for (label, value, key) in [("width", &mut self.export_width, "simulation:export:width"), ("height", &mut self.export_height, "simulation:export:height")] {
312 ui.label(label);
313 let drag = ui.add(egui::DragValue::new(value).range(16..=4096).speed(8));
314 self.hits.insert(key.into(), drag.rect);
315 }
316 ui.label("fps");
317 let drag = ui.add(egui::DragValue::new(&mut self.export_fps).range(1..=120));
318 self.hits.insert("simulation:export:fps".into(), drag.rect);
319 });
320 ui.horizontal_wrapped(|ui| {
321 if self.export_path.is_empty() {
322 self.export_path = format!("{}.{}", study.name.trim().replace(' ', "-"), self.export_format);
323 }
324 ui.label("file");
325 let field = ui.add(egui::TextEdit::singleline(&mut self.export_path).desired_width(220.0));
326 self.hits.insert("simulation:export:path".into(), field.rect);
327 let run = ui.button("Export");
328 self.hits.insert("simulation:export:run".into(), run.rect);
329 if run.clicked() {
330 self.export = Some(ExportRequest {
331 study: study.id.clone(),
332 format: self.export_format.clone(),
333 width: self.export_width,
334 height: self.export_height,
335 fps: self.export_fps,
336 path: self.export_path.clone(),
337 });
338 self.notice = Some("Exporting…".into());
339 self.error = None;
340 }
341 });
342 }
343
344 fn clips(&mut self, ui: &mut egui::Ui, study: &Study, change: &mut Option<Change>) {
347 ui.strong("Clips");
348 if study.clips.is_empty() {
349 ui.weak("None: + Clip disables a mate, hides or fades parts, moves the camera, cuts a section or plays an exploded view over a span.");
350 }
351 for clip in &study.clips {
352 let key = format!("simulation:clip:{}", clip.id);
353 ui.horizontal_wrapped(|ui| {
354 let row = ui.selectable_label(false, clip_label(clip)).on_hover_text("Open the clip's fields");
355 self.hits.insert(key.clone(), row.rect);
356 if row.clicked() {
357 *change = Some(Change::Open(Some(clip.id.clone())));
358 }
359 ui.weak(format!("{:.2}–{:.2} s", clip.from, clip.to));
360 let remove = ui.button("Remove");
361 self.hits.insert(format!("{key}:remove"), remove.rect);
362 if remove.clicked() {
363 *change = Some(Change::Remove { study: study.id.clone(), item: Some(clip.id.clone()) });
364 }
365 });
366 }
367 let mut kind = String::new();
368 let combo = egui::ComboBox::from_id_salt("simulation-add-clip").selected_text("+ Clip").show_ui(ui, |ui| {
369 for (id, label) in CLIP_KINDS {
370 let option = ui.selectable_value(&mut kind, id.to_string(), label);
371 self.hits.insert(format!("simulation:menuitem:clip_kind:{id}"), option.rect);
372 }
373 });
374 self.hits.insert("simulation:add_clip".into(), combo.response.rect);
375 if !kind.is_empty() {
376 *change = Some(Change::AddClip { study: study.id.clone(), kind });
377 }
378 }
379
380 fn item_form(&mut self, ui: &mut egui::Ui, study: &Study, open: &str, choices: &FormChoices, picking: Option<&RefSelectField>, change: &mut Option<Change>) {
385 let motor = study.motors.iter().find(|m| m.id == open);
386 let clip = study.clips.iter().find(|c| c.id == open);
387 let (title, subtitle, schema, mut params, hidden) = match (motor, clip) {
388 (Some(motor), _) => {
389 let params = forms::motor_params(motor);
390 let mate = choices.mates.iter().find(|m| m.id == motor.constraint).map_or_else(|| motor.constraint.clone(), |m| format!("{} ({})", m.id, m.kind));
391 (format!("Motor {}", motor.id), mate, forms::motor_schema(motor, choices), params.clone(), forms::motor_hidden(¶ms))
392 }
393 (None, Some(clip)) => {
394 let params = forms::clip_params(clip);
395 (format!("Clip {}", clip.id), forms::clip_title(clip).to_string(), forms::clip_schema(clip, choices), params.clone(), forms::clip_hidden(clip, ¶ms))
396 }
397 (None, None) => return,
398 };
399 let fields = form_fields_from_schema(&schema);
400 let hidden: HashSet<String> = hidden.into_iter().collect();
401 let spec = FormViewSpec {
402 title: &title,
403 subtitle: Some(subtitle.as_str()),
404 fields: &fields,
405 hidden: Some(&hidden),
406 banner: None,
407 trailing: None,
408 exit_label: "Return to list",
409 extra: None,
410 rollback: false,
411 hits_prefix: "simulation:item:",
412 read_only: None,
413 picking: picking.map(|f| crate::form::ActivePick { path: &f.path, names: &f.names }),
414 };
415 let before = params.clone();
416 let out = form_view(ui, &spec, &mut params, Some(&mut self.hits));
417 self.hover = out.hovered_entity;
418 if let Some(activate) = out.ref_activate {
419 *change = Some(Change::BeginRefSelect { clip: open.to_string(), path: activate.path, label: activate.label, filter: activate.filter, multiple: activate.multiple, seed: activate.seed });
420 }
421 if let Some(index) = out.pick_remove {
422 *change = Some(Change::RefSelectRemove(index));
423 }
424 if out.pick_done {
425 *change = Some(Change::FinishRefSelect);
426 }
427 if out.exit_clicked {
428 *change = Some(Change::Open(None));
429 }
430 if out.changed {
431 let field = params
433 .as_object()
434 .into_iter()
435 .flatten()
436 .find(|(key, value)| before.get(key.as_str()) != Some(value))
437 .map(|(key, _)| key.clone())
438 .unwrap_or_default();
439 let patch = match (motor, clip) {
440 (Some(_), _) => forms::motor_patch(¶ms, choices),
441 (None, Some(clip)) => forms::clip_patch(clip, ¶ms),
442 (None, None) => return,
443 };
444 match patch {
445 Ok(patch) => *change = Some(Change::Patch { study: study.id.clone(), item: Some(open.to_string()), patch, coalesce: Some(format!("simulation:{}:{open}:{field}", study.id)) }),
446 Err(error) => self.error = Some(error),
447 }
448 }
449 }
450
451 fn study_fields(&mut self, ui: &mut egui::Ui, study: &Study, change: &mut Option<Change>) {
453 let id = study.id.clone();
454 ui.horizontal_wrapped(|ui| {
455 let mut name = study.name.clone();
456 let field = ui.add(egui::TextEdit::singleline(&mut name).desired_width(140.0));
457 self.hits.insert("simulation:field:name".into(), field.rect);
458 if field.changed() && !name.trim().is_empty() {
459 *change = Some(patch(&id, None, "name", json!(name.trim()), "name"));
460 }
461 ui.label("Duration");
462 let mut duration = study.duration;
463 let field = ui.add(egui::DragValue::new(&mut duration).range(0.1..=MAX_DURATION).speed(0.1).suffix(" s"));
464 self.hits.insert("simulation:field:duration".into(), field.rect);
465 if field.changed() {
466 *change = Some(patch(&id, None, "duration", json!(duration), "duration"));
467 }
468 let mut fps = study.fps;
469 let field = ui.add(egui::DragValue::new(&mut fps).range(*FPS_RANGE.start()..=*FPS_RANGE.end()).suffix(" fps"));
470 self.hits.insert("simulation:field:fps".into(), field.rect);
471 if field.changed() {
472 *change = Some(patch(&id, None, "fps", json!(fps), "fps"));
473 }
474 let delete = ui.button("Delete study");
475 self.hits.insert("simulation:delete_study".into(), delete.rect);
476 if delete.clicked() {
477 *change = Some(Change::Remove { study: id.clone(), item: None });
478 }
479 });
480 }
481
482 fn transport(&mut self, ui: &mut egui::Ui, study: &Study, playback: &Value, change: &mut Option<Change>) {
484 let this_study = playback["study"].as_str() == Some(study.id.as_str());
485 let playing = this_study && playback["playing"] == true;
486 let looping = this_study && playback["looping"] == true;
487 let time = if this_study { playback["time"].as_f64().unwrap_or(0.0) } else { 0.0 };
488 ui.horizontal_wrapped(|ui| {
489 let play = ui.button(if playing { "Pause" } else { "▶ Play" });
490 self.hits.insert("simulation:play".into(), play.rect);
491 if play.clicked() {
492 *change = Some(if playing { Change::Pause } else { Change::Play { study: study.id.clone() } });
493 }
494 let stop = ui.add_enabled(this_study, egui::Button::new("■ Stop"));
495 self.hits.insert("simulation:stop".into(), stop.rect);
496 if stop.clicked() {
497 *change = Some(Change::Stop);
498 }
499 let loop_button = ui.selectable_label(looping, "Loop");
500 self.hits.insert("simulation:loop".into(), loop_button.rect);
501 if loop_button.clicked() {
502 *change = Some(Change::Loop { study: study.id.clone(), looping: !looping });
503 }
504 let publish = ui
505 .button(if study.motion.is_some() { "Publish again" } else { "Publish" })
506 .on_hover_text("Bake the study to the end and keep its motion with the assembly, for a manufacturing-instructions step to play");
507 self.hits.insert("simulation:publish".into(), publish.rect);
508 if publish.clicked() {
509 *change = Some(Change::Publish { study: study.id.clone() });
510 }
511 let frame = study.frame_at(time);
512 let readout = ui.monospace(format!("{time:.2} s frame {frame}/{}", study.frame_count().saturating_sub(1)));
513 self.hits.insert("simulation:time".into(), readout.rect);
514 if this_study {
515 let (baked, total) = (playback["baked"].as_f64().unwrap_or(0.0), playback["total"].as_f64().unwrap_or(1.0).max(1.0));
516 if baked < total {
517 let bar = ui.add(egui::ProgressBar::new((baked / total) as f32).desired_width(90.0).text(format!("baking {baked:.0}/{total:.0}")));
518 self.hits.insert("simulation:bake".into(), bar.rect);
519 }
520 if let Some(error) = playback["error"].as_str() {
521 let failed = playback["failed"].as_array().map_or(0, Vec::len);
522 let label = ui.colored_label(ui.visuals().warn_fg_color, format!("{failed} frame(s) the mates could not hold: {error}"));
523 self.hits.insert("simulation:failed".into(), label.rect);
524 }
525 }
526 let mut check = study.interference.enabled;
527 let box_ = ui.checkbox(&mut check, "Interference");
528 self.hits.insert("simulation:field:interference".into(), box_.rect);
529 if box_.changed() {
530 *change = Some(patch(&study.id, None, "interference", json!({ "enabled": check }), ""));
531 }
532 if this_study {
533 let clashing = playback["clashes"].as_array().map_or(0, Vec::len);
534 if clashing > 0 {
535 let shown: Vec<String> = playback["shownClashes"]
536 .as_array()
537 .into_iter()
538 .flatten()
539 .filter_map(|pair| Some(format!("{} ↔ {}", pair.get(0)?.as_str()?, pair.get(1)?.as_str()?)))
540 .collect();
541 let text = if shown.is_empty() {
542 format!("{clashing} frame(s) with interference")
543 } else {
544 format!("{clashing} frame(s) with interference; here: {}", shown.join(", "))
545 };
546 let label = ui.colored_label(ui.visuals().error_fg_color, text);
547 self.hits.insert("simulation:interference".into(), label.rect);
548 }
549 }
550 });
551 }
552
553 fn ruler(&mut self, ui: &mut egui::Ui, study: &Study, playback: &Value, change: &mut Option<Change>) {
559 let width = ui.available_width().max(120.0);
560 let (rect, response) = ui.allocate_exact_size(egui::vec2(width, RULER_HEIGHT), egui::Sense::click_and_drag());
561 self.hits.insert("simulation:ruler".into(), rect);
562 let painter = ui.painter_at(rect);
563 let visuals = ui.visuals();
564 let stroke = visuals.widgets.noninteractive.fg_stroke;
565 let x_of = |t: f64| rect.left() + rect.width() * (t / study.duration) as f32;
566 let this_study = playback["study"].as_str() == Some(study.id.as_str());
567 if this_study {
568 let baked = playback["baked"].as_u64().unwrap_or(0) as usize;
569 if baked > 0 {
570 let baked_to = study.frame_time(baked - 1);
571 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);
572 }
573 for index in playback["clashes"].as_array().into_iter().flatten().filter_map(Value::as_u64) {
574 let x = x_of(study.frame_time(index as usize));
575 painter.line_segment([egui::pos2(x, rect.top() + 10.0), egui::pos2(x, rect.bottom())], egui::Stroke::new(2.0, visuals.error_fg_color));
576 }
577 for index in playback["failed"].as_array().into_iter().flatten().filter_map(Value::as_u64) {
578 let x = x_of(study.frame_time(index as usize));
579 painter.line_segment([egui::pos2(x, rect.top() + 4.0), egui::pos2(x, rect.bottom())], egui::Stroke::new(2.0, visuals.warn_fg_color));
580 }
581 }
582 painter.line_segment([rect.left_bottom(), rect.right_bottom()], stroke);
583 let step = if study.duration > 30.0 { 10.0 } else { 1.0 };
584 let mut t = 0.0;
585 while t <= study.duration + 1e-9 {
586 let x = x_of(t);
587 painter.line_segment([egui::pos2(x, rect.bottom() - 6.0), egui::pos2(x, rect.bottom())], stroke);
588 painter.text(egui::pos2(x + 2.0, rect.top()), egui::Align2::LEFT_TOP, format!("{t:.0}s"), egui::FontId::proportional(10.0), stroke.color);
589 t += step;
590 }
591 if this_study {
592 let x = x_of(playback["time"].as_f64().unwrap_or(0.0));
593 painter.line_segment([egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], egui::Stroke::new(2.0, visuals.selection.stroke.color));
594 }
595 if response.is_pointer_button_down_on() || response.dragged() || response.clicked() {
596 if let Some(pointer) = response.interact_pointer_pos() {
597 let u = ((pointer.x - rect.left()) / rect.width()).clamp(0.0, 1.0) as f64;
598 *change = Some(Change::Seek { study: study.id.clone(), time: u * study.duration });
599 }
600 }
601 ui.weak(format!("{} frames at {} fps", study.frame_count(), study.fps));
602 }
603
604 fn motors(&mut self, ui: &mut egui::Ui, study: &Study, mates: &[MotorMate], change: &mut Option<Change>) {
607 ui.strong("Motors");
608 if study.motors.is_empty() {
609 ui.weak("None: + Motor drives a distance or angle mate.");
610 }
611 for motor in &study.motors {
612 let key = format!("simulation:motor:{}", motor.id);
613 let mate = mates.iter().find(|m| m.id == motor.constraint);
614 let unit = mate.map_or("/s", |m| m.rate_unit());
615 let label = match (&motor.drive, mate) {
616 (Drive::Speed { speed }, Some(m)) => format!("{} ({}) at {speed} {unit}", m.id, m.kind),
617 (Drive::Speed { speed }, None) => format!("{} (missing) at {speed} {unit}", motor.constraint),
618 (Drive::Profile { start, end, .. }, Some(m)) => format!("{} ({}) {start} → {end} {}", m.id, m.kind, unit.trim_end_matches("/s")),
619 (Drive::Profile { start, end, .. }, None) => format!("{} (missing) {start} → {end}", motor.constraint),
620 };
621 ui.horizontal_wrapped(|ui| {
622 let row = ui.selectable_label(false, label).on_hover_text("Open the motor's fields");
623 self.hits.insert(key.clone(), row.rect);
624 if row.clicked() {
625 *change = Some(Change::Open(Some(motor.id.clone())));
626 }
627 ui.weak(format!("{:.2}–{:.2} s", motor.from, motor.to));
628 let remove = ui.button("Remove");
629 self.hits.insert(format!("{key}:remove"), remove.rect);
630 if remove.clicked() {
631 *change = Some(Change::Remove { study: study.id.clone(), item: Some(motor.id.clone()) });
632 }
633 });
634 }
635 let add = ui.add_enabled(!mates.is_empty(), egui::Button::new("+ Motor"));
636 self.hits.insert("simulation:add_motor".into(), add.rect);
637 if add.clicked() {
638 *change = Some(Change::AddMotor { study: study.id.clone(), mate: None });
639 }
640 }
641
642 fn apply(&mut self, change: Change, engine: &mut EngineState) {
643 self.notice = None;
644 self.error = None;
645 let result = match change {
646 Change::Activate(id) => engine.simulation_set_active(&id).map(|()| None),
647 Change::NewStudy => engine.simulation_add_study("").map(|id| Some(format!("New study {id}"))),
648 Change::AddMotor { study, mate } => {
649 let study = (!study.is_empty()).then_some(study);
650 engine.simulation_add_motor(study.as_deref(), mate.as_deref()).map(|(_, id)| {
651 self.open = Some(id.clone());
652 Some(format!("Motor {id} added"))
653 })
654 }
655 Change::Remove { study, item } => {
656 if item.is_some() && item == self.open && engine.ref_select_field().is_some_and(|f| f.target == RefSelectTarget::Simulation) {
658 engine.cancel_ref_select();
659 }
660 engine.simulation_remove(&study, item.as_deref()).map(|()| None)
661 }
662 Change::Patch { study, item, patch, coalesce } => engine.simulation_patch(&study, item.as_deref(), &patch, coalesce.as_deref()).map(|()| None),
663 Change::Play { study } => engine.simulation_play(Some(&study), None).map(|_| None),
664 Change::Pause => {
665 engine.simulation_pause();
666 Ok(None)
667 }
668 Change::Stop => {
669 engine.simulation_stop();
670 Ok(None)
671 }
672 Change::Loop { study, looping } => engine.simulation_set_looping(Some(&study), looping).map(|()| None),
673 Change::Seek { study, time } => engine.simulation_seek(Some(&study), time).map(|()| None),
674 Change::AddClip { study, kind } => engine.simulation_add_clip(Some(&study), &kind, None).map(|(_, id)| {
675 self.open = Some(id.clone());
676 Some(format!("Clip {id} added"))
677 }),
678 Change::Publish { study } => engine.simulation_publish(Some(&study)).map(|(_, frames)| Some(format!("Published: {frames} frames kept with the assembly"))),
679 Change::Open(item) => {
680 if item.is_none() && engine.ref_select_field().is_some_and(|f| f.target == RefSelectTarget::Simulation) {
682 engine.finish_ref_select();
683 }
684 self.open = item;
685 Ok(None)
686 }
687 Change::BeginRefSelect { clip, path, label, filter, multiple, seed } => {
688 if engine.ref_select_active() {
691 engine.finish_ref_select();
692 }
693 engine.begin_ref_select_for_simulation(&clip, path, label, filter, multiple, seed);
694 Ok(None)
695 }
696 Change::FinishRefSelect => {
697 engine.finish_ref_select();
698 Ok(None)
699 }
700 Change::RefSelectRemove(index) => {
701 engine.ref_select_remove(index);
702 Ok(None)
703 }
704 };
705 match result {
706 Ok(notice) => self.notice = notice,
707 Err(error) => self.error = Some(error),
708 }
709 }
710}
711
712fn clip_label(clip: &Clip) -> String {
714 match &clip.body {
715 ClipBody::Suppress { constraint } => format!("Disable {constraint}"),
716 ClipBody::Camera { views } => format!("Camera ({})", crate::panels::count(views.len(), "view", "views")),
717 ClipBody::Visibility { occurrences } => format!("Hide ({})", crate::panels::count(occurrences.len(), "part", "parts")),
718 ClipBody::Transparency { occurrences, .. } => format!("Transparency ({})", crate::panels::count(occurrences.len(), "part", "parts")),
719 ClipBody::Section { view, .. } => format!("Section {view}"),
720 ClipBody::Explode { collapse, .. } => if *collapse { "Collapse".to_string() } else { "Explode".to_string() },
721 }
722}
723
724fn patch(study: &str, item: Option<&str>, key: &str, value: Value, field: &str) -> Change {
726 let mut fields = serde_json::Map::new();
727 fields.insert(key.to_string(), value);
728 Change::Patch {
729 study: study.to_string(),
730 item: item.map(str::to_string),
731 patch: Value::Object(fields),
732 coalesce: (!field.is_empty()).then(|| format!("simulation:{study}:{field}")),
733 }
734}
735
736pub static HIT_KEYS: &[HitKeyDoc] = &[
737 HitKeyDoc { panel: "simulation", prefix: "panel:clip", meaning: "the visible region of the pane (scroll_into_view scrolls a simulation:* widget inside it)", command: None },
738 HitKeyDoc { panel: "simulation", prefix: "simulation:error", meaning: "why the last action was refused", command: None },
739 HitKeyDoc { panel: "simulation", prefix: "simulation:notice", meaning: "what the last action did", command: None },
740 HitKeyDoc { panel: "simulation", prefix: "simulation:study:", meaning: "a study's row, by study id: make it the active one", command: Some("simulation_update") },
741 HitKeyDoc { panel: "simulation", prefix: "simulation:new_study", meaning: "+ Study: a new study, made active", command: Some("simulation_new_study") },
742 HitKeyDoc { panel: "simulation", prefix: "simulation:delete_study", meaning: "delete the active study", command: Some("simulation_remove") },
743 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") },
744 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") },
745 HitKeyDoc { panel: "simulation", prefix: "simulation:play", meaning: "Play the active study from its playhead, or Pause while it plays", command: Some("simulation_play") },
746 HitKeyDoc { panel: "simulation", prefix: "simulation:stop", meaning: "Stop: every part back at its document pose, the playhead at 0", command: Some("simulation_stop") },
747 HitKeyDoc { panel: "simulation", prefix: "simulation:loop", meaning: "Loop: repeat the study at its end", command: Some("simulation_play") },
748 HitKeyDoc { panel: "simulation", prefix: "simulation:time", meaning: "the playhead's time and frame", command: Some("simulation_playback") },
749 HitKeyDoc { panel: "simulation", prefix: "simulation:bake", meaning: "the bake's progress bar, while frames are still being solved", command: Some("simulation_playback") },
750 HitKeyDoc { panel: "simulation", prefix: "simulation:failed", meaning: "how many frames the mates could not hold, and the last reason", command: Some("simulation_playback") },
751 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") },
752 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") },
753 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") },
754 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") },
755 HitKeyDoc { panel: "simulation", prefix: "simulation:add_clip", meaning: "+ Clip: the menu of clip kinds (suppress = Disable a mate, visibility, transparency, camera, section, explode), each added over the whole study on its natural target; its form opens", command: Some("simulation_add_clip") },
756 HitKeyDoc { panel: "simulation", prefix: "simulation:menuitem:clip_kind:", meaning: "+ Clip's entries, by kind", command: Some("simulation_add_clip") },
757 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") },
758 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") },
759 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") },
760 HitKeyDoc { panel: "simulation", prefix: "simulation:export:format#", meaning: "the export format dropdown's entries while open (#gif, #mp4, #html)", command: Some("simulation_export") },
761];
762
763pub fn state_json(engine: &EngineState, panel: &SimulationPanel) -> String {
765 let mut state = engine.simulation_state_json();
766 state["playback"] = engine.simulation_playback_json();
767 state["pane"] = serde_json::from_str(&panel.state_json()).unwrap_or(Value::Null);
768 state.to_string()
769}
770