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", "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    /// 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 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                // A default path follows the format's extension.
311                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    /// A row per clip — its label (click: its form), its span and Remove;
350    /// then + Clip.
351    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    /// The open motor's or clip's form: the schema-driven dialog every pane
386    /// shares, over the item's flat params ([`brep_simulation::forms`]). An
387    /// edit becomes the item's patch; a clip's Parts field runs the viewport
388    /// pick; Return goes back to the rows.
389    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(&params))
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, &params))
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            // A typing run in one field is one undo step; another field is another.
437            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(&params, choices),
446                (None, Some(clip)) => forms::clip_patch(clip, &params),
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    /// The study's name, duration and frame rate, and Delete study.
457    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    /// Play/Pause, Stop, Loop, the playhead's time and the bake's progress.
488    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    /// A time ruler over the study: a tick a second (or every 10 s on a
559    /// long study), labelled; the baked extent, the playhead, a red mark on
560    /// each frame where parts interfere and an amber one on each frame the
561    /// mates could not hold; a press or drag on it scrubs. Then the frame
562    /// count.
563    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    /// A row per motor — the mate it drives and its drive (click: its form),
610    /// its span and Remove; then + Motor.
611    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                // The open item's running pick goes with it.
662                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                // Leaving a clip's form commits the pick running in it.
686                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                // Focus moves from one selection box to the next: a pick
694                // already running commits first.
695                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
717/// A clip row's label: its kind and the span's reading of what it acts on.
718fn 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
729/// A one-field patch; a non-empty `field` coalesces a typing run into one undo step.
730fn 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
768/// `__brepSimulation`: the engine's simulation state and the pane's.
769pub 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