Skip to main content

brep_render/engine_state/
simulation_play.rs

1//! Motion-study playback: bake, play, scrub.
2//!
3//! A study's frames are solved ONCE, in order, through the kernel's frame
4//! solve (`assembly_frame_solve_impl`: the mates at the motors' values and the
5//! suppress clips' switches, warm-started from the previous frame), and kept
6//! as poses — [`BakedFrame`]s — until the document changes. Playback,
7//! scrubbing and (later) every output read the same frames, so they cannot
8//! disagree. The solve is sequential (scrubbing backwards would otherwise mean
9//! re-solving from 0), so it is time-sliced under a per-tick budget inside the
10//! app's frame ([`EngineState::simulation_tick`]) rather than run at once, and
11//! the pane shows its progress; the playhead never runs ahead of the bake.
12//!
13//! Displays are posed by matrix over the geometry the renderer already holds
14//! (`SolidDisplay::pose`, as an exploded view's are), re-posing only the
15//! occurrences whose pose changed since the shown frame, and
16//! [`EngineState::simulation_stop`] puts every display back exactly. Nothing
17//! is written to the document. A frame the mates cannot hold keeps the
18//! previous frame's poses and is marked failed; it never stops the bake.
19//!
20//! Interference is checked on every baked frame (`Study::interference`): the
21//! occurrences' display meshes, posed by the frame, are screened pair by pair
22//! for an edge of one piercing a face of the other (`interference::
23//! displays_pierce`: face-to-face contact is not a clash, nor are coincident
24//! surfaces). A clashing frame is marked, its parts are tinted while shown,
25//! and playback keeps going — there is no stop at contact.
26//!
27//! The timeline's clips are read from the same study sample: suppress clips
28//! go into the bake (a mate off for the frame), and at show time a frame's
29//! sample hides the occurrences of its visibility clips, folds an explode
30//! clip's step translations (from the exploded view's record) into the
31//! occurrences' poses, drives the camera between two PMI views' cameras for
32//! a camera clip — the camera as it stood before the first such frame is
33//! kept and given back with the displays — sets the displays' opacity for a
34//! transparency clip, and cuts the posed displays on a PMI view's section
35//! planes (swept along their normals) for a section clip. No PMI view is
36//! ever activated by playback: one posing path, one set of originals, one
37//! exact restore.
38
39use super::interference::{displays_pierce, overlaps, posed_bbox};
40use super::pmi_section::{clip_display_all, side_of_all, Side};
41use super::{component_move::now_ms, EngineState};
42use crate::camera::Aabb;
43use crate::scene::SolidDisplay;
44use brep_kernel::{PmiCamera, PmiProjection};
45use brep_simulation::model::Study;
46use brep_simulation::sample::SectionSample;
47use brep_simulation::FrameSample;
48use serde_json::{json, Map, Value};
49use std::collections::{BTreeMap, HashMap};
50
51/// Solving per app tick: a 121-frame study of a small mechanism bakes in a
52/// few ticks while the UI keeps drawing.
53const BAKE_BUDGET_MS: f64 = 12.0;
54
55/// One baked frame: every movable occurrence's world pose (row-major 4×4, the
56/// kernel's spelling), and whether the mates held it.
57#[derive(Debug, Clone, PartialEq)]
58pub struct BakedFrame {
59    pub poses: BTreeMap<String, [f64; 16]>,
60    /// The mates could not hold this frame; the previous frame's poses stand in.
61    pub failed: bool,
62    /// The occurrence pairs that interfere at this frame (each `a < b`), by
63    /// the participant ids of the interference check; empty when the study
64    /// does not check or nothing clashes.
65    pub clashes: Vec<(String, String)>,
66}
67
68/// The row-major identity pose.
69pub(super) const IDENTITY: [f64; 16] = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0];
70
71/// The tint of a clashing part's displays while its frame is shown.
72const CLASH_TINT: [f32; 3] = [0.92, 0.25, 0.2];
73
74/// The study being played or scrubbed, its bake so far and what the displays
75/// show of it.
76#[derive(Debug)]
77pub struct SimulationPlayback {
78    pub study: String,
79    /// The study as baked, and the document revision it was baked against;
80    /// either moving starts the bake over.
81    baked_study: Study,
82    revision: u64,
83    /// The mates' document values, what a speed motor starts from.
84    base: HashMap<String, f64>,
85    pub frames: Vec<BakedFrame>,
86    total: usize,
87    /// The previous frame's reply poses: the next frame's warm start.
88    reference: Map<String, Value>,
89    /// The playhead, seconds.
90    pub time: f64,
91    pub playing: bool,
92    pub looping: bool,
93    /// The frame the displays show.
94    shown: Option<usize>,
95    /// The frame asked for (by a seek or the playhead); shown once it is baked.
96    wanted: Option<usize>,
97    /// Displays as they were before their first pose, by solid name.
98    originals: HashMap<String, SolidDisplay>,
99    /// Occurrence → the world pose its displays show.
100    applied: HashMap<String, [f64; 16]>,
101    /// The last frame the mates could not hold, and why.
102    pub error: Option<String>,
103    /// The interference check's participants: occurrence → its member solids
104    /// (`interference_participants`, taken at prepare).
105    participants: Vec<(String, Vec<String>)>,
106    /// The solids tinted for the shown frame's clashes.
107    tinted: Vec<String>,
108    /// The solids a visibility clip hides at the shown frame.
109    hidden: Vec<String>,
110    /// The solids a transparency clip made see-through at the shown frame.
111    translucent: Vec<String>,
112    /// The solids a section clip cut or hid at the shown frame; put back
113    /// (and re-posed) before the next frame is posed.
114    sectioned: Vec<String>,
115    /// The camera as it stood before a camera clip first drove it; given
116    /// back with the displays.
117    camera_before: Option<PmiCamera>,
118    /// The shown frame's clip sample (what automation reads).
119    shown_sample: Option<FrameSample>,
120}
121
122impl SimulationPlayback {
123    /// The study as baked.
124    pub(crate) fn baked_study(&self) -> &Study {
125        &self.baked_study
126    }
127
128    /// The document revision the bake was made against.
129    pub(crate) fn revision(&self) -> u64 {
130        self.revision
131    }
132
133    /// The mates' document values (a speed motor's start).
134    pub(crate) fn base(&self) -> &HashMap<String, f64> {
135        &self.base
136    }
137
138    /// The display as the document poses it, when playback has recorded it.
139    pub(crate) fn original(&self, name: &str) -> Option<&SolidDisplay> {
140        self.originals.get(name)
141    }
142}
143
144impl EngineState {
145    /// The playback, if a study has been played or scrubbed.
146    pub(crate) fn simulation_playback(&self) -> Option<&SimulationPlayback> {
147        self.simulation_play.as_ref()
148    }
149
150    /// Bake `study` (the active one when `None`) to the end, now: the whole
151    /// bake in one call, as an export or a published motion needs it. Pauses
152    /// playback; the playhead is kept. Returns the study id.
153    pub fn simulation_bake_all(&mut self, study: Option<&str>) -> Result<String, String> {
154        let id = self.simulation_prepare(study)?;
155        if let Some(play) = self.simulation_play.as_mut() {
156            play.playing = false;
157        }
158        self.ensure_assembly_session();
159        let mut guard = 0usize;
160        while self.simulation_play.as_ref().is_some_and(|play| play.frames.len() < play.total) {
161            guard += 1;
162            if guard > 1_000_000 {
163                return Err("the bake did not finish".into());
164            }
165            self.simulation_bake_next();
166        }
167        Ok(id)
168    }
169
170    /// A publish wrote `motion` into `study`'s block entry: the bake is of the
171    /// same study still, so it is kept — its record takes the motion and the
172    /// revision the write made, which `simulation_prepare` compares.
173    pub(super) fn simulation_keep_bake_through_publish(&mut self, study: &str, motion: Value) {
174        let revision = self.history.revision();
175        if let Some(play) = self.simulation_play.as_mut().filter(|play| play.study == study) {
176            play.baked_study.motion = Some(motion);
177            play.revision = revision;
178        }
179    }
180
181    /// Play `study` (the active one when `None`) from where its playhead
182    /// stands — from 0 when it stands at the end — baking it first if need
183    /// be; `looping` sets whether it repeats. Returns the study id.
184    pub fn simulation_play(&mut self, study: Option<&str>, looping: Option<bool>) -> Result<String, String> {
185        let id = self.simulation_prepare(study)?;
186        let play = self.simulation_play.as_mut().expect("prepared");
187        if let Some(looping) = looping {
188            play.looping = looping;
189        }
190        if play.time >= play.baked_study.duration - 1e-9 {
191            play.time = 0.0;
192        }
193        play.playing = true;
194        play.wanted = Some(play.baked_study.frame_at(play.time));
195        Ok(id)
196    }
197
198    /// Hold the playhead where it is; the frame stays shown.
199    pub fn simulation_pause(&mut self) {
200        if let Some(play) = self.simulation_play.as_mut() {
201            play.playing = false;
202        }
203    }
204
205    /// Every display back at its document pose, the playhead at 0. The bake
206    /// is kept, so Play starts again at once.
207    pub fn simulation_stop(&mut self) {
208        self.simulation_restore_displays();
209        if let Some(play) = self.simulation_play.as_mut() {
210            play.playing = false;
211            play.time = 0.0;
212            play.wanted = None;
213        }
214    }
215
216    /// Put the playhead at `time` (seconds, clamped to the study) and show
217    /// that frame, pausing; a frame not baked yet is shown when it is.
218    pub fn simulation_seek(&mut self, study: Option<&str>, time: f64) -> Result<(), String> {
219        if !time.is_finite() {
220            return Err("seek: the time is not a number".into());
221        }
222        self.simulation_prepare(study)?;
223        let play = self.simulation_play.as_mut().expect("prepared");
224        play.playing = false;
225        play.time = time.clamp(0.0, play.baked_study.duration);
226        play.wanted = Some(play.baked_study.frame_at(play.time));
227        self.simulation_show_wanted();
228        Ok(())
229    }
230
231    /// Whether playback repeats at the study's end.
232    pub fn simulation_set_looping(&mut self, study: Option<&str>, looping: bool) -> Result<(), String> {
233        self.simulation_prepare(study)?;
234        self.simulation_play.as_mut().expect("prepared").looping = looping;
235        Ok(())
236    }
237
238    /// Whether a study is being played or scrubbed (its displays may be posed).
239    pub fn simulation_playing_or_shown(&self) -> bool {
240        self.simulation_play.as_ref().is_some_and(|play| play.playing || play.shown.is_some())
241    }
242
243    /// Make `study` (the active one when `None`) the playback, keeping an
244    /// up-to-date bake of the same study and starting over otherwise.
245    fn simulation_prepare(&mut self, study: Option<&str>) -> Result<String, String> {
246        let block = self.simulation()?.ok_or("no study yet: New study starts one")?;
247        let study = match study {
248            Some(id) => block.study(id).ok_or_else(|| format!("no study '{id}'"))?,
249            None => block.active_study().ok_or("no study yet: New study starts one")?,
250        }
251        .clone();
252        let revision = self.history.revision();
253        let current = self
254            .simulation_play
255            .as_ref()
256            .is_some_and(|play| play.study == study.id && play.revision == revision && play.baked_study == study);
257        if current {
258            return Ok(study.id);
259        }
260        // Another study's (or a stale bake's) poses go, and an exploded view
261        // is put away, so every display sits at its document pose: what the
262        // frames' matrices are relative to.
263        self.simulation_restore_displays();
264        if self.pmi_active_view.is_some() {
265            self.pmi_deactivate_view();
266        }
267        let previous = self.simulation_play.take();
268        let (time, looping) = previous
269            .filter(|play| play.study == study.id)
270            .map_or((0.0, false), |play| (play.time.min(study.duration), play.looping));
271        let base = self.simulation_motor_mates().into_iter().map(|mate| (mate.id, mate.value)).collect();
272        let participants = if study.interference.enabled { self.interference_participants() } else { Vec::new() };
273        let total = study.frame_count();
274        let id = study.id.clone();
275        self.simulation_play = Some(SimulationPlayback {
276            study: id.clone(),
277            baked_study: study,
278            revision,
279            base,
280            frames: Vec::with_capacity(total),
281            total,
282            reference: Map::new(),
283            time,
284            playing: false,
285            looping,
286            shown: None,
287            wanted: None,
288            originals: HashMap::new(),
289            applied: HashMap::new(),
290            error: None,
291            participants,
292            tinted: Vec::new(),
293            hidden: Vec::new(),
294            translucent: Vec::new(),
295            sectioned: Vec::new(),
296            camera_before: None,
297            shown_sample: None,
298        });
299        Ok(id)
300    }
301
302    /// Advance by `dt` seconds: bake frames under the budget, move the
303    /// playhead and show its frame. `true` while another frame is needed
304    /// (playing, or still baking). The app calls it once per frame.
305    pub fn simulation_tick(&mut self, dt: f64) -> bool {
306        let Some(play) = self.simulation_play.as_ref() else { return false };
307        // The document moved under the bake: start over, keeping the playhead.
308        if play.revision != self.history.revision() {
309            let (study, time, playing, looping) = (play.study.clone(), play.time, play.playing, play.looping);
310            self.simulation_restore_displays();
311            self.simulation_play = None;
312            if self.simulation_prepare(Some(&study)).is_err() {
313                return false;
314            }
315            let play = self.simulation_play.as_mut().expect("prepared");
316            play.time = time.min(play.baked_study.duration);
317            play.playing = playing;
318            play.looping = looping;
319            play.wanted = Some(play.baked_study.frame_at(play.time));
320        }
321        let start = now_ms();
322        if self.simulation_play.as_ref().is_some_and(|play| play.frames.len() < play.total) {
323            // The frame solve runs against the MAIN-side kernel session, which
324            // the history runner's thread owns after a run: bring it over (a
325            // no-op once it is current), as a live drag does before its solve.
326            self.ensure_assembly_session();
327        }
328        while self.simulation_play.as_ref().is_some_and(|play| play.frames.len() < play.total) && now_ms() - start < BAKE_BUDGET_MS {
329            self.simulation_bake_next();
330        }
331        let play = self.simulation_play.as_mut().expect("checked above");
332        if play.playing {
333            let duration = play.baked_study.duration;
334            play.time += dt.max(0.0);
335            if play.time >= duration - 1e-9 {
336                if play.looping {
337                    play.time = (play.time % duration).max(0.0);
338                } else {
339                    play.time = duration;
340                    play.playing = false;
341                }
342            }
343            // The playhead waits for the bake rather than running ahead of it.
344            let baked = play.frames.len();
345            if play.baked_study.frame_at(play.time) >= baked {
346                play.time = play.baked_study.frame_time(baked.saturating_sub(1));
347                if baked == 0 {
348                    play.time = 0.0;
349                }
350            }
351            play.wanted = Some(play.baked_study.frame_at(play.time));
352        }
353        self.simulation_show_wanted();
354        let play = self.simulation_play.as_ref().expect("checked above");
355        play.playing || play.frames.len() < play.total
356    }
357
358    /// Solve the next unbaked frame against the session (one step of the
359    /// tick's bake loop; the tests step it by hand).
360    pub(super) fn simulation_bake_next(&mut self) {
361        let Some(play) = self.simulation_play.as_mut() else { return };
362        let index = play.frames.len();
363        if index >= play.total {
364            return;
365        }
366        let time = play.baked_study.frame_time(index);
367        let base = &play.base;
368        let sample = play.baked_study.sample(time, &|id| base.get(id).copied());
369        let mut overrides = Map::new();
370        for (id, mate) in &sample.mates {
371            let mut change = Map::new();
372            if let Some(value) = mate.value {
373                change.insert("value".into(), json!(value));
374            }
375            if let Some(enabled) = mate.enabled {
376                change.insert("enabled".into(), json!(enabled));
377            }
378            overrides.insert(id.clone(), Value::Object(change));
379        }
380        let request = json!({ "overrides": overrides, "reference": play.reference });
381        let (poses, failed): (BTreeMap<String, [f64; 16]>, bool) = match brep_kernel::assembly_frame_solve_impl(&request.to_string()) {
382            Ok(reply) => {
383                let reply: Value = serde_json::from_str(&reply).unwrap_or(Value::Null);
384                let poses_json = reply.get("poses").and_then(Value::as_object).cloned().unwrap_or_default();
385                let poses = poses_json.iter().map(|(id, pose)| (id.clone(), crate::pipeline::pose_params_to_transform(pose))).collect();
386                play.reference = poses_json;
387                (poses, false)
388            }
389            Err(error) => {
390                let poses = play.frames.last().map(|frame| frame.poses.clone()).unwrap_or_default();
391                play.error = Some(format!("frame {index} ({time:.3} s): {error}"));
392                (poses, true)
393            }
394        };
395        // The interference screen reads the scene, so the playback borrow ends
396        // here; its participants are lent out and put back.
397        let participants = std::mem::take(&mut play.participants);
398        let previous = play.frames.last().cloned();
399        let clashes = if participants.is_empty() { Vec::new() } else { self.simulation_frame_clashes(&participants, &poses, previous.as_ref()) };
400        let Some(play) = self.simulation_play.as_mut() else { return };
401        play.participants = participants;
402        play.frames.push(BakedFrame { poses, failed, clashes });
403    }
404
405    /// The occurrence pairs whose posed displays interfere at a frame: each
406    /// occurrence's displays are carried by its pose over the document
407    /// (identity for one the frame did not move), the pairs whose posed boxes
408    /// overlap are screened mesh against mesh, and a pair neither of whose
409    /// occurrences moved since the previous frame keeps that frame's verdict.
410    /// The geometry read is the display AS THE DOCUMENT POSES IT: a solid
411    /// already posed for a shown frame (the bake may still be running) is
412    /// read from its recorded original, never from the scene.
413    fn simulation_frame_clashes(&self, participants: &[(String, Vec<String>)], poses: &BTreeMap<String, [f64; 16]>, previous: Option<&BakedFrame>) -> Vec<(String, String)> {
414        let originals = self.simulation_play.as_ref().map(|play| &play.originals);
415        let display_of = |name: &String| -> Option<&SolidDisplay> { originals.and_then(|originals| originals.get(name)).or_else(|| self.scene.solid(name)) };
416        let delta_of = |occ: &str| -> [f64; 16] {
417            let top = occ.split(':').next().unwrap_or(occ);
418            let (key, world) = match poses.get(occ) {
419                Some(world) => (occ, world),
420                None => match poses.get(top) {
421                    Some(world) => (top, world),
422                    None => return IDENTITY,
423                },
424            };
425            match self.occurrence_document_matrix(key) {
426                Some(document) => mat_mul(world, &rigid_inverse(&document)),
427                None => IDENTITY,
428            }
429        };
430        let mut posed: Vec<(&str, [f64; 16], Aabb, &[String])> = Vec::new();
431        for (occ, solids) in participants {
432            let delta = delta_of(occ);
433            let mut bbox = Aabb::empty();
434            for name in solids {
435                if let Some(solid) = display_of(name) {
436                    bbox.union(&posed_bbox(&solid.bbox, &delta));
437                }
438            }
439            if !bbox.is_empty() {
440                posed.push((occ.as_str(), delta, bbox, solids.as_slice()));
441            }
442        }
443        let same_as_previous = |occ: &str, prev: &BakedFrame| {
444            let top = occ.split(':').next().unwrap_or(occ);
445            poses.get(occ) == prev.poses.get(occ) && poses.get(top) == prev.poses.get(top)
446        };
447        let mut clashes = Vec::new();
448        for i in 0..posed.len() {
449            for j in i + 1..posed.len() {
450                if !overlaps(&posed[i].2, &posed[j].2) {
451                    continue;
452                }
453                let pair = (posed[i].0.to_string(), posed[j].0.to_string());
454                if let Some(prev) = previous {
455                    if same_as_previous(&pair.0, prev) && same_as_previous(&pair.1, prev) {
456                        if prev.clashes.contains(&pair) {
457                            clashes.push(pair);
458                        }
459                        continue;
460                    }
461                }
462                let hit = posed[i].3.iter().filter_map(display_of).any(|a| posed[j].3.iter().filter_map(display_of).any(|b| displays_pierce(a, &posed[i].1, b, &posed[j].1)));
463                if hit {
464                    clashes.push(pair);
465                }
466            }
467        }
468        clashes
469    }
470
471    /// Show the wanted frame if it is baked and not already shown.
472    fn simulation_show_wanted(&mut self) {
473        let Some(play) = self.simulation_play.as_ref() else { return };
474        let Some(wanted) = play.wanted else { return };
475        if wanted >= play.frames.len() || play.shown == Some(wanted) {
476            return;
477        }
478        self.simulation_pose_frame(wanted);
479    }
480
481    /// Where every moved occurrence sits at frame `index` (row-major world
482    /// matrices): the solve's poses, plus an explode clip's steps — its view's
483    /// enabled steps, each `fraction` of the way at the frame's progress (the
484    /// steps below the progress whole, the one at it partly), translating
485    /// their target occurrences on top of the solve. `None` before the frame
486    /// is baked.
487    pub(crate) fn simulation_frame_worlds(&self, index: usize) -> Option<BTreeMap<String, [f64; 16]>> {
488        let play = self.simulation_play.as_ref()?;
489        let frame = play.frames.get(index)?;
490        let mut worlds = frame.poses.clone();
491        let time = play.baked_study.frame_time(index);
492        let base = &play.base;
493        let sample = play.baked_study.sample(time, &|id| base.get(id).copied());
494        for explode in &sample.explode {
495            let Some(view) = self.exploded_views().into_iter().find(|view| view.id == explode.view) else { continue };
496            let steps: Vec<_> = view.steps.iter().filter(|step| step.enabled).collect();
497            let count = steps.len() as f64;
498            for (k, step) in steps.iter().enumerate() {
499                let fraction = (explode.progress * count - k as f64).clamp(0.0, 1.0);
500                if fraction <= 0.0 {
501                    continue;
502                }
503                for target in &step.targets {
504                    let Some(world) = worlds.get(target).copied().or_else(|| self.occurrence_document_matrix(target)) else { continue };
505                    let mut shift = IDENTITY;
506                    for axis in 0..3 {
507                        shift[axis * 4 + 3] = step.translate[axis] * fraction;
508                    }
509                    worlds.insert(target.clone(), mat_mul(&shift, &world));
510                }
511            }
512        }
513        Some(worlds)
514    }
515
516    /// Pose the displays at frame `index`, touching only the occurrences
517    /// whose pose differs from what they show; then the frame's clips (hidden
518    /// occurrences, the camera) and the clash tint.
519    fn simulation_pose_frame(&mut self, index: usize) {
520        let Some(play) = self.simulation_play.as_ref() else { return };
521        if index >= play.frames.len() {
522            return;
523        }
524        let time = play.baked_study.frame_time(index);
525        let base = play.base.clone();
526        let sample = play.baked_study.sample(time, &|id| base.get(id).copied());
527        let Some(worlds) = self.simulation_frame_worlds(index) else { return };
528        let Some(play) = self.simulation_play.as_ref() else { return };
529        let frame = worlds;
530        // Occurrences posed last time and absent from this frame go back.
531        let gone: Vec<String> = play.applied.keys().filter(|id| !frame.contains_key(*id)).cloned().collect();
532        self.simulation_unsection();
533        let Some(play) = self.simulation_play.as_ref() else { return };
534        let mut changed = false;
535        for id in gone {
536            self.simulation_pose_occurrence(&id, None);
537            changed = true;
538        }
539        for (id, world) in &frame {
540            let Some(document) = self.occurrence_document_matrix(id) else { continue };
541            let shown = self.simulation_play.as_ref().and_then(|play| play.applied.get(id).copied()).unwrap_or(document);
542            if same_matrix(&shown, world) {
543                continue;
544            }
545            if same_matrix(world, &document) {
546                // Back at the document pose: the originals, not an identity pose.
547                self.simulation_pose_occurrence(id, None);
548            } else {
549                let delta = mat_mul(world, &rigid_inverse(&document));
550                self.simulation_pose_occurrence(id, Some((delta, *world)));
551            }
552            changed = true;
553        }
554        if let Some(play) = self.simulation_play.as_mut() {
555            play.shown = Some(index);
556        }
557        if changed {
558            self.dirty = true;
559        }
560        self.simulation_apply_section(sample.section.as_ref());
561        self.simulation_tint_clashes(index);
562        self.simulation_apply_hidden(&sample.hidden);
563        self.simulation_apply_opacity(&sample.opacity);
564        if let Some(camera) = &sample.camera {
565            self.simulation_apply_camera(&camera.from, &camera.to, camera.t);
566        }
567        if let Some(play) = self.simulation_play.as_mut() {
568            play.shown_sample = Some(sample);
569        }
570    }
571
572    /// The solids the last frame's section cut or hid go back to their
573    /// originals and, where their occurrence is posed, are posed again, so
574    /// this frame's section (or none) starts from whole geometry.
575    fn simulation_unsection(&mut self) {
576        let Some(play) = self.simulation_play.as_mut() else { return };
577        let sectioned = std::mem::take(&mut play.sectioned);
578        if sectioned.is_empty() {
579            return;
580        }
581        let applied: Vec<(String, [f64; 16])> = play.applied.iter().map(|(id, world)| (id.clone(), *world)).collect();
582        let mut reposed: Vec<String> = Vec::new();
583        for name in &sectioned {
584            if let (Some(original), Some(display)) = (play.originals.get(name), self.scene.solid_mut(name)) {
585                *display = original.clone();
586            }
587            // The longest posed occurrence this solid belongs to.
588            if let Some((id, _)) = applied.iter().filter(|(id, _)| name.starts_with(&format!("{id}:"))).max_by_key(|(id, _)| id.len()) {
589                if !reposed.contains(id) {
590                    reposed.push(id.clone());
591                }
592            }
593        }
594        for id in reposed {
595            let Some(world) = applied.iter().find(|(key, _)| *key == id).map(|(_, world)| *world) else { continue };
596            let Some(document) = self.occurrence_document_matrix(&id) else { continue };
597            self.simulation_pose_occurrence(&id, Some((mat_mul(&world, &rigid_inverse(&document)), world)));
598        }
599        self.dirty = true;
600    }
601
602    /// Cut the displays on the PMI view's section planes, moved `offset`
603    /// along their normals: a solid wholly on the removed side is hidden, one
604    /// the planes cross is clipped and capped, one the view's section
605    /// excludes stays whole. Nothing with no section.
606    fn simulation_apply_section(&mut self, section: Option<&SectionSample>) {
607        let Some(section) = section else { return };
608        let state = self.pmi_state_shared();
609        let Some(view) = state.find_view(&section.view) else { return };
610        let report = self.pmi_report.as_ref().and_then(|report| report.view(&section.view));
611        let planes: Vec<([f64; 3], [f64; 3])> = view
612            .section_planes_in(report)
613            .into_iter()
614            .map(|(point, normal)| (std::array::from_fn(|i| point[i] + normal[i] * section.offset), normal))
615            .collect();
616        if planes.is_empty() {
617            return;
618        }
619        let exclude = view.display.section.clone();
620        let names: Vec<(String, Side)> = self
621            .scene
622            .solids()
623            .iter()
624            .filter(|solid| solid.visible)
625            .map(|solid| (solid.name.clone(), if exclude.as_ref().is_some_and(|cut| cut.excludes(&solid.name)) { Side::Kept } else { side_of_all(solid, &planes) }))
626            .collect();
627        let mut sectioned = Vec::new();
628        for (name, side) in names {
629            if side == Side::Kept {
630                continue;
631            }
632            let Some(display) = self.scene.solid(&name).cloned() else { continue };
633            if let Some(play) = self.simulation_play.as_mut() {
634                play.originals.entry(name.clone()).or_insert(display);
635            }
636            if let Some(display) = self.scene.solid_mut(&name) {
637                match side {
638                    Side::Removed => display.visible = false,
639                    Side::Cut => clip_display_all(display, &planes),
640                    Side::Kept => {}
641                }
642                display.revision = crate::scene::next_revision();
643            }
644            sectioned.push(name);
645        }
646        if let Some(play) = self.simulation_play.as_mut() {
647            play.sectioned = sectioned;
648        }
649        self.dirty = true;
650    }
651
652    /// Set the opacity of the solids a transparency clip names at this frame
653    /// and give the last frame's see-through solids their original back.
654    fn simulation_apply_opacity(&mut self, opacity: &BTreeMap<String, f64>) {
655        let mut want: Vec<(String, f32)> = Vec::new();
656        for (id, value) in opacity {
657            for name in self.simulation_occurrence_solids(id) {
658                want.push((name, value.clamp(0.0, 1.0) as f32));
659            }
660        }
661        want.sort_by(|a, b| a.0.cmp(&b.0));
662        want.dedup_by(|a, b| a.0 == b.0);
663        let Some(play) = self.simulation_play.as_ref() else { return };
664        let previous = play.translucent.clone();
665        let mut changed = false;
666        for name in previous.iter().filter(|name| !want.iter().any(|(n, _)| n == *name)) {
667            let opacity = self.simulation_play.as_ref().and_then(|play| play.originals.get(name)).map(|original| original.opacity);
668            if let (Some(opacity), Some(display)) = (opacity, self.scene.solid_mut(name)) {
669                if display.opacity != opacity {
670                    display.opacity = opacity;
671                    display.revision = crate::scene::next_revision();
672                    changed = true;
673                }
674            }
675        }
676        for (name, value) in &want {
677            let Some(display) = self.scene.solid(name).cloned() else { continue };
678            if let Some(play) = self.simulation_play.as_mut() {
679                play.originals.entry(name.clone()).or_insert(display);
680            }
681            if let Some(display) = self.scene.solid_mut(name) {
682                if display.opacity != Some(*value) {
683                    display.opacity = Some(*value);
684                    display.revision = crate::scene::next_revision();
685                    changed = true;
686                }
687            }
688        }
689        if let Some(play) = self.simulation_play.as_mut() {
690            play.translucent = want.into_iter().map(|(name, _)| name).collect();
691        }
692        if changed {
693            self.dirty = true;
694        }
695    }
696
697    /// The solids of occurrence `id`: a nested occurrence's by name prefix, a
698    /// top component's members.
699    fn simulation_occurrence_solids(&self, id: &str) -> Vec<String> {
700        if id.contains(':') {
701            let prefix = format!("{id}:");
702            self.scene.solids().iter().filter(|solid| solid.name.starts_with(&prefix)).map(|solid| solid.name.clone()).collect()
703        } else {
704            self.member_solid_names(id)
705        }
706    }
707
708    /// Hide the solids of the frame's visibility clips and show again the
709    /// ones hidden for the last frame that no clip hides now (from their
710    /// originals, so a solid the document hides stays hidden).
711    fn simulation_apply_hidden(&mut self, occurrences: &[String]) {
712        let mut want: Vec<String> = occurrences.iter().flat_map(|id| self.simulation_occurrence_solids(id)).collect();
713        want.sort();
714        want.dedup();
715        let Some(play) = self.simulation_play.as_ref() else { return };
716        let previous = play.hidden.clone();
717        let mut changed = false;
718        for name in previous.iter().filter(|name| !want.contains(name)) {
719            let visible = self.simulation_play.as_ref().and_then(|play| play.originals.get(name)).map(|original| original.visible);
720            if let (Some(visible), Some(display)) = (visible, self.scene.solid_mut(name)) {
721                if display.visible != visible {
722                    display.visible = visible;
723                    changed = true;
724                }
725            }
726        }
727        for name in &want {
728            let Some(display) = self.scene.solid(name).cloned() else { continue };
729            if let Some(play) = self.simulation_play.as_mut() {
730                play.originals.entry(name.clone()).or_insert(display);
731            }
732            if let Some(display) = self.scene.solid_mut(name) {
733                if display.visible {
734                    display.visible = false;
735                    changed = true;
736                }
737            }
738        }
739        if let Some(play) = self.simulation_play.as_mut() {
740            play.hidden = want;
741        }
742        if changed {
743            self.dirty = true;
744        }
745    }
746
747    /// Put the camera `t` of the way from PMI view `from`'s camera to view
748    /// `to`'s (eye, target, up and the projection's scale blended; the
749    /// projection kind switches half way), keeping the camera as it stood
750    /// before the first such frame.
751    fn simulation_apply_camera(&mut self, from: &str, to: &str, t: f64) {
752        let Some(camera) = self.simulation_camera_between(from, to, t) else { return };
753        let standing = self.snapshot_camera();
754        if let Some(play) = self.simulation_play.as_mut() {
755            if play.camera_before.is_none() {
756                play.camera_before = Some(standing);
757            }
758        }
759        self.simulation_apply_pmi_camera(&camera);
760    }
761
762    /// The camera `t` of the way from PMI view `from`'s camera to view `to`'s
763    /// (eye, target, up and the projection's scale blended; the projection
764    /// kind switches half way); `None` when either view has no camera.
765    pub(crate) fn simulation_camera_between(&self, from: &str, to: &str, t: f64) -> Option<PmiCamera> {
766        let state = self.pmi_state_shared();
767        let a = state.find_view(from).and_then(|v| v.camera.clone())?;
768        let b = state.find_view(to).and_then(|v| v.camera.clone())?;
769        let t = t.clamp(0.0, 1.0);
770        let lerp = |p: [f64; 3], q: [f64; 3]| -> [f64; 3] { std::array::from_fn(|i| p[i] + (q[i] - p[i]) * t) };
771        let scale_of = |camera: &PmiCamera| match camera.projection {
772            PmiProjection::Orthographic { half_height } => half_height,
773            PmiProjection::Perspective { fov_y_deg } => fov_y_deg,
774        };
775        let orthographic = |camera: &PmiCamera| matches!(camera.projection, PmiProjection::Orthographic { .. });
776        let same_kind = orthographic(&a) == orthographic(&b);
777        let (ortho, scale) = if same_kind {
778            (orthographic(&a), scale_of(&a) + (scale_of(&b) - scale_of(&a)) * t)
779        } else if t < 0.5 {
780            (orthographic(&a), scale_of(&a))
781        } else {
782            (orthographic(&b), scale_of(&b))
783        };
784        Some(PmiCamera {
785            eye: lerp(a.eye, b.eye),
786            target: lerp(a.target, b.target),
787            up: lerp(a.up, b.up),
788            projection: if ortho { PmiProjection::Orthographic { half_height: scale } } else { PmiProjection::Perspective { fov_y_deg: scale } },
789            viewport: a.viewport,
790        })
791    }
792
793    /// Apply a PMI camera record as the live camera.
794    fn simulation_apply_pmi_camera(&mut self, camera: &PmiCamera) {
795        let (kind, scale) = match camera.projection {
796            PmiProjection::Orthographic { half_height } => ("orthographic", half_height),
797            PmiProjection::Perspective { fov_y_deg } => ("perspective", fov_y_deg),
798        };
799        let json = json!({ "kind": kind, "eye": camera.eye, "target": camera.target, "up": camera.up, "scale": scale }).to_string();
800        if self.apply_camera_state_json(&json).is_ok() {
801            self.controls_sync_after_camera_apply();
802        }
803    }
804
805    /// Tint the displays of the occurrences that clash at frame `index`, and
806    /// give back their colour to the ones tinted for the last frame that no
807    /// longer clash. Runs after the frame's posing (a re-posed display is
808    /// rebuilt from its original, which holds the colour the document gave it).
809    fn simulation_tint_clashes(&mut self, index: usize) {
810        let Some(play) = self.simulation_play.as_ref() else { return };
811        let Some(frame) = play.frames.get(index) else { return };
812        let mut want: Vec<String> = Vec::new();
813        for (a, b) in &frame.clashes {
814            for occ in [a, b] {
815                if let Some((_, solids)) = play.participants.iter().find(|(id, _)| id == occ) {
816                    want.extend(solids.iter().cloned());
817                }
818            }
819        }
820        want.sort();
821        want.dedup();
822        let previous = play.tinted.clone();
823        let mut changed = false;
824        for name in previous.iter().filter(|name| !want.contains(name)) {
825            let color = self.simulation_play.as_ref().and_then(|play| play.originals.get(name)).map(|original| original.color_override);
826            if let (Some(color), Some(display)) = (color, self.scene.solid_mut(name)) {
827                if display.color_override != color {
828                    display.color_override = color;
829                    display.revision = crate::scene::next_revision();
830                    changed = true;
831                }
832            }
833        }
834        for name in &want {
835            // The original is kept before the tint: an unmoved occurrence may
836            // not have been posed, so nothing recorded it yet.
837            let Some(display) = self.scene.solid(name).cloned() else { continue };
838            if let Some(play) = self.simulation_play.as_mut() {
839                play.originals.entry(name.clone()).or_insert(display);
840            }
841            if let Some(display) = self.scene.solid_mut(name) {
842                if display.color_override != Some(CLASH_TINT) {
843                    display.color_override = Some(CLASH_TINT);
844                    // The renderer re-derives a style on a new revision only.
845                    display.revision = crate::scene::next_revision();
846                    changed = true;
847                }
848            }
849        }
850        if let Some(play) = self.simulation_play.as_mut() {
851            play.tinted = want;
852        }
853        if changed {
854            self.dirty = true;
855        }
856    }
857
858    /// Pose occurrence `id`'s displays by `delta` over their originals (and
859    /// record the `world` pose they then show), or put them back (`None`).
860    /// A top component's pass re-poses the solids of its nested occurrences
861    /// too, so those are forgotten and posed again after it (they follow it
862    /// in a frame's map).
863    fn simulation_pose_occurrence(&mut self, id: &str, pose: Option<([f64; 16], [f64; 16])>) {
864        let names: Vec<String> = if id.contains(':') {
865            let prefix = format!("{id}:");
866            self.scene.solids().iter().filter(|solid| solid.name.starts_with(&prefix)).map(|solid| solid.name.clone()).collect()
867        } else {
868            self.member_solid_names(id)
869        };
870        let Some(play) = self.simulation_play.as_mut() else { return };
871        for name in names {
872            let Some(display) = self.scene.solid(&name) else { continue };
873            let original = play.originals.entry(name.clone()).or_insert_with(|| display.clone()).clone();
874            if let Some(display) = self.scene.solid_mut(&name) {
875                *display = original;
876                if let Some((delta, _)) = &pose {
877                    pose_display_by_matrix(display, delta);
878                }
879            }
880        }
881        let nested = format!("{id}:");
882        play.applied.retain(|key, _| !key.starts_with(&nested));
883        match pose {
884            Some((_, world)) => {
885                play.applied.insert(id.to_string(), world);
886            }
887            None => {
888                play.applied.remove(id);
889            }
890        }
891    }
892
893    /// Where occurrence `id`'s displays sit in the document: a top
894    /// component's solved pose, or that composed with a nested occurrence's
895    /// solved pose inside it (row-major 4×4).
896    pub(super) fn occurrence_document_matrix(&self, id: &str) -> Option<[f64; 16]> {
897        let (top, rest) = match id.split_once(':') {
898            Some((top, rest)) => (top, Some(rest)),
899            None => (id, None),
900        };
901        let record = self.assembly_components().iter().find(|record| record.id == top)?;
902        match rest {
903            None => Some(record.transform),
904            Some(rest) => Some(mat_mul(&record.transform, record.nested_poses.get(rest)?)),
905        }
906    }
907
908    /// Every posed display back exactly; the bake and the playhead are kept,
909    /// and the shown frame is asked for again (a run that rebuilds the
910    /// displays re-poses it; a stop forgets it).
911    pub(crate) fn simulation_restore_displays(&mut self) {
912        let Some(play) = self.simulation_play.as_mut() else { return };
913        play.applied.clear();
914        play.tinted.clear();
915        play.hidden.clear();
916        play.translucent.clear();
917        play.sectioned.clear();
918        play.shown_sample = None;
919        let camera_before = play.camera_before.take();
920        let originals = std::mem::take(&mut play.originals);
921        if let Some(camera) = camera_before {
922            self.simulation_apply_pmi_camera(&camera);
923        }
924        let Some(play) = self.simulation_play.as_mut() else { return };
925        if let Some(shown) = play.shown.take() {
926            play.wanted.get_or_insert(shown);
927        }
928        if originals.is_empty() {
929            return;
930        }
931        for (name, original) in originals {
932            if let Some(display) = self.scene.solid_mut(&name) {
933                *display = original;
934            }
935        }
936        self.dirty = true;
937    }
938
939    /// Post-apply tail: the displays are fresh (un-posed before the
940    /// reconcile), so the frame that was shown goes back on them when the
941    /// bake is still current; a moved document re-bakes on the next tick.
942    pub(crate) fn simulation_after_apply(&mut self) {
943        let Some(play) = self.simulation_play.as_ref() else { return };
944        if play.revision == self.history.revision() {
945            self.simulation_show_wanted();
946        }
947    }
948
949    /// Forget the playback entirely (displays restored): the document closed
950    /// or the workbench left.
951    pub fn simulation_forget_playback(&mut self) {
952        self.simulation_restore_displays();
953        self.simulation_play = None;
954    }
955
956    /// What automation and the pane read (`__brepSimulation.playback`):
957    /// `{study, time, playheadFrame, shown, baked, total, playing, looping,
958    /// failed: [frame…], error, poses: {occurrence: {translate,
959    /// rotateEulerDeg}}, interference, clashes: [frame…], shownClashes:
960    /// [[a, b]…], hidden: [occurrence…], explode: [{view, progress}…],
961    /// camera: {from, to, t} | null, cameraHeld, opacity: {occurrence:
962    /// value}, section: {view, offset} | null}` — `poses` are the
963    /// occurrences the shown frame moved from their document poses (explode
964    /// clips included), `clashes` the baked frames where parts interfere,
965    /// `shownClashes` the pairs at the shown frame, and `hidden`, `explode`
966    /// and `camera` the shown frame's clips (`cameraHeld`: a camera clip has
967    /// taken the camera, which Stop gives back); `null` with no playback.
968    pub fn simulation_playback_json(&self) -> Value {
969        let Some(play) = self.simulation_play.as_ref() else { return Value::Null };
970        let failed: Vec<usize> = play.frames.iter().enumerate().filter(|(_, frame)| frame.failed).map(|(index, _)| index).collect();
971        let poses: BTreeMap<&str, Value> = play
972            .applied
973            .iter()
974            .map(|(id, world)| (id.as_str(), brep_kernel::transform_to_pose_params(&brep_kernel::AffineTransform { elements: *world })))
975            .collect();
976        json!({
977            "study": play.study,
978            "time": play.time,
979            "playheadFrame": play.baked_study.frame_at(play.time),
980            "shown": play.shown,
981            "baked": play.frames.len(),
982            "total": play.total,
983            "playing": play.playing,
984            "looping": play.looping,
985            "failed": failed,
986            "error": play.error,
987            "poses": poses,
988            "interference": play.baked_study.interference.enabled,
989            "clashes": play.frames.iter().enumerate().filter(|(_, frame)| !frame.clashes.is_empty()).map(|(index, _)| index).collect::<Vec<usize>>(),
990            "shownClashes": play.shown.and_then(|index| play.frames.get(index)).map(|frame| frame.clashes.clone()).unwrap_or_default(),
991            "hidden": play.shown_sample.as_ref().map(|sample| sample.hidden.clone()).unwrap_or_default(),
992            "explode": play.shown_sample.as_ref().map(|sample| sample.explode.iter().map(|e| json!({ "view": e.view, "progress": e.progress })).collect::<Vec<Value>>()).unwrap_or_default(),
993            "camera": play.shown_sample.as_ref().and_then(|sample| sample.camera.as_ref()).map(|c| json!({ "from": c.from, "to": c.to, "t": c.t })),
994            "cameraHeld": play.camera_before.is_some(),
995            "opacity": play.shown_sample.as_ref().map(|sample| sample.opacity.clone()).unwrap_or_default(),
996            "section": play.shown_sample.as_ref().and_then(|sample| sample.section.as_ref()).map(|s| json!({ "view": s.view, "offset": s.offset })),
997        })
998    }
999}
1000
1001/// `a · b`, row-major 4×4.
1002pub(super) fn mat_mul(a: &[f64; 16], b: &[f64; 16]) -> [f64; 16] {
1003    std::array::from_fn(|k| {
1004        let (row, col) = (k / 4, k % 4);
1005        (0..4).map(|n| a[row * 4 + n] * b[n * 4 + col]).sum()
1006    })
1007}
1008
1009/// The inverse of a rigid row-major 4×4: `[Rᵀ | −Rᵀ·t]`.
1010pub(super) fn rigid_inverse(m: &[f64; 16]) -> [f64; 16] {
1011    let mut out = [0.0; 16];
1012    for row in 0..3 {
1013        for col in 0..3 {
1014            out[row * 4 + col] = m[col * 4 + row];
1015        }
1016        out[row * 4 + 3] = -(0..3).map(|k| m[k * 4 + row] * m[k * 4 + 3]).sum::<f64>();
1017    }
1018    out[15] = 1.0;
1019    out
1020}
1021
1022/// Two poses within roundoff of each other.
1023pub(super) fn same_matrix(a: &[f64; 16], b: &[f64; 16]) -> bool {
1024    a.iter().zip(b).all(|(x, y)| (x - y).abs() <= 1e-9)
1025}
1026
1027/// `a · b`, column-major 4×4 (`SolidDisplay::pose`'s spelling).
1028fn mat4_mul_column_major(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
1029    std::array::from_fn(|k| {
1030        let (col, row) = (k / 4, k % 4);
1031        (0..4).map(|n| a[n * 4 + row] * b[col * 4 + n]).sum()
1032    })
1033}
1034
1035/// Move a display rigidly by the row-major `m`: the renderer draws the
1036/// buffers it holds for the shape through the matrix (`pose`, over any pose
1037/// already on it), and the positions, normals, edge polylines, vertices and
1038/// box follow for everything that reads them (picking, sheets, exports).
1039fn pose_display_by_matrix(display: &mut SolidDisplay, m: &[f64; 16]) {
1040    let column_major: [f32; 16] = std::array::from_fn(|k| {
1041        let (col, row) = (k / 4, k % 4);
1042        m[row * 4 + col] as f32
1043    });
1044    display.pose = if display.shape_revision == 0 {
1045        Some(column_major)
1046    } else {
1047        display.pose.map(|before| mat4_mul_column_major(&column_major, &before))
1048    };
1049    if display.shape_revision == 0 {
1050        display.shape_revision = display.revision;
1051    }
1052    display.revision = crate::scene::next_revision();
1053    let point = |p: [f64; 3]| -> [f64; 3] {
1054        std::array::from_fn(|i| m[i * 4] * p[0] + m[i * 4 + 1] * p[1] + m[i * 4 + 2] * p[2] + m[i * 4 + 3])
1055    };
1056    let vector = |v: [f64; 3]| -> [f64; 3] { std::array::from_fn(|i| m[i * 4] * v[0] + m[i * 4 + 1] * v[1] + m[i * 4 + 2] * v[2]) };
1057    let mut bbox = crate::camera::Aabb::empty();
1058    for position in &mut display.mesh.positions {
1059        let posed = point(position.map(f64::from));
1060        *position = posed.map(|v| v as f32);
1061        bbox.expand(posed);
1062    }
1063    for normal in &mut display.mesh.normals {
1064        let rotated = vector(normal.map(f64::from));
1065        let length = rotated.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-20);
1066        *normal = rotated.map(|v| (v / length) as f32);
1067    }
1068    for edge in &mut display.edges {
1069        for p in &mut edge.polyline {
1070            let posed = point(p.map(f64::from));
1071            *p = posed.map(|v| v as f32);
1072            bbox.expand(posed);
1073        }
1074    }
1075    for vertex in &mut display.vertices {
1076        vertex.position = point(vertex.position);
1077        bbox.expand(vertex.position);
1078    }
1079    if !bbox.is_empty() {
1080        display.bbox = bbox;
1081    }
1082}