Skip to main content

brep_app/
document.rs

1//! Open documents and their active tab. Each document owns an engine, store
2//! identity, and clean baseline for dirty tracking. Keeping engine access on
3//! `Documents` allows separate mutable borrows of other app fields.
4//!
5//! Every engine has its own history runner and resident kernel state. All open
6//! documents are pumped each frame, so switching tabs cannot redirect a pending
7//! result to another document. Each open document retains its runner and scene.
8//!
9//! Display settings are shared across tabs by [`carry_settings`]; workbench
10//! selection belongs to each document. A new engine initially inherits the
11//! current workbench, which a loaded document can override.
12
13use brep_render::engine_state::EngineState;
14
15pub mod ecad;
16
17/// Builds a fresh engine for a new document — the platform runner, the viewcube,
18/// and the persisted display settings, all applied before anything is loaded
19/// into it. Injected by the shell so tests (which need the SYNCHRONOUS inline
20/// runner) can construct documents without a background thread.
21pub type EngineFactory = Box<dyn Fn() -> EngineState>;
22
23/// Whether a document may be edited. Every file-based document is
24/// [`Access::Editable`]. PLM permissions apply to [`Document::save_access`],
25/// independently of editing the session copy.
26///
27/// Held by the engine's history ([`brep_render::history::History::lock`]),
28/// which refuses every change to the saved document while it is read-only —
29/// the one door every edit passes, so no panel has to remember to ask.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum Access {
32    Editable,
33    /// `reason` explains the edit or save restriction to the user.
34    ReadOnly { reason: String },
35}
36
37impl Access {
38    pub fn read_only(reason: impl Into<String>) -> Self {
39        Access::ReadOnly { reason: reason.into() }
40    }
41
42    pub fn is_editable(&self) -> bool {
43        matches!(self, Access::Editable)
44    }
45}
46
47/// Empty history for loading fixtures and creating nonpart documents.
48pub const EMPTY_DOCUMENT: &str = r#"{"expressions":"","configurator":{},"features":[]}"#;
49
50/// Workbenches share a document format. Only part workspaces seed geometry
51/// references; assembly, drawing, harness and eCAD workspaces start empty.
52fn is_part_workbench(id: &str) -> bool {
53    !matches!(crate::workbench::resolve(id).id,
54        "assembly" | "drawing" | "wireHarness" | "diagram" | "pcb" | "symbol" | "pads")
55}
56
57/// Seed only a feature-empty part when explicitly creating a new part. PLM
58/// adoption uses this too, preserving the source's expressions and document blocks.
59pub fn seed_new_part_datum(engine: &mut EngineState) {
60    if engine.history_len() == 0 && is_part_workbench(&engine.settings.workbench) {
61        let id = engine.next_feature_id(&brep_render::features::feature_short_name("D"));
62        let mut params = engine.feature_default_params("D");
63        params["id"] = serde_json::json!(id);
64        engine.add_feature(&serde_json::json!({
65            "type": "D", "inputParams": params, "persistentData": {}
66        }).to_string()).expect("valid default datum");
67    }
68}
69
70/// ONE open model: the engine that owns it plus the identity the file lane needs
71/// — the store name it was opened from / saved to, and the clean baseline.
72pub struct Document {
73    /// The windowing-agnostic brain for THIS document (scene, camera, history,
74    /// settings, selection). Panels borrow it; it is never forked.
75    pub engine: EngineState,
76    /// The store identity (`None` = a never-saved "untitled" model). The RAW
77    /// identity, not the display name — a native dialog hands back a full path
78    /// and a plain Save must write back to it.
79    name: Option<String>,
80    /// Which untitled document this is while it has no `name`: 1 is plain
81    /// "untitled", 2 and up read "untitled 2", … — assigned by
82    /// [`Documents::open_document`], the lowest number no other open untitled
83    /// tab holds, so two new tabs never share a label (lane U, ruling P2).
84    untitled: u32,
85    /// PLM write permission is independent of editing the session copy.
86    plm_save_access: Option<Access>,
87    /// The model request JSON as of the last New / Open / Save — the baseline
88    /// the dirty flag compares the live history against.
89    saved_signature: Option<String>,
90    /// The cached tab-strip dirty dot. See [`Document::dirty_marker`] for why
91    /// this is not simply `is_dirty()`.
92    dirty_marker: bool,
93    /// The [`Document::edit_key`] `dirty_marker` was last refreshed at.
94    marker_key: Option<(u64, u64)>,
95    /// How many times the marker has serialized the document to recompute
96    /// itself — the per-frame cost [`Document::refresh_dirty_marker`] bounds.
97    dirty_checks: u64,
98    /// A process-unique handle, so the shell can notice "the active document is
99    /// a different one now" without comparing indices (closing a tab BEFORE the
100    /// active one moves the index without changing the document).
101    id: u64,
102    /// This document's eCAD editors — Diagram, PCB, Symbol and Pads — kept
103    /// beside its engine, so each tab has its own and a tab switch keeps their
104    /// pan, zoom and view: a switch rebuilds the PANELS
105    /// (`reset_document_scoped_state`), never a document. Built with the
106    /// document; all four measured 0.65 ms (release), so no document waits for
107    /// its first eCAD workbench to have them.
108    pub ecad: crate::workbench::ecad::Editors,
109    /// Each editor's standing with its block of this document's history: what
110    /// takes BREP's undo and redo to the editor, and the editor's edits into the
111    /// document ([`ecad::BlockSync`]).
112    pub ecad_blocks: [ecad::BlockSync; 4],
113    pub ecad_parts_revision: Option<u64>,
114    /// The broken-reference lines [`crate::panels::ecad_parts::sync`] has
115    /// already put in front of the user. A device whose part is missing is a
116    /// STANDING state and the revision bumps on every edit, so without this it
117    /// would toast on every keystroke — the same rule
118    /// [`ecad::BlockSync::unreported`] keeps for an UNLINKED component.
119    pub ecad_parts_reported: Vec<String>,
120    /// What [`crate::panels::ecad_parts::follow_occurrences`] last saw of the
121    /// assembly's occurrences; `None` until the first frame looks.
122    pub ecad_occurrences: Option<crate::panels::ecad_parts::OccurrenceMark>,
123    /// The parts-library entries whose saved part has moved on since this
124    /// document took it — the shell's `UpdateComponents` list for the active
125    /// document, handed over each frame so the Diagram and PCB workbenches can
126    /// mark the components placed from them and offer the update where the
127    /// user is looking (`viewport::ecad`).
128    pub ecad_outdated: Vec<String>,
129    /// Set by the eCAD host's Update parts button; the shell runs the update
130    /// (`UpdateComponents::run`, one undo step) on its next frame.
131    pub ecad_update_requested: bool,
132    /// The history's refused-edit count as of the last
133    /// [`Document::take_refused`], so each refusal is told once.
134    refused_seen: u64,
135}
136
137/// What the dirty flag compares: the saved document ([`EngineState::history_request_json`])
138/// WITHOUT its `workbench` and derived `thumbnail`.
139///
140/// The workbench is saved, so a file reopens where it was left, but it is view state
141/// and not work. Counting it made Close ask to discard documents nobody had edited:
142/// open a board saved in PCB, look at its Diagram, and the saved document differed
143/// from the file in exactly `/workbench` (the eCAD re-audit's B5, measured in lane X).
144/// The tab's dot never counted it ([`Document::edit_key`] does not move for it), so
145/// the prompt came with no dot. Now the two agree, and Save still writes the field.
146///
147/// Built as `history_request_json` builds the saved document, less the one field,
148/// rather than by parsing that again: the dot recomputes this on every run-moving
149/// edit, and a second parse of a megabyte assembly doubles it. A test holds the two
150/// together (`the_signature_is_the_saved_document_less_its_workbench`).
151fn signature(engine: &EngineState) -> String {
152    signature_without(engine, None)
153}
154
155/// The saved-content signature with one document block left out — the
156/// baseline of a copy saved WITHOUT that block (a family member saved as a new
157/// part drops `familySource` on the copy), in the same canonical form.
158fn signature_without(engine: &EngineState, without: Option<&str>) -> String {
159    let mut document: serde_json::Value =
160        serde_json::from_str(&engine.history.request_json()).unwrap_or_else(|_| serde_json::json!({}));
161    if let Some(object) = document.as_object_mut() {
162        if let Some(key) = without {
163            object.remove(key);
164        }
165        if !engine.metadata.is_empty() {
166            object.insert("metadata".into(), engine.metadata.to_json());
167        }
168    }
169    document.to_string()
170}
171
172/// One [`ecad::BlockSync`] per eCAD block, none of them pulled yet.
173fn ecad_blocks() -> [ecad::BlockSync; 4] {
174    use ecad::Block;
175    [Block::Diagram, Block::Pcb, Block::Symbol, Block::Pads].map(ecad::BlockSync::new)
176}
177
178impl Document {
179    /// Create a fresh document. Part workbenches start with the same ordinary,
180    /// schema-seeded datum a user adds from the palette. Existing content must
181    /// use `new` instead: opening a file never changes its feature history.
182    pub fn fresh(mut engine: EngineState, class: crate::document_class::DocumentClass) -> Self {
183        engine.set_history_json(EMPTY_DOCUMENT).expect("valid empty document");
184        crate::document_class::set_document_class(&mut engine, class);
185        seed_new_part_datum(&mut engine);
186        Self::new(engine)
187    }
188
189    /// Wrap `engine` as a document, taking its CURRENT model as the clean
190    /// baseline — so a freshly loaded (or freshly emptied) document is clean and
191    /// the first edit marks it dirty.
192    pub fn new(engine: EngineState) -> Self {
193        let saved_signature = Some(signature(&engine));
194        Self {
195            engine,
196            name: None,
197            plm_save_access: None,
198            saved_signature,
199            dirty_marker: false,
200            marker_key: None,
201            dirty_checks: 0,
202            id: next_document_id(),
203            untitled: 1,
204            ecad: crate::workbench::ecad::Editors::new(),
205            ecad_blocks: ecad_blocks(),
206            ecad_parts_revision: None,
207            ecad_parts_reported: Vec::new(),
208            ecad_occurrences: None,
209            ecad_outdated: Vec::new(),
210            ecad_update_requested: false,
211            refused_seen: 0,
212        }
213    }
214
215    /// Wrap `engine` as a document RESTORED from the autosave blob
216    /// (`crate::recovery`): it carries the store identity it had (`None` for
217    /// an untitled one) and NO clean baseline, so it is dirty from its first
218    /// frame — the work it holds is exactly what was never saved, and the close
219    /// guard and the tab dot must say so until a Save writes it somewhere.
220    pub fn recovered(engine: EngineState, name: Option<String>) -> Self {
221        Self {
222            engine,
223            name,
224            plm_save_access: None,
225            saved_signature: None,
226            dirty_marker: true,
227            marker_key: None,
228            dirty_checks: 0,
229            id: next_document_id(),
230            untitled: 1,
231            ecad: crate::workbench::ecad::Editors::new(),
232            ecad_blocks: ecad_blocks(),
233            ecad_parts_revision: None,
234            ecad_parts_reported: Vec::new(),
235            ecad_occurrences: None,
236            ecad_outdated: Vec::new(),
237            ecad_update_requested: false,
238            refused_seen: 0,
239        }
240    }
241
242    /// Whether this document may be edited ([`Access`]).
243    pub fn access(&self) -> Access {
244        match self.engine.history.locked() {
245            None => Access::Editable,
246            Some(reason) => Access::read_only(reason),
247        }
248    }
249
250    /// Make the document editable or read-only. Read-only locks the engine's
251    /// history, so its saved bytes cannot change until it is editable again.
252    pub fn set_access(&mut self, access: Access) {
253        match access {
254            Access::Editable => self.engine.history.unlock(),
255            Access::ReadOnly { reason } => self.engine.history.lock(reason),
256        }
257    }
258
259    /// Saving to the current PLM revision may be restricted while session
260    /// editing remains available. A local Save As clears this restriction.
261    pub fn save_access(&self) -> Access {
262        self.plm_save_access.clone().unwrap_or_else(|| self.access())
263    }
264
265    pub(crate) fn has_plm_save_access(&self) -> bool { self.plm_save_access.is_some() }
266
267    pub fn set_plm_save_access(&mut self, access: Access) {
268        self.plm_save_access = Some(access);
269        self.engine.history.unlock();
270    }
271
272    /// The sentence to show when an edit was refused since the last call —
273    /// `None` when nothing was. Called once a frame by the shell, which toasts it.
274    pub fn take_refused(&mut self) -> Option<String> {
275        let refused = self.engine.history.refused_user_edits();
276        if refused == self.refused_seen {
277            return None;
278        }
279        self.refused_seen = refused;
280        let reason = self.engine.history.locked()?;
281        Some(format!("This document is read-only: {reason}."))
282    }
283
284    /// The raw store identity, or `None` for a never-saved document.
285    pub fn name(&self) -> Option<&str> {
286        self.name.as_deref()
287    }
288
289    pub fn set_name(&mut self, name: Option<String>) {
290        if self.name != name { self.plm_save_access = None; }
291        self.name = name;
292    }
293
294    /// The tab label: PLM part/revision, the bare file name, or `untitled`
295    /// (`untitled 2`, … for the second and later untitled tabs).
296    pub fn title(&self) -> String {
297        match &self.name {
298            Some(name) => crate::panels::plm_host::revision_of(name)
299                .map(|(part, revision)| format!("{part}/{revision}"))
300                .unwrap_or_else(|| crate::store::model_display_name(name)),
301            None => self.untitled_title(),
302        }
303    }
304
305    /// What an untitled document is called, and what Save As offers for it.
306    pub fn untitled_title(&self) -> String {
307        match self.untitled {
308            0 | 1 => "untitled".to_string(),
309            n => format!("untitled {n}"),
310        }
311    }
312
313    /// Snapshot the current model as the clean baseline (after New / Open /
314    /// Save, or a `?loadModel=` boot-load).
315    pub fn mark_clean(&mut self) {
316        self.saved_signature = Some(signature(&self.engine));
317        self.dirty_marker = false;
318        self.marker_key = Some(self.edit_key());
319    }
320
321    pub(crate) fn save_signature(&self) -> String { signature(&self.engine) }
322
323    /// [`Self::save_signature`] of the document with one block left out: what a
324    /// copy saved without that block has as its clean baseline.
325    pub(crate) fn save_signature_without(&self, block: &str) -> String {
326        signature_without(&self.engine, Some(block))
327    }
328
329    pub(crate) fn mark_saved_signature(&mut self, saved: String) {
330        self.saved_signature = Some(saved);
331        self.marker_key = None;
332        self.refresh_dirty_marker();
333    }
334
335    /// Dirty = the live model differs from the last saved/opened baseline.
336    /// (Rolling the history does NOT change the request document, so navigating
337    /// steps never marks dirty — only real edits/add/delete/reorder do.)
338    /// The workbench the document is in is not part of it ([`signature`]).
339    ///
340    /// HONEST but not free: it serializes the whole request document, which for
341    /// an assembly carrying an embedded parts library is megabytes. Every place
342    /// where being wrong would cost the user work — the close prompt, Save —
343    /// calls THIS. The per-frame tab dot calls [`Self::dirty_marker`] instead.
344    pub fn is_dirty(&self) -> bool {
345        match &self.saved_signature {
346            Some(saved) => *saved != signature(&self.engine),
347            None => true,
348        }
349    }
350
351    /// What moves when the saved document can have changed: the APPLIED RUN
352    /// generation and the history's revision.
353    ///
354    /// Both, because each misses edits the other sees. An ordinary edit (a
355    /// param change, add, delete, reorder, undo) re-runs the history, but a
356    /// block write runs nothing — a drawing sheet, a PMI label move, an eCAD
357    /// edit, a BOM part-attribute edit — and moves only the revision, which
358    /// every write to the SAVED document moves, the `partsLibrary` block
359    /// included (it is held in a field beside the document and both its setters
360    /// tick the revision themselves). An object-metadata change (a workbench
361    /// change is not an unsaved change at all, [`signature`]) moves neither and can
362    /// leave the dot one edit behind: a marker briefly optimistic, never a lost
363    /// edit, since [`Self::is_dirty`] is recomputed honestly at the close prompt.
364    pub fn edit_key(&self) -> (u64, u64) {
365        (self.engine.applied_generation(), self.engine.history_revision())
366    }
367
368    /// The cached dirty flag the tab strip draws. Recomputing it serializes
369    /// the whole document, so it is recomputed only when the
370    /// [`Self::edit_key`] moved, and, once the tab is marked unsaved, only when
371    /// a RUN moved it (an ordinary edit, an undo, a redo).
372    ///
373    /// The second rule is for drags. Dragging a block (a PMI label, a view on
374    /// a sheet) writes the document every frame and moves only the revision;
375    /// recomputing there serialized the document on every frame, 2.6 ms in a
376    /// release build on a 1.3 MB assembly. An unsaved tab stays unsaved while
377    /// only the revision moves, so a drag pays one serialization, on the frame
378    /// the dot lights. Param-slider drags re-run the history each frame and
379    /// still recompute each frame, as they always did.
380    ///
381    /// The residue is deliberate: a block edit that lands exactly back on the
382    /// saved bytes keeps the dot on until the next run or Save. Showing unsaved
383    /// when clean costs one needless Save; the opposite loses work, which is
384    /// the bug the revision key fixed. [`Self::is_dirty`] stays honest at the
385    /// close prompt either way.
386    pub fn refresh_dirty_marker(&mut self) {
387        let key = self.edit_key();
388        let previous = self.marker_key.replace(key);
389        if previous == Some(key) {
390            return;
391        }
392        let run_moved = previous.map_or(true, |(generation, _)| generation != key.0);
393        if self.dirty_marker && !run_moved {
394            return;
395        }
396        self.dirty_checks += 1;
397        self.dirty_marker = self.is_dirty();
398    }
399
400    /// Take a write that OPENING the document made — `before` and `after` are
401    /// the saved document either side of it, `key` the [`Self::edit_key`] before
402    /// it — as though the file had been saved that way: every top-level block
403    /// the write changed, and that was still as last saved, is moved in the
404    /// clean baseline too, and a tab dot that was current stays as it was.
405    ///
406    /// For an eCAD editor's first sight of an older board (`viewport::ecad`'s
407    /// `store`). Not [`Self::mark_clean`]: whatever else was unsaved before the
408    /// open stays unsaved. So the open changes nothing a document that needed no
409    /// bringing up would show — the honest [`Self::is_dirty`] and the dot alike.
410    pub fn absorb_open_write(&mut self, before: &serde_json::Value, after: &serde_json::Value, key: (u64, u64)) {
411        let rebased = self.saved_signature.as_deref().and_then(|saved| {
412            let mut base: serde_json::Value = serde_json::from_str(saved).ok()?;
413            let (base_map, before, after) = (base.as_object_mut()?, before.as_object()?, after.as_object()?);
414            for name in before.keys().chain(after.keys()) {
415                let (was, is) = (before.get(name), after.get(name));
416                if was == is || base_map.get(name) != was {
417                    continue;
418                }
419                match is {
420                    Some(value) => base_map.insert(name.clone(), value.clone()),
421                    None => base_map.remove(name),
422                };
423            }
424            Some(base.to_string())
425        });
426        if let Some(rebased) = rebased {
427            self.saved_signature = Some(rebased);
428        }
429        if self.marker_key == Some(key) {
430            self.marker_key = Some(self.edit_key());
431        }
432    }
433
434    /// How many times [`Self::refresh_dirty_marker`] has serialized the
435    /// document.
436    pub fn dirty_checks(&self) -> u64 {
437        self.dirty_checks
438    }
439
440    pub fn dirty_marker(&self) -> bool {
441        self.dirty_marker
442    }
443
444    /// The document's process-unique handle (identity across index shuffles).
445    pub fn id(&self) -> u64 {
446        self.id
447    }
448}
449
450/// Every open document + which one is active. Always holds AT LEAST ONE
451/// document: closing the last tab leaves a fresh untitled one in its place, so
452/// [`Documents::engine_mut`] is infallible and the shell never has to render a
453/// "no document" state that would be an empty viewport with extra steps.
454pub struct Documents {
455    open: Vec<Document>,
456    active: usize,
457    /// How a new tab's engine is built (platform runner + persisted settings).
458    new_engine: EngineFactory,
459}
460
461impl Documents {
462    /// A session holding one fresh document built by `new_engine`.
463    pub fn new(new_engine: EngineFactory) -> Self {
464        let first = Document::fresh(new_engine(), crate::document_class::DocumentClass::Normal);
465        Self {
466            open: vec![first],
467            active: 0,
468            new_engine,
469        }
470    }
471
472    /// A fresh engine for a document about to be opened — the caller loads into
473    /// it and hands the result to [`Self::open_document`].
474    ///
475    /// Seeded with the WHOLE of the session's settings, workbench included, so
476    /// a new tab continues what you were doing. The load that follows overrides
477    /// the workbench if the document names one (see the module header).
478    /// Build every later tab's engine through `wrap` as well: the app's live
479    /// PLM reconnect puts the new store's settings over the factory's.
480    pub fn wrap_engine_factory(&mut self, wrap: impl Fn(EngineState) -> EngineState + 'static) {
481        let old = std::mem::replace(&mut self.new_engine, Box::new(EngineState::new));
482        self.new_engine = Box::new(move || wrap(old()));
483    }
484
485    pub fn spawn_engine(&self) -> EngineState {
486        let mut engine = (self.new_engine)();
487        carry_settings(&self.engine().settings_json(), &mut engine, true);
488        // ...and the LIVE VIEWPORT SIZE, which decides how the document about to
489        // be loaded is FRAMED.
490        //
491        // A brand-new engine's camera is `ViewCamera::default()` — 800x600 —
492        // until the viewport tile draws it (`Viewport::show` is the only
493        // production `EngineState::resize`), and that is a whole frame after the
494        // load. But `load_model_and_fit` arms `pending_fit`, and the app pumps
495        // every open document at the TOP of the frame (`BrepApp::ui`), before
496        // the dock lays out and before the viewport draws. So when the run
497        // lands quickly — a warm process, a small document — the deferred
498        // `zoom_to_fit` fires against 800x600 and frames the model to the wrong
499        // ASPECT; when the run lands a frame later it fires against the real
500        // viewport. Same document, same window, two different cameras decided by
501        // a race with the background runner. Handing the engine the session's
502        // viewport size HERE, before anything is loaded into it, makes both
503        // orderings frame the same way. (The app-level one-shot at
504        // `BrepApp::first_run_framed` sits in the same place in the frame and is
505        // fixed by the same line.)
506        let (width, height) = self.viewport_size();
507        engine.resize(width, height);
508        engine
509    }
510
511    /// The session's 3D viewport in logical px, as the active engine last had it
512    /// from [`crate::viewport::Viewport::show`]. Every document in a session
513    /// draws into the SAME tile, so this is the size any new one will get.
514    fn viewport_size(&self) -> (f64, f64) {
515        let camera = &self.engine().camera;
516        (camera.width, camera.height)
517    }
518
519    pub fn engine(&self) -> &EngineState {
520        &self.open[self.active].engine
521    }
522
523    pub fn engine_mut(&mut self) -> &mut EngineState {
524        &mut self.open[self.active].engine
525    }
526
527    pub fn active(&self) -> &Document {
528        &self.open[self.active]
529    }
530
531    pub fn active_mut(&mut self) -> &mut Document {
532        &mut self.open[self.active]
533    }
534
535    pub fn active_index(&self) -> usize {
536        self.active
537    }
538
539    /// The ACTIVE document's handle — the shell compares it frame to frame to
540    /// notice a switch from ANY source (a tab click, a close, New, Open, Open
541    /// Part, a crash-recovery restore) with one check instead of a hook per site.
542    pub fn active_id(&self) -> u64 {
543        self.open[self.active].id
544    }
545
546    pub fn len(&self) -> usize {
547        self.open.len()
548    }
549
550    pub fn get(&self, index: usize) -> Option<&Document> {
551        self.open.get(index)
552    }
553
554    pub fn get_mut(&mut self, index: usize) -> Option<&mut Document> {
555        self.open.get_mut(index)
556    }
557
558    pub fn iter(&self) -> std::slice::Iter<'_, Document> {
559        self.open.iter()
560    }
561
562    pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Document> {
563        self.open.iter_mut()
564    }
565
566    /// Land every open document's finished runs; `true` while any of them still
567    /// has work in flight, so the frame loop keeps pumping.
568    ///
569    /// EVERY open document is pumped, not just the active one: a run belongs to
570    /// the engine that submitted it (each document owns its own runner), so a
571    /// run still in flight when the user switches tabs must land in ITS
572    /// document rather than be dropped or, worse, applied to whatever is on
573    /// screen. An idle document's pump is a couple of empty `try_recv`s.
574    ///
575    /// The kernel's parts library is one store for every tab, and a pump can
576    /// SUBMIT a run as well as land one: `EngineState::pump` re-runs after its
577    /// runner refused a run for a library it no longer holds, after a ports
578    /// edit moved a point, and when a mesh reconstruction lands (its STEP is
579    /// imported, which writes the store into the document and runs). A submit hands the runner whatever library is
580    /// in the store, and a run that lands mirrors the store it ran with into
581    /// its document's own `partsLibrary` block. So a tab in the BACKGROUND that
582    /// may do either — a run in flight, or a point move waiting — is pumped with
583    /// ITS library installed, and the active document's is put back right after.
584    /// (A STEP probe's reply only stashes what it found, and landing a run
585    /// submits nothing, so those need no more than `run_pending`.)
586    /// Pumped with the active one's, it resolved its components against another
587    /// document's parts, and the run that landed wrote that into its own file.
588    /// An idle background tab submits and lands nothing, so it costs nothing.
589    pub fn pump_all(&mut self) -> bool {
590        use brep_render::brep_kernel::parts_library_revision;
591        let mut work_in_flight = false;
592        for index in 0..self.open.len() {
593            let background = index != self.active;
594            let doc = &self.open[index];
595            let own_library = background
596                && (doc.engine.run_pending()
597                    || doc.engine.mesh_imports_pending()
598                    || doc.engine.history.ports_follow_pending());
599            if own_library {
600                install_library_of(doc);
601            }
602            let revision = parts_library_revision();
603            let doc = &mut self.open[index];
604            doc.engine.pump();
605            work_in_flight |= doc.engine.run_pending()
606                || doc.engine.queries_pending()
607                || doc.engine.mesh_imports_pending()
608                || doc.engine.step_probes_pending()
609                || doc.engine.topology_pending()
610                || doc.engine.sheet_lines_pending();
611            if own_library || (background && parts_library_revision() != revision) {
612                self.install_active_library();
613            }
614        }
615        work_in_flight
616    }
617
618    /// The tab holding the document stored under `name`, if any.
619    pub fn index_of(&self, name: &str) -> Option<usize> {
620        self.open
621            .iter()
622            .position(|doc| doc.name.as_deref() == Some(name))
623    }
624
625    /// Activate the tab already holding `name`; `false` when it is not open.
626    /// The "focus the existing tab" half of open-or-focus.
627    pub fn focus_named(&mut self, name: &str) -> bool {
628        match self.index_of(name) {
629            Some(index) => {
630                self.activate(index);
631                true
632            }
633            None => false,
634        }
635    }
636
637    /// Add `doc` as a new tab and make it active.
638    pub fn open_document(&mut self, mut doc: Document) -> usize {
639        if doc.name.is_none() {
640            let taken: Vec<u32> = self.open.iter().filter(|d| d.name.is_none()).map(|d| d.untitled).collect();
641            doc.untitled = (1..).find(|n| !taken.contains(n)).unwrap_or(1);
642        }
643        self.open.push(doc);
644        let index = self.open.len() - 1;
645        self.activate(index);
646        index
647    }
648
649    /// Number the untitled tabs 1, 2, … in tab order. Only for a session being
650    /// built before anyone has seen its tabs — the autosave restore, which adds
651    /// the recovered documents beside the boot seed and then closes the seed,
652    /// so the seed's "untitled" must not push a recovered tab to "untitled 2".
653    pub(crate) fn renumber_untitled(&mut self) {
654        for (n, doc) in self.open.iter_mut().filter(|d| d.name.is_none()).enumerate() {
655            doc.untitled = n as u32 + 1;
656        }
657    }
658
659    /// Make tab `index` active, carrying the session's display settings over to
660    /// it. Out-of-range indices are ignored (a stale click never panics).
661    pub fn activate(&mut self, index: usize) {
662        if index >= self.open.len() || index == self.active {
663            return;
664        }
665        let settings = self.open[self.active].engine.settings_json();
666        self.active = index;
667        carry_settings(&settings, &mut self.open[index].engine, false);
668        self.install_active_library();
669    }
670
671    /// Put the ACTIVE document's parts library into the kernel's store.
672    ///
673    /// That store is one per thread, not one per document: every tab's engine
674    /// resolves its ACOMP features against it and hands it to its runner, and a
675    /// document seeds it only as it loads. So opening a second document — a New
676    /// tab is enough — replaced it, and switching back left the assembly running
677    /// against the other document's parts. Its components lost every connection
678    /// point, and the next Diagram wire read "has no connection point named '2'
679    /// in port 'Pins'" and took the harness connections already drawn with it
680    /// (the eCAD workflow audit, issue 13). The block in the document's own
681    /// history is the library that document last ran with, so it is the one to
682    /// put back; an entry the store already holds unchanged is kept as it is,
683    /// heal included (`install_parts_library`).
684    fn install_active_library(&self) {
685        install_library_of(&self.open[self.active]);
686    }
687
688    /// Close tab `index`. Closing the LAST document leaves a fresh untitled one
689    /// (the always-one-document invariant); closing a tab before the active one
690    /// keeps the same document active at its new index.
691    ///
692    /// The caller is responsible for the unsaved-changes prompt — this is the
693    /// mechanical close (`panels::file` owns the confirmation).
694    pub fn close(&mut self, index: usize) {
695        if index >= self.open.len() {
696            return;
697        }
698        // Taken BEFORE the removal: if the closing tab was the active one, its
699        // settings are the session's and must survive into whatever is shown next.
700        let settings = self.open[self.active].engine.settings_json();
701        // Same reason as `spawn_engine`: a refill built from the factory starts
702        // on the 800x600 default camera, so carry the live viewport size too.
703        let viewport = self.viewport_size();
704        let closed_active = index == self.active;
705        self.open.remove(index);
706        // The replacement for a session closed down to nothing is a brand-new
707        // tab, so it takes the WHOLE of the session's settings — workbench
708        // included — exactly as `New` does through `spawn_engine`.
709        let refilled = self.open.is_empty();
710        if refilled {
711            let mut engine = (self.new_engine)();
712            carry_settings(&settings, &mut engine, true);
713            self.open.push(Document::fresh(engine, crate::document_class::DocumentClass::Normal));
714            self.active = 0;
715            self.open[0].engine.resize(viewport.0, viewport.1);
716        } else if closed_active {
717            self.active = index.min(self.open.len() - 1);
718        } else if index < self.active {
719            self.active -= 1;
720        }
721        if closed_active {
722            let active = self.active;
723            carry_settings(&settings, &mut self.open[active].engine, refilled);
724            self.install_active_library();
725        }
726    }
727
728    /// Refresh every tab's dirty dot (see [`Document::refresh_dirty_marker`]).
729    pub fn refresh_dirty_markers(&mut self) {
730        for doc in &mut self.open {
731            doc.refresh_dirty_marker();
732        }
733    }
734}
735
736/// Put `doc`'s own parts library — the block in its history, the library it
737/// last ran with — into the kernel's store (`Documents::install_active_library`).
738fn install_library_of(doc: &Document) {
739    let library = serde_json::from_value(doc.engine.history.parts_library().clone()).unwrap_or_default();
740    brep_render::brep_kernel::install_parts_library(&library);
741}
742
743/// Copy the SESSION-scoped display settings (theme, UI scale, colors, wireframe,
744/// projection, lod…) from a `settings_json()` snapshot into `target`.
745/// `with_workbench` is true ONLY when seeding a brand-new engine — see the
746/// module header for why activation must never carry it.
747fn carry_settings(settings_json: &str, target: &mut EngineState, with_workbench: bool) {
748    let Ok(mut settings) = serde_json::from_str::<serde_json::Value>(settings_json) else {
749        return;
750    };
751    if !with_workbench {
752        if let Some(object) = settings.as_object_mut() {
753            object.remove("workbench");
754        }
755    }
756    let _ = target.apply_settings_json(&settings.to_string());
757}
758
759/// Hand out the next document handle. A plain process counter: handles only ever
760/// need to be distinct WITHIN a session, and they never reach storage.
761fn next_document_id() -> u64 {
762    use std::sync::atomic::{AtomicU64, Ordering};
763    static NEXT: AtomicU64 = AtomicU64::new(1);
764    NEXT.fetch_add(1, Ordering::Relaxed)
765}
766