Skip to main content

brep_app/panels/
simulation.rs

1//! The Simulation pane — the Simulation workbench's
2//! ([`crate::workbench::simulation`]) study list over the assembly's
3//! `simulation` block, and the active study's timeline: its name, duration
4//! and frame rate, the transport (play, pause, stop, loop) over a time ruler
5//! that scrubs, and a row per motor and per clip. A row opens its item as
6//! the app's schema-driven form ([`crate::form_view`], the one dialog engine
7//! every feature, constraint and PMI form uses): standard number, option and
8//! checkbox fields from [`brep_simulation::forms`], and a clip's parts as a
9//! reference-selection field picked in the viewport (the engine's
10//! `simulation_refs` lane). Playback poses the displays only; the engine's
11//! `simulation_play` module bakes and shows the frames.
12//!
13//! The pane holds only transient widget state (its hit rects, an action a
14//! toolbar button handed it, the last notice). Every record it draws is read
15//! from the engine each frame, and every edit goes through the engine's
16//! simulation methods — the same ones the `simulation_*` automation commands
17//! call — so undo, save and reload come from the document. A field being
18//! typed in is one undo step per field.
19
20use 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
29/// The owner the form's hovered reference lines light entities under.
30const DIALOG_HOVER_OWNER: &str = "simulation";
31
32/// An action a toolbar button hands the pane.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum SimulationRequest {
35    NewStudy,
36    AddMotor,
37}
38
39/// The ruler's height, in points.
40const RULER_HEIGHT: f32 = 22.0;
41
42/// What the pane's Export button asks the app for (the app owns the
43/// viewport's render core, so it runs the export: `take_export`).
44#[derive(Debug, Clone, PartialEq)]
45pub struct ExportRequest {
46    pub study: String,
47    /// `gif` or `mp4`.
48    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    /// Whether the last frame ran in the Simulation workbench (`left_workbench`).
62    in_workbench: bool,
63    /// The Export row's settings (kept across frames; the path defaults to the
64    /// study's name when empty).
65    export_format: String,
66    export_width: u32,
67    export_height: u32,
68    export_fps: u32,
69    export_path: String,
70    /// An export the Export button asked for, until the app takes it.
71    export: Option<ExportRequest>,
72    /// The motor or clip whose form is open (its row was clicked, or it was
73    /// just added); `None` shows the rows.
74    open: Option<String>,
75    /// The entity a reference line of the form is hovered over this frame.
76    hover: Option<String>,
77}
78
79/// One engine edit the frame asks for, applied after drawing.
80enum 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    /// Bake to the end and write the study's motion into the block, for an
93    /// instructions step to attach.
94    Publish { study: String },
95    /// Open a motor's or clip's form (`None`: back to the rows).
96    Open(Option<String>),
97    /// A clip form's Parts field was activated: pick in the viewport.
98    BeginRefSelect { clip: String, path: Vec<String>, label: String, filter: Vec<String>, multiple: bool, seed: Vec<String> },
99    /// The focused reference field's Done: commit the running pick.
100    FinishRefSelect,
101    /// An `✕` on the focused reference field: drop the i-th running pick.
102    RefSelectRemove(usize),
103}
104
105/// The clip kinds `+ Clip` offers (the ones playback applies).
106const 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    /// Hand the pane an action; it performs it on the next frame it draws.
114    pub fn request(&mut self, request: SimulationRequest) {
115        self.request = Some(request);
116    }
117
118    /// The export the Export button asked for, if any (the app runs it and
119    /// reports back through `report`).
120    pub fn take_export(&mut self) -> Option<ExportRequest> {
121        self.export.take()
122    }
123
124    /// What an export (or any app-run action) came to: a notice or an error.
125    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    /// Told each frame whether the current workbench offers the Simulation
136    /// pane (Simulation, All); true on the frame that leaves it (the playback
137    /// is then stopped, so every posed part is back at its document pose).
138    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    /// Drive the study's playback for this frame. The playback belongs to the
145    /// Simulation pane: it ticks (bakes, advances the playhead, shows a frame,
146    /// tints a clash, drives a camera clip) only while the current workbench
147    /// offers the pane (`pane_shown`: Simulation or All, whether or not the
148    /// pane is open in the dock), and the frame that leaves it stops it, so the
149    /// displays and the camera are back as the document has them. Elsewhere
150    /// the bake is kept for the return, and nothing done there reaches it: an explode
151    /// step moved in the PMI workbench is a history revision, which a running
152    /// tick would answer by re-baking the study and showing its first frame
153    /// there — the clash tint and the camera clip over a PMI edit (owner
154    /// report 2026-10-26). Returns whether another frame is wanted.
155    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    /// Drop the published rects: the dock calls this for a pane it did not
167    /// draw this frame.
168    pub fn clear_hits(&mut self) {
169        self.hits.clear();
170    }
171
172    /// What automation reads beside the engine's `simulation_state_json`:
173    /// the last notice and error, and the motor or clip whose form is open.
174    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        // An item that is gone (removed, undone) closes its form.
248        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                // The form replaces the rows (Return brings them back); the
255                // transport and ruler above it keep scrubbing.
256                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        // This pane drew the pick: the shell finishes an in-form pick no form
271        // claims, so one never runs on with its field off screen.
272        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        // A hovered reference line lights the part it names in the 3D view.
278        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    /// Export: format, size, fps, path and the button. The app renders the
288    /// baked frames at that size through the viewport and writes the file.
289    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                // A default path follows the format's extension.
306                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    /// A row per clip — its label (click: its form), its span and Remove;
345    /// then + Clip.
346    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    /// The open motor's or clip's form: the schema-driven dialog every pane
381    /// shares, over the item's flat params ([`brep_simulation::forms`]). An
382    /// edit becomes the item's patch; a clip's Parts field runs the viewport
383    /// pick; Return goes back to the rows.
384    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(&params))
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, &params))
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            // A typing run in one field is one undo step; another field is another.
432            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(&params, choices),
441                (None, Some(clip)) => forms::clip_patch(clip, &params),
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    /// The study's name, duration and frame rate, and Delete study.
452    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    /// Play/Pause, Stop, Loop, the playhead's time and the bake's progress.
483    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    /// A time ruler over the study: a tick a second (or every 10 s on a
554    /// long study), labelled; the baked extent, the playhead, a red mark on
555    /// each frame where parts interfere and an amber one on each frame the
556    /// mates could not hold; a press or drag on it scrubs. Then the frame
557    /// count.
558    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    /// A row per motor — the mate it drives and its drive (click: its form),
605    /// its span and Remove; then + Motor.
606    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                // The open item's running pick goes with it.
657                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                // Leaving a clip's form commits the pick running in it.
681                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                // Focus moves from one selection box to the next: a pick
689                // already running commits first.
690                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
712/// A clip row's label: its kind and the span's reading of what it acts on.
713fn 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
724/// A one-field patch; a non-empty `field` coalesces a typing run into one undo step.
725fn 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
763/// `__brepSimulation`: the engine's simulation state and the pane's.
764pub 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