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 cam;
16pub mod live_link;
17pub mod owned_save;
18pub mod work_part;
19pub mod ecad;
20
21/// Builds a fresh engine for a new document — the platform runner, the viewcube,
22/// and the persisted display settings, all applied before anything is loaded
23/// into it. Injected by the shell so tests (which need the SYNCHRONOUS inline
24/// runner) can construct documents without a background thread.
25pub type EngineFactory = Box<dyn Fn() -> EngineState>;
26
27/// Whether a document may be edited. Every file-based document is
28/// [`Access::Editable`]. PLM permissions apply to [`Document::save_access`],
29/// independently of editing the session copy.
30///
31/// Held by the engine's history ([`brep_render::history::History::lock`]),
32/// which refuses every change to the saved document while it is read-only —
33/// the one door every edit passes, so no panel has to remember to ask.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum Access {
36    Editable,
37    /// `reason` explains the edit or save restriction to the user.
38    ReadOnly { reason: String },
39}
40
41impl Access {
42    pub fn read_only(reason: impl Into<String>) -> Self {
43        Access::ReadOnly { reason: reason.into() }
44    }
45
46    pub fn is_editable(&self) -> bool {
47        matches!(self, Access::Editable)
48    }
49}
50
51/// Empty history for loading fixtures and creating nonpart documents.
52pub const EMPTY_DOCUMENT: &str = r#"{"expressions":"","configurator":{},"features":[]}"#;
53
54/// Workbenches share a document format. Only part workspaces seed geometry
55/// references; assembly, drawing, harness and eCAD workspaces start empty.
56fn is_part_workbench(id: &str) -> bool {
57    !matches!(crate::workbench::resolve(id).id,
58        "assembly" | "drawing" | "wireHarness" | "diagram" | "pcb" | "symbol" | "pads")
59}
60
61/// Seed only a feature-empty part when explicitly creating a new part. PLM
62/// adoption uses this too, preserving the source's expressions and document blocks.
63pub fn seed_new_part_datum(engine: &mut EngineState) {
64    if engine.history_len() == 0 && is_part_workbench(&engine.settings.workbench) {
65        let id = engine.next_feature_id(&brep_render::features::feature_short_name("D"));
66        let mut params = engine.feature_default_params("D");
67        params["id"] = serde_json::json!(id);
68        engine.add_feature(&serde_json::json!({
69            "type": "D", "inputParams": params, "persistentData": {}
70        }).to_string()).expect("valid default datum");
71    }
72}
73
74/// ONE open model: the engine that owns it plus the identity the file lane needs
75/// — the store name it was opened from / saved to, and the clean baseline.
76pub struct Document {
77    /// The windowing-agnostic brain for THIS document (scene, camera, history,
78    /// settings, selection). Panels borrow it; it is never forked.
79    pub engine: EngineState,
80    /// The store identity (`None` = a never-saved "untitled" model). The RAW
81    /// identity, not the display name — a native dialog hands back a full path
82    /// and a plain Save must write back to it.
83    name: Option<String>,
84    /// Which untitled document this is while it has no `name`: 1 is plain
85    /// "untitled", 2 and up read "untitled 2", … — assigned by
86    /// [`Documents::open_document`], the lowest number no other open untitled
87    /// tab holds, so two new tabs never share a label (lane U, ruling P2).
88    untitled: u32,
89    /// PLM write permission is independent of editing the session copy.
90    plm_save_access: Option<Access>,
91    /// The PLM workspace folder `(id, name)` this tab was opened from (a link
92    /// in the workspace panel), for this session: the parts its Save makes
93    /// PLM items for are linked there. `None`: opened another way, so the
94    /// items go to the top of the workspace.
95    plm_folder: Option<(String, String)>,
96    /// A hash of the stored text this tab was opened from (or last reloaded
97    /// from): while the store still holds those bytes, nobody has saved it
98    /// since, and there is nothing to take — see [`Self::holds_stored`].
99    opened_from: Option<u64>,
100    /// The model request JSON as of the last New / Open / Save — the baseline
101    /// the dirty flag compares the live history against.
102    saved_signature: Option<String>,
103    /// The baseline's top-level parts library as `{entry: (sourceKey,
104    /// sourceSignature)}`, taken from the engine whenever the baseline is (a
105    /// handful of entries, where parsing `saved_signature` is the whole
106    /// document); `None` when the baseline came from elsewhere, so
107    /// [`Self::saved_sources`] parses it.
108    saved_sources: Option<SavedSources>,
109    /// Moves every time `saved_signature` is replaced (a cache key for what
110    /// is derived from the baseline).
111    baseline_generation: u64,
112    /// [`Self::content_key`] when the baseline was taken from the document
113    /// itself (`None` when it came from elsewhere): while the key is unmoved,
114    /// the document IS its baseline.
115    baseline_content: Option<ContentKey>,
116    /// While the document is as it was opened, what the runs of the open write
117    /// back is part of the open: see [`Document::settle_open_runs`]. `None`
118    /// once the user edits, or once anything else writes the document.
119    settle: Option<Settle>,
120    /// After a write that discarded the user's edits (`Take their version`),
121    /// the runs it causes re-solve the document back towards its clean
122    /// baseline, but not to the same bits: see [`Document::converge_runs`].
123    /// `None` once the user edits, once it converged, or after
124    /// [`CONVERGE_RUNS`] runs.
125    converge: Option<(Settle, u32)>,
126    /// The cached tab-strip dirty dot. See [`Document::dirty_marker`] for why
127    /// this is not simply `is_dirty()`.
128    dirty_marker: bool,
129    /// The [`Document::edit_key`] `dirty_marker` was last refreshed at.
130    marker_key: Option<(u64, u64)>,
131    /// How many times the marker has serialized the document to recompute
132    /// itself — the per-frame cost [`Document::refresh_dirty_marker`] bounds.
133    dirty_checks: u64,
134    /// A process-unique handle, so the shell can notice "the active document is
135    /// a different one now" without comparing indices (closing a tab BEFORE the
136    /// active one moves the index without changing the document).
137    id: u64,
138    /// This document's eCAD editors — Diagram, PCB, Symbol and Pads — kept
139    /// beside its engine, so each tab has its own and a tab switch keeps their
140    /// pan, zoom and view: a switch rebuilds the PANELS
141    /// (`reset_document_scoped_state`), never a document. Built with the
142    /// document; all four measured 0.65 ms (release), so no document waits for
143    /// its first eCAD workbench to have them.
144    pub ecad: crate::workbench::ecad::Editors,
145    /// Each editor's standing with its block of this document's history: what
146    /// takes BREP's undo and redo to the editor, and the editor's edits into the
147    /// document ([`ecad::BlockSync`]).
148    pub ecad_blocks: [ecad::BlockSync; 4],
149    pub ecad_parts_revision: Option<u64>,
150    /// The broken-reference lines `crate::panels::ecad_parts::sync` has
151    /// already put in front of the user. A device whose part is missing is a
152    /// STANDING state and the revision bumps on every edit, so without this it
153    /// would toast on every keystroke — the same rule
154    /// [`ecad::BlockSync::unreported`] keeps for an UNLINKED component.
155    pub ecad_parts_reported: Vec<String>,
156    /// What `crate::panels::ecad_parts::follow_occurrences` last saw of the
157    /// assembly's occurrences; `None` until the first frame looks.
158    pub ecad_occurrences: Option<crate::panels::ecad_parts::OccurrenceMark>,
159    /// The parts-library entries whose saved part has moved on since this
160    /// document took it — the shell's `UpdateComponents` list for the active
161    /// document, handed over each frame so the Diagram and PCB workbenches can
162    /// mark the components placed from them and offer the update where the
163    /// user is looking (`viewport::ecad`).
164    pub ecad_outdated: Vec<String>,
165    /// Set by the eCAD host's Update parts button; the shell runs the update
166    /// (`UpdateComponents::run`, one undo step) on its next frame.
167    pub ecad_update_requested: bool,
168    /// The history's refused-edit count as of the last
169    /// [`Document::take_refused`], so each refusal is told once.
170    refused_seen: u64,
171    /// The [`Document::edit_key`] and baseline generation the
172    /// `insertedCopies` block was last brought up to date at
173    /// (`owned_save::note_inserted_copies`).
174    pub(crate) inserted_noted: Option<((u64, u64), u64)>,
175}
176
177/// What the dirty flag compares: the saved document ([`EngineState::history_request_json`])
178/// WITHOUT its `workbench` and derived `thumbnail`.
179///
180/// The workbench is saved, so a file reopens where it was left, but it is view state
181/// and not work. Counting it made Close ask to discard documents nobody had edited:
182/// open a board saved in PCB, look at its Diagram, and the saved document differed
183/// from the file in exactly `/workbench` (the eCAD re-audit's B5, measured in lane X).
184/// The tab's dot never counted it ([`Document::edit_key`] does not move for it), so
185/// the prompt came with no dot. Now the two agree, and Save still writes the field.
186///
187/// Built as `history_request_json` builds the saved document, less the one field,
188/// rather than by parsing that again: the dot recomputes this on every run-moving
189/// edit, and a second parse of a megabyte assembly doubles it. A test holds the two
190/// together (`the_signature_is_the_saved_document_less_its_workbench`).
191/// A parts library's top-level entries as `{entry: (sourceKey, sourceSignature)}`.
192pub(crate) type SavedSources = std::collections::HashMap<String, (String, String)>;
193
194fn sources_of(library: &serde_json::Value) -> SavedSources {
195    library
196        .as_object()
197        .map(|entries| {
198            entries
199                .iter()
200                .map(|(name, entry)| {
201                    let field = |key: &str| entry[key].as_str().unwrap_or_default().to_string();
202                    (name.clone(), (field("sourceKey"), field("sourceSignature")))
203                })
204                .collect()
205        })
206        .unwrap_or_default()
207}
208
209fn raw_hash(text: &str) -> u64 {
210    use std::hash::{Hash, Hasher};
211    let mut hasher = std::collections::hash_map::DefaultHasher::new();
212    text.hash(&mut hasher);
213    hasher.finish()
214}
215
216fn signature(engine: &EngineState) -> String {
217    signature_without(engine, None)
218}
219
220/// What [`Document::settle_open_runs`] last saw: the user's edit serial at the
221/// clean baseline, and the applied-run generation and history revision at the
222/// last check.
223#[derive(Clone, Copy, Debug, PartialEq)]
224struct Settle {
225    serial: u64,
226    generation: u64,
227    revision: u64,
228}
229
230impl Settle {
231    fn of(engine: &EngineState) -> Self {
232        Self {
233            serial: engine.history.edit_serial(),
234            generation: engine.applied_generation(),
235            revision: engine.history.revision(),
236        }
237    }
238}
239
240/// What the document holds: its runs and edits ([`Document::edit_key`]) and
241/// its object metadata, which moves neither.
242type ContentKey = ((u64, u64), u64);
243
244fn content_key(engine: &EngineState) -> ContentKey {
245    ((engine.applied_generation(), engine.history_revision()), engine.metadata.stamp())
246}
247
248/// How many applied runs [`Document::converge_runs`] watches.
249const CONVERGE_RUNS: u32 = 16;
250/// Relative tolerance of [`nearly_equal`] (absolute below 1).
251const CONVERGE_TOLERANCE: f64 = 1e-6;
252
253/// The document keys [`Document::converge_runs`] leaves out of its compare:
254/// ONLY keys whose value in the history never reaches a saved file, because
255/// the saved form (`EngineState::history_request_json`) overwrites them from
256/// the session: the rendered preview, and the active workbench (stamped into
257/// the history by any write-back of that form, e.g. a Work part's return).
258/// Adding a key here lets a difference in it read clean, so a new entry needs
259/// the same argument.
260const CONVERGE_DERIVED_DOCUMENT_KEYS: &[&str] = &["thumbnail", "workbench"];
261/// The embedded-copy keys it leaves out, at every depth, on the same terms:
262/// the copy's cached geometry, which a refresh blanks and the next run re-heals.
263const CONVERGE_DERIVED_COPY_KEYS: &[&str] = &["snapshot"];
264
265/// `document` without [`CONVERGE_DERIVED_DOCUMENT_KEYS`] and, on every
266/// embedded copy, [`CONVERGE_DERIVED_COPY_KEYS`]. Everything else is compared.
267fn without_derived(mut document: serde_json::Value) -> serde_json::Value {
268    fn strip(document: &mut serde_json::Value) {
269        if let Some(entries) = document.get_mut("partsLibrary").and_then(serde_json::Value::as_object_mut) {
270            for entry in entries.values_mut() {
271                if let Some(entry) = entry.as_object_mut() {
272                    for key in CONVERGE_DERIVED_COPY_KEYS {
273                        entry.remove(*key);
274                    }
275                }
276                strip(&mut entry["document"]);
277            }
278        }
279    }
280    if let Some(object) = document.as_object_mut() {
281        for key in CONVERGE_DERIVED_DOCUMENT_KEYS {
282            object.remove(*key);
283        }
284    }
285    strip(&mut document);
286    document
287}
288
289/// `a` and `b` alike but for floating-point numbers within
290/// [`CONVERGE_TOLERANCE`]. Keys, strings, booleans and integers (counts, ids)
291/// compare exactly.
292fn nearly_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
293    use serde_json::Value;
294    match (a, b) {
295        (Value::Number(x), Value::Number(y)) if x.is_f64() && y.is_f64() => match (x.as_f64(), y.as_f64()) {
296            (Some(x), Some(y)) => (x - y).abs() <= CONVERGE_TOLERANCE * x.abs().max(y.abs()).max(1.0),
297            _ => false,
298        },
299        (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(x, y)| nearly_equal(x, y)),
300        (Value::Object(x), Value::Object(y)) => {
301            x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| nearly_equal(v, w)))
302        }
303        _ => a == b,
304    }
305}
306
307/// The saved-content signature with one document block left out — the
308/// baseline of a copy saved WITHOUT that block (a family member saved as a new
309/// part drops `familySource` on the copy), in the same canonical form.
310fn signature_without(engine: &EngineState, without: Option<&str>) -> String {
311    let mut document: serde_json::Value =
312        serde_json::from_str(&engine.history.request_json()).unwrap_or_else(|_| serde_json::json!({}));
313    if let Some(object) = document.as_object_mut() {
314        if let Some(key) = without {
315            // In order, as the document drops it (`set_document_block_no_undo`):
316            // `remove` would move the last key into the gap.
317            object.shift_remove(key);
318        }
319        if !engine.metadata.is_empty() {
320            object.insert("metadata".into(), engine.metadata.to_json());
321        }
322    }
323    without_run_record(&mut document);
324    if let Some(object) = document.as_object_mut() {
325        object.shift_remove(owned_save::INSERTED_COPIES_BLOCK);
326    }
327    document.to_string()
328}
329
330/// Drop what the mate solver writes into each assembly constraint as the
331/// record of its last RUN — `persistentData.error`, `.kernel` (residual,
332/// iterations, rank, …) and `.message`, which quotes the residual — so an
333/// edit and its undo compare equal although the re-solve after the undo
334/// started from the solved pose and took 0 iterations instead of 4. What a
335/// solve decides (`status`, `satisfied`, first-solve choices) still counts.
336/// The library's saved rows go too ([`without_saved_rows`]).
337/// Comparison only: the saved file keeps the record.
338pub(crate) fn without_run_record(document: &mut serde_json::Value) {
339    without_saved_rows(document);
340    let Some(constraints) = document
341        .get_mut("assembly")
342        .and_then(|assembly| assembly.get_mut("constraints"))
343        .and_then(serde_json::Value::as_array_mut)
344    else {
345        return;
346    };
347    for constraint in constraints {
348        if let Some(record) = constraint.get_mut("persistentData").and_then(serde_json::Value::as_object_mut) {
349            for key in ["error", "kernel", "message"] {
350                record.remove(key);
351            }
352        }
353    }
354}
355
356/// Drop each library entry's `nestedRows`, at every depth: the rows of the
357/// run that made the entry's snapshot, which a run writes back and re-stamps
358/// (the kernel's `SavedRows`), the same kind of record as the solver's.
359fn without_saved_rows(document: &mut serde_json::Value) {
360    let Some(entries) = document.get_mut("partsLibrary").and_then(serde_json::Value::as_object_mut) else {
361        return;
362    };
363    for entry in entries.values_mut() {
364        if let Some(object) = entry.as_object_mut() {
365            object.shift_remove("nestedRows");
366        }
367        if let Some(child) = entry.get_mut("document") {
368            without_saved_rows(child);
369        }
370    }
371}
372
373/// One [`ecad::BlockSync`] per eCAD block, none of them pulled yet.
374fn ecad_blocks() -> [ecad::BlockSync; 4] {
375    use ecad::Block;
376    [Block::Diagram, Block::Pcb, Block::Symbol, Block::Pads].map(ecad::BlockSync::new)
377}
378
379impl Document {
380    /// Create a fresh document. Part workbenches start with the same ordinary,
381    /// schema-seeded datum a user adds from the palette. Existing content must
382    /// use `new` instead: opening a file never changes its feature history.
383    pub fn fresh(mut engine: EngineState, class: crate::document_class::DocumentClass) -> Self {
384        engine.set_history_json(EMPTY_DOCUMENT).expect("valid empty document");
385        crate::document_class::set_document_class(&mut engine, class);
386        seed_new_part_datum(&mut engine);
387        Self::new(engine)
388    }
389
390    /// Wrap `engine` as a document, taking its CURRENT model as the clean
391    /// baseline — so a freshly loaded (or freshly emptied) document is clean and
392    /// the first edit marks it dirty.
393    pub fn new(engine: EngineState) -> Self {
394        let saved_signature = Some(signature(&engine));
395        let baseline_content = Some(content_key(&engine));
396        let saved_sources = Some(sources_of(engine.history.parts_library()));
397        let settle = Some(Settle::of(&engine));
398        Self {
399            engine,
400            name: None,
401            plm_save_access: None,
402            plm_folder: None,
403            opened_from: None,
404            baseline_content,
405            saved_signature,
406            saved_sources,
407            baseline_generation: 0,
408            settle,
409            converge: None,
410            dirty_marker: false,
411            marker_key: None,
412            dirty_checks: 0,
413            id: next_document_id(),
414            untitled: 1,
415            ecad: crate::workbench::ecad::Editors::new(),
416            ecad_blocks: ecad_blocks(),
417            ecad_parts_revision: None,
418            ecad_parts_reported: Vec::new(),
419            ecad_occurrences: None,
420            ecad_outdated: Vec::new(),
421            ecad_update_requested: false,
422            refused_seen: 0,
423            inserted_noted: None,
424        }
425    }
426
427    /// Wrap `engine` as a document RESTORED from the autosave blob
428    /// (`crate::recovery`): it carries the store identity it had (`None` for
429    /// an untitled one) and NO clean baseline, so it is dirty from its first
430    /// frame — the work it holds is exactly what was never saved, and the close
431    /// guard and the tab dot must say so until a Save writes it somewhere.
432    pub fn recovered(engine: EngineState, name: Option<String>) -> Self {
433        Self {
434            engine,
435            name,
436            plm_save_access: None,
437            plm_folder: None,
438            opened_from: None,
439            saved_signature: None,
440            saved_sources: None,
441            baseline_generation: 0,
442            baseline_content: None,
443            settle: None,
444            converge: None,
445            dirty_marker: true,
446            marker_key: None,
447            dirty_checks: 0,
448            id: next_document_id(),
449            untitled: 1,
450            ecad: crate::workbench::ecad::Editors::new(),
451            ecad_blocks: ecad_blocks(),
452            ecad_parts_revision: None,
453            ecad_parts_reported: Vec::new(),
454            ecad_occurrences: None,
455            ecad_outdated: Vec::new(),
456            ecad_update_requested: false,
457            refused_seen: 0,
458            inserted_noted: None,
459        }
460    }
461
462    /// Whether this document may be edited ([`Access`]).
463    pub fn access(&self) -> Access {
464        match self.engine.history.locked() {
465            None => Access::Editable,
466            Some(reason) => Access::read_only(reason),
467        }
468    }
469
470    /// Make the document editable or read-only. Read-only locks the engine's
471    /// history, so its saved bytes cannot change until it is editable again.
472    pub fn set_access(&mut self, access: Access) {
473        match access {
474            Access::Editable => self.engine.history.unlock(),
475            Access::ReadOnly { reason } => self.engine.history.lock(reason),
476        }
477    }
478
479    /// Saving to the current PLM revision may be restricted while session
480    /// editing remains available. A local Save As clears this restriction.
481    pub fn save_access(&self) -> Access {
482        self.plm_save_access.clone().unwrap_or_else(|| self.access())
483    }
484
485    pub(crate) fn has_plm_save_access(&self) -> bool { self.plm_save_access.is_some() }
486
487    pub fn set_plm_save_access(&mut self, access: Access) {
488        self.plm_save_access = Some(access);
489        self.engine.history.unlock();
490    }
491
492    /// The sentence to show when an edit was refused since the last call —
493    /// `None` when nothing was. Called once a frame by the shell, which toasts it.
494    pub fn take_refused(&mut self) -> Option<String> {
495        let refused = self.engine.history.refused_user_edits();
496        if refused == self.refused_seen {
497            return None;
498        }
499        self.refused_seen = refused;
500        let reason = self.engine.history.locked()?;
501        Some(format!("This document is read-only: {reason}."))
502    }
503
504    /// The raw store identity, or `None` for a never-saved document.
505    pub fn name(&self) -> Option<&str> {
506        self.name.as_deref()
507    }
508
509    pub fn set_name(&mut self, name: Option<String>) {
510        if self.name != name { self.plm_save_access = None; }
511        self.name = name;
512    }
513
514    /// The tab label: PLM part/revision, the bare file name, or `untitled`
515    /// (`untitled 2`, … for the second and later untitled tabs).
516    pub fn title(&self) -> String {
517        match &self.name {
518            Some(name) => crate::panels::plm_host::revision_of(name)
519                .map(|(part, revision)| format!("{part}/{revision}"))
520                .unwrap_or_else(|| crate::store::model_display_name(name)),
521            None => self.untitled_title(),
522        }
523    }
524
525    /// What an untitled document is called, and what Save As offers for it.
526    pub fn untitled_title(&self) -> String {
527        match self.untitled {
528            0 | 1 => "untitled".to_string(),
529            n => format!("untitled {n}"),
530        }
531    }
532
533    /// Snapshot the current model as the clean baseline (after New / Open /
534    /// Save, or a `?loadModel=` boot-load).
535    pub fn mark_clean(&mut self) {
536        self.saved_signature = Some(signature(&self.engine));
537        self.saved_sources = Some(sources_of(self.engine.history.parts_library()));
538        self.baseline_generation += 1;
539        self.baseline_content = Some(content_key(&self.engine));
540        self.dirty_marker = false;
541        self.marker_key = Some(self.edit_key());
542        self.settle = Some(Settle::of(&self.engine));
543    }
544
545    /// Keep taking what the open's runs write back as part of the open, from
546    /// here (a check on open that wrote the document itself and absorbed that
547    /// write, [`Self::absorb_open_write`], re-arms so the run it causes is
548    /// absorbed too).
549    pub(crate) fn arm_open_settle(&mut self) {
550        self.settle = Some(Settle::of(&self.engine));
551    }
552
553    /// A document's first runs after it is opened write back into it: the
554    /// first solve of a mate stored unsolved (the pose and the solve's
555    /// status), and the snapshots of embedded parts stored without one (the
556    /// PLM import blanks them). None of that is the user's change, so closing
557    /// the document must not ask to save it.
558    ///
559    /// Called once a frame. While the user has not edited the document since
560    /// it was opened (its edit serial is unchanged), a run that has APPLIED
561    /// moves the clean baseline to the document as that run left it. The rule
562    /// is conservative where it cannot tell: the document changing while no
563    /// run applied (a write that took no undo step, which could be the user's)
564    /// stops the settling for good, and the document stays as dirty as it is.
565    /// The worst case is one needless save prompt, never an unsaved edit that
566    /// reads saved.
567    pub fn settle_open_runs(&mut self) {
568        let Some(last) = self.settle else {
569            return;
570        };
571        let now = Settle::of(&self.engine);
572        if now.serial != last.serial || (now.generation == last.generation && now.revision != last.revision) {
573            self.settle = None;
574            return;
575        }
576        if now.generation == last.generation || self.engine.run_pending() {
577            return;
578        }
579        if now.revision != last.revision {
580            self.saved_signature = Some(signature(&self.engine));
581            self.saved_sources = Some(sources_of(self.engine.history.parts_library()));
582            self.baseline_generation += 1;
583            self.baseline_content = Some(content_key(&self.engine));
584            self.marker_key = None;
585        }
586        self.settle = Some(now);
587    }
588
589    /// Watch the runs a write that discarded the user's edits causes: see
590    /// [`Self::converge_runs`].
591    pub(crate) fn arm_converge(&mut self) {
592        self.converge = Some((Settle::of(&self.engine), 0));
593    }
594
595    /// Called once a frame. After `Self::arm_converge`, once a run has
596    /// applied and the user has not edited since: when the document equals its
597    /// clean baseline to within `CONVERGE_TOLERANCE` in every number (mates
598    /// re-solved the poses the discarded edit had moved, to ~1e-9), the solved
599    /// document becomes the baseline, as a Save's would. A real change is far
600    /// outside the tolerance and leaves the tab changed.
601    pub fn converge_runs(&mut self) {
602        let Some((last, runs)) = self.converge else {
603            return;
604        };
605        let now = Settle::of(&self.engine);
606        if now.serial != last.serial || runs >= CONVERGE_RUNS {
607            self.converge = None;
608            return;
609        }
610        if now.generation == last.generation || self.engine.run_pending() {
611            return;
612        }
613        let current = signature(&self.engine);
614        let near = self.saved_signature.as_deref().is_some_and(|saved| {
615            match (serde_json::from_str::<serde_json::Value>(saved), serde_json::from_str::<serde_json::Value>(&current)) {
616                (Ok(saved), Ok(current)) => nearly_equal(&without_derived(saved), &without_derived(current)),
617                _ => false,
618            }
619        });
620        if near {
621            if self.saved_signature.as_deref() != Some(current.as_str()) {
622                self.saved_signature = Some(current);
623                self.saved_sources = Some(sources_of(self.engine.history.parts_library()));
624                self.baseline_generation += 1;
625                self.baseline_content = Some(content_key(&self.engine));
626                self.marker_key = None;
627            }
628            self.converge = None;
629        } else {
630            self.converge = Some((now, runs + 1));
631        }
632    }
633
634    pub(crate) fn save_signature(&self) -> String { signature(&self.engine) }
635
636    /// The workspace folder this tab was opened from ([`Self::plm_folder`]'s field).
637    pub(crate) fn plm_folder(&self) -> Option<&(String, String)> {
638        self.plm_folder.as_ref()
639    }
640
641    /// The tab was opened (or reloaded) from the stored `text`.
642    pub(crate) fn note_opened_from(&mut self, text: &str) {
643        self.opened_from = Some(raw_hash(text));
644    }
645
646    /// Whether the store's `text` is still the text this tab was opened from:
647    /// a byte compare, not the document's, which the open's own runs have
648    /// written back into (snapshots, a first mate solve).
649    pub(crate) fn holds_stored(&self, text: &str) -> bool {
650        self.opened_from == Some(raw_hash(text))
651    }
652
653    pub(crate) fn set_plm_folder(&mut self, folder: Option<(String, String)>) {
654        self.plm_folder = folder;
655    }
656
657    /// The clean baseline's top-level parts library, `{entry: (sourceKey,
658    /// sourceSignature)}` (empty for a recovered, never-saved tab), without
659    /// parsing the saved document when the baseline was taken from the engine.
660    pub(crate) fn saved_sources(&self) -> SavedSources {
661        match &self.saved_sources {
662            Some(sources) => sources.clone(),
663            None => self
664                .saved_signature
665                .as_deref()
666                .and_then(|saved| serde_json::from_str::<serde_json::Value>(saved).ok())
667                .map(|saved| sources_of(&saved["partsLibrary"]))
668                .unwrap_or_default(),
669        }
670    }
671
672    /// The clean baseline as a document (`None` for a recovered, never-saved
673    /// one): what this tab last opened or saved.
674    pub(crate) fn saved_document(&self) -> Option<serde_json::Value> {
675        serde_json::from_str(self.saved_signature.as_deref()?).ok()
676    }
677
678    /// Take `baseline` (what [`Self::saved_document`] returned, changed the
679    /// way the document itself just was) as the clean baseline: a write that
680    /// is not the user's change adds no unsaved change, and the user's own
681    /// unsaved changes stay unsaved. `key` is [`Self::edit_key`] before the write.
682    pub(crate) fn rebase_saved_document(&mut self, baseline: &serde_json::Value, key: (u64, u64)) {
683        self.saved_signature = Some(baseline.to_string());
684        // Not the engine's own state: the sources are read from it on demand.
685        self.saved_sources = None;
686        self.baseline_generation += 1;
687        self.baseline_content = None;
688        if self.marker_key == Some(key) {
689            self.marker_key = Some(self.edit_key());
690        }
691    }
692
693    /// [`Self::save_signature`] of the document with one block left out: what a
694    /// copy saved without that block has as its clean baseline.
695    pub(crate) fn save_signature_without(&self, block: &str) -> String {
696        signature_without(&self.engine, Some(block))
697    }
698
699    pub(crate) fn mark_saved_signature(&mut self, saved: String) {
700        self.saved_signature = Some(saved);
701        self.saved_sources = None;
702        self.baseline_generation += 1;
703        self.baseline_content = None;
704        self.marker_key = None;
705        self.refresh_dirty_marker();
706    }
707
708    /// Restored from crash recovery and not saved since: it has no clean
709    /// baseline, and what differs from its stored document is the user's
710    /// unsaved work, not an older copy to refresh.
711    pub fn is_recovered(&self) -> bool {
712        self.saved_signature.is_none()
713    }
714
715    /// Its Save did not reach the store after all (a write-behind backend
716    /// reported the failure once `write` had returned `Ok`): unsaved again, so
717    /// the close prompt asks and Save writes it. A later Save that lands takes
718    /// a new baseline as any Save does.
719    pub(crate) fn mark_write_failed(&mut self) {
720        self.mark_saved_signature(String::new());
721    }
722
723    /// Dirty = the live model differs from the last saved/opened baseline.
724    /// (Rolling the history does NOT change the request document, so navigating
725    /// steps never marks dirty — only real edits/add/delete/reorder do.)
726    /// The workbench the document is in is not part of it (`signature`).
727    ///
728    /// HONEST but not free: it serializes the whole request document, which for
729    /// an assembly carrying an embedded parts library is megabytes. Every place
730    /// where being wrong would cost the user work — the close prompt, Save —
731    /// calls THIS. The per-frame tab dot calls [`Self::dirty_marker`] instead.
732    pub fn is_dirty(&self) -> bool {
733        match &self.saved_signature {
734            Some(saved) => *saved != signature(&self.engine),
735            None => true,
736        }
737    }
738
739    /// What moves when the saved document can have changed: the APPLIED RUN
740    /// generation and the history's revision.
741    ///
742    /// Both, because each misses edits the other sees. An ordinary edit (a
743    /// param change, add, delete, reorder, undo) re-runs the history, but a
744    /// block write runs nothing — a drawing sheet, a PMI label move, an eCAD
745    /// edit, a BOM part-attribute edit — and moves only the revision, which
746    /// every write to the SAVED document moves, the `partsLibrary` block
747    /// included (it is held in a field beside the document and both its setters
748    /// tick the revision themselves). An object-metadata change (a workbench
749    /// change is not an unsaved change at all, `signature`) moves neither and can
750    /// leave the dot one edit behind: a marker briefly optimistic, never a lost
751    /// edit, since [`Self::is_dirty`] is recomputed honestly at the close prompt.
752    pub fn edit_key(&self) -> (u64, u64) {
753        (self.engine.applied_generation(), self.engine.history_revision())
754    }
755
756    /// Whether the document is still the baseline it took from itself (an
757    /// Open, a Save, or the open's runs settling).
758    pub(crate) fn is_its_baseline(&self) -> bool {
759        self.baseline_content == Some(content_key(&self.engine))
760    }
761
762    /// The cached dirty flag the tab strip draws. Recomputing it serializes
763    /// the whole document, so it is recomputed only when the
764    /// [`Self::edit_key`] moved, and, once the tab is marked unsaved, only when
765    /// a RUN moved it (an ordinary edit, an undo, a redo).
766    ///
767    /// The second rule is for drags. Dragging a block (a PMI label, a view on
768    /// a sheet) writes the document every frame and moves only the revision;
769    /// recomputing there serialized the document on every frame, 2.6 ms in a
770    /// release build on a 1.3 MB assembly. An unsaved tab stays unsaved while
771    /// only the revision moves, so a drag pays one serialization, on the frame
772    /// the dot lights. Param-slider drags re-run the history each frame and
773    /// still recompute each frame, as they always did.
774    ///
775    /// The residue is deliberate: a block edit that lands exactly back on the
776    /// saved bytes keeps the dot on until the next run or Save. Showing unsaved
777    /// when clean costs one needless Save; the opposite loses work, which is
778    /// the bug the revision key fixed. [`Self::is_dirty`] stays honest at the
779    /// close prompt either way.
780    pub fn refresh_dirty_marker(&mut self) {
781        let key = self.edit_key();
782        let previous = self.marker_key.replace(key);
783        if previous == Some(key) {
784            return;
785        }
786        let run_moved = previous.map_or(true, |(generation, _)| generation != key.0);
787        if self.dirty_marker && !run_moved {
788            return;
789        }
790        self.dirty_checks += 1;
791        self.dirty_marker = self.is_dirty();
792    }
793
794    /// Take a write that OPENING the document made — `before` and `after` are
795    /// the saved document either side of it, `key` the [`Self::edit_key`] before
796    /// it — as though the file had been saved that way: every top-level block
797    /// the write changed, and that was still as last saved, is moved in the
798    /// clean baseline too, and a tab dot that was current stays as it was.
799    ///
800    /// For an eCAD editor's first sight of an older board (`viewport::ecad`'s
801    /// `store`). Not [`Self::mark_clean`]: whatever else was unsaved before the
802    /// open stays unsaved. So the open changes nothing a document that needed no
803    /// bringing up would show — the honest [`Self::is_dirty`] and the dot alike.
804    pub fn absorb_open_write(&mut self, before: &serde_json::Value, after: &serde_json::Value, key: (u64, u64)) {
805        // Compared as the baseline was taken ([`signature`]): without the
806        // run's records, or a block holding saved rows never matches.
807        let compared = |document: &serde_json::Value| {
808            let mut document = document.clone();
809            without_run_record(&mut document);
810            document
811        };
812        let (before, after) = (&compared(before), &compared(after));
813        let rebased = self.saved_signature.as_deref().and_then(|saved| {
814            let mut base: serde_json::Value = serde_json::from_str(saved).ok()?;
815            let (base_map, before, after) = (base.as_object_mut()?, before.as_object()?, after.as_object()?);
816            for name in before.keys().chain(after.keys()) {
817                let (was, is) = (before.get(name), after.get(name));
818                if was == is || base_map.get(name) != was {
819                    continue;
820                }
821                match is {
822                    Some(value) => base_map.insert(name.clone(), value.clone()),
823                    None => base_map.shift_remove(name),
824                };
825            }
826            Some(base.to_string())
827        });
828        if let Some(rebased) = rebased {
829            self.saved_signature = Some(rebased);
830            self.saved_sources = None;
831            self.baseline_generation += 1;
832            self.baseline_content = None;
833        }
834        if self.marker_key == Some(key) {
835            self.marker_key = Some(self.edit_key());
836        }
837    }
838
839    /// How many times [`Self::refresh_dirty_marker`] has serialized the
840    /// document.
841    pub fn dirty_checks(&self) -> u64 {
842        self.dirty_checks
843    }
844
845    pub fn dirty_marker(&self) -> bool {
846        self.dirty_marker
847    }
848
849    /// The document's process-unique handle (identity across index shuffles).
850    pub fn id(&self) -> u64 {
851        self.id
852    }
853}
854
855/// Every open document + which one is active. Always holds AT LEAST ONE
856/// document: closing the last tab leaves a fresh untitled one in its place, so
857/// [`Documents::engine_mut`] is infallible and the shell never has to render a
858/// "no document" state that would be an empty viewport with extra steps.
859pub struct Documents {
860    work_part: Option<work_part::WorkPart>,
861    /// An ancestor sub-assembly to make the Work part once its assembly's run
862    /// lands: `(assembly tab id, component path, mode)`
863    /// ([`Documents::work_part_up_to`]).
864    work_part_next: Option<(u64, String, work_part::WorkPartMode)>,
865    /// The Work part readback's `context` map (every assembly solid outside
866    /// the work occurrence, in assembly coordinates), by (component path,
867    /// host scene fingerprint): automation reads it every frame.
868    work_part_context: std::cell::RefCell<Option<((String, u64), serde_json::Value)>>,
869    /// [`Documents::owned_modified_keys_of`]'s last answer: `(tab id, edit
870    /// key, baseline length)` and the keys.
871    owned_cache: std::cell::RefCell<Option<owned_save::OwnedCacheKey>>,
872    /// [`Documents::synced_signatures`]'s last answer, by (tab id, baseline).
873    synced_cache: std::cell::RefCell<Option<owned_save::SyncedCache>>,
874    /// The signature each entry added after a tab's baseline was first seen
875    /// with, by ((tab id, baseline generation), entry name).
876    learned_signatures: std::cell::RefCell<std::collections::HashMap<((u64, u64), String), String>>,
877    /// The newest state of every source open documents hold ([`live_link`]).
878    live: live_link::LiveLinks,
879    /// The store's spelling of a document identity
880    /// ([`crate::store::ModelStore::canonicalizer`]): a tab and a `sourceKey`
881    /// naming one file the same way.
882    canon: crate::store::Canonicalizer,
883    open: Vec<Document>,
884    active: usize,
885    /// How a new tab's engine is built (platform runner + persisted settings).
886    new_engine: EngineFactory,
887}
888
889impl Documents {
890    /// A session holding one fresh document built by `new_engine`.
891    pub fn new(new_engine: EngineFactory) -> Self {
892        let first = Document::fresh(new_engine(), crate::document_class::DocumentClass::Normal);
893        Self {
894            open: vec![first],
895            work_part: None,
896            work_part_next: None,
897            work_part_context: Default::default(),
898            owned_cache: Default::default(),
899            synced_cache: Default::default(),
900            learned_signatures: Default::default(),
901            live: Default::default(),
902            canon: std::rc::Rc::new(str::to_string),
903            active: 0,
904            new_engine,
905        }
906    }
907
908    /// A fresh engine for a document about to be opened — the caller loads into
909    /// it and hands the result to [`Self::open_document`].
910    ///
911    /// Seeded with the WHOLE of the session's settings, workbench included, so
912    /// a new tab continues what you were doing. The load that follows overrides
913    /// the workbench if the document names one (see the module header).
914    /// Build every later tab's engine through `wrap` as well: the app's live
915    /// PLM reconnect puts the new store's settings over the factory's.
916    pub fn wrap_engine_factory(&mut self, wrap: impl Fn(EngineState) -> EngineState + 'static) {
917        let old = std::mem::replace(&mut self.new_engine, Box::new(EngineState::new));
918        self.new_engine = Box::new(move || wrap(old()));
919    }
920
921    pub fn spawn_engine(&self) -> EngineState {
922        let mut engine = (self.new_engine)();
923        carry_settings(&self.engine().settings_json(), &mut engine, true);
924        // ...and the LIVE VIEWPORT SIZE, which decides how the document about to
925        // be loaded is FRAMED.
926        //
927        // A brand-new engine's camera is `ViewCamera::default()` — 800x600 —
928        // until the viewport tile draws it (`Viewport::show` is the only
929        // production `EngineState::resize`), and that is a whole frame after the
930        // load. But `load_model_and_fit` arms `pending_fit`, and the app pumps
931        // every open document at the TOP of the frame (`BrepApp::ui`), before
932        // the dock lays out and before the viewport draws. So when the run
933        // lands quickly — a warm process, a small document — the deferred
934        // `zoom_to_fit` fires against 800x600 and frames the model to the wrong
935        // ASPECT; when the run lands a frame later it fires against the real
936        // viewport. Same document, same window, two different cameras decided by
937        // a race with the background runner. Handing the engine the session's
938        // viewport size HERE, before anything is loaded into it, makes both
939        // orderings frame the same way. (The app-level one-shot at
940        // `BrepApp::first_run_framed` sits in the same place in the frame and is
941        // fixed by the same line.)
942        let (width, height) = self.viewport_size();
943        engine.resize(width, height);
944        engine
945    }
946
947    /// The session's 3D viewport in logical px, as the active engine last had it
948    /// from [`crate::viewport::Viewport::show`]. Every document in a session
949    /// draws into the SAME tile, so this is the size any new one will get.
950    fn viewport_size(&self) -> (f64, f64) {
951        let camera = &self.engine().camera;
952        (camera.width, camera.height)
953    }
954
955    /// The engine the user is editing: the active tab's, or — while a Work
956    /// part is edited in place in that tab — the hosted part's
957    /// ([`work_part`]). Save, the dirty dot and the tab identity stay the
958    /// tab's own ([`Self::active`], [`Self::tab_engine`]).
959    pub fn engine(&self) -> &EngineState {
960        match self.hosted() {
961            Some(doc) => &doc.engine,
962            None => &self.open[self.active].engine,
963        }
964    }
965
966    pub fn engine_mut(&mut self) -> &mut EngineState {
967        if self.work_part_in_place() {
968            return &mut self.hosted_mut().unwrap().engine;
969        }
970        &mut self.open[self.active].engine
971    }
972
973    /// The active TAB's own engine — the document Save writes. The same as
974    /// [`Self::engine`] except while a Work part is edited in place, when this
975    /// is the assembly.
976    pub fn tab_engine(&self) -> &EngineState {
977        &self.open[self.active].engine
978    }
979
980    pub fn tab_engine_mut(&mut self) -> &mut EngineState {
981        &mut self.open[self.active].engine
982    }
983
984    /// The identity of what the panels and viewport are drawing: the hosted
985    /// Work part's document while one is edited in place, else the active
986    /// tab's. The shell resets its document-scoped state when this changes.
987    pub fn view_id(&self) -> u64 {
988        self.hosted().map_or_else(|| self.active_id(), Document::id)
989    }
990
991    pub fn active(&self) -> &Document {
992        &self.open[self.active]
993    }
994
995    pub fn active_mut(&mut self) -> &mut Document {
996        &mut self.open[self.active]
997    }
998
999    /// The document being DRAWN and edited: the hosted Work part while one is
1000    /// edited in place, else the active tab ([`Self::active`] stays the tab,
1001    /// for its name, dirty state and Save).
1002    pub fn view(&self) -> &Document {
1003        self.hosted().unwrap_or(&self.open[self.active])
1004    }
1005
1006    pub fn view_mut(&mut self) -> &mut Document {
1007        if self.work_part_in_place() {
1008            return self.hosted_mut().unwrap();
1009        }
1010        &mut self.open[self.active]
1011    }
1012
1013    pub fn active_index(&self) -> usize {
1014        self.active
1015    }
1016
1017    /// The ACTIVE document's handle — the shell compares it frame to frame to
1018    /// notice a switch from ANY source (a tab click, a close, New, Open, Open
1019    /// Part, a crash-recovery restore) with one check instead of a hook per site.
1020    pub fn active_id(&self) -> u64 {
1021        self.open[self.active].id
1022    }
1023
1024    pub fn len(&self) -> usize {
1025        self.open.len()
1026    }
1027
1028    pub fn get(&self, index: usize) -> Option<&Document> {
1029        self.open.get(index)
1030    }
1031
1032    pub fn get_mut(&mut self, index: usize) -> Option<&mut Document> {
1033        self.open.get_mut(index)
1034    }
1035
1036    pub fn iter(&self) -> std::slice::Iter<'_, Document> {
1037        self.open.iter()
1038    }
1039
1040    pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Document> {
1041        self.open.iter_mut()
1042    }
1043
1044    /// Land every open document's finished runs; `true` while any of them still
1045    /// has work in flight, so the frame loop keeps pumping.
1046    ///
1047    /// EVERY open document is pumped, not just the active one: a run belongs to
1048    /// the engine that submitted it (each document owns its own runner), so a
1049    /// run still in flight when the user switches tabs must land in ITS
1050    /// document rather than be dropped or, worse, applied to whatever is on
1051    /// screen. An idle document's pump is a couple of empty `try_recv`s.
1052    ///
1053    /// The kernel's parts library is one store for every tab, and a pump can
1054    /// SUBMIT a run as well as land one: `EngineState::pump` re-runs after its
1055    /// runner refused a run for a library it no longer holds, after a ports
1056    /// edit moved a point, and when a mesh reconstruction lands (its STEP is
1057    /// imported, which writes the store into the document and runs). A submit hands the runner whatever library is
1058    /// in the store, and a run that lands mirrors the store it ran with into
1059    /// its document's own `partsLibrary` block. So a tab in the BACKGROUND that
1060    /// may do either — a run in flight, or a point move waiting — is pumped with
1061    /// ITS library installed, and the active document's is put back right after.
1062    /// (A STEP probe's reply only stashes what it found, and landing a run
1063    /// submits nothing, so those need no more than `run_pending`.)
1064    /// Pumped with the active one's, it resolved its components against another
1065    /// document's parts, and the run that landed wrote that into its own file.
1066    /// An idle background tab submits and lands nothing, so it costs nothing.
1067    pub fn pump_all(&mut self) -> bool {
1068        use brep_render::brep_kernel::parts_library_revision;
1069        let mut work_in_flight = false;
1070        let in_place = self.work_part_in_place();
1071        for index in 0..self.open.len() {
1072            // While a Work part is edited in place, its assembly tab is pumped
1073            // as a background document: the store holds the PART's library.
1074            let background = index != self.active || in_place;
1075            // Only a frame where something lands needs the document's own
1076            // library: a run in flight is otherwise polled and left, and
1077            // swapping the libraries both ways on every frame of it cost
1078            // ~30 ms a frame on l500 (a Work part edit in place).
1079            let doc = &mut self.open[index];
1080            let own_library = background
1081                && (doc.engine.run_landing()
1082                    || doc.engine.mesh_imports_pending()
1083                    || doc.engine.history.ports_follow_pending());
1084            let doc = &self.open[index];
1085            if own_library {
1086                install_library_of(doc);
1087            }
1088            let revision = parts_library_revision();
1089            let doc = &mut self.open[index];
1090            if background && !own_library && doc.engine.run_pending() {
1091                doc.engine.pump_holding_runs();
1092            } else {
1093                doc.engine.pump();
1094            }
1095            work_in_flight |= doc.engine.run_pending()
1096                || doc.engine.queries_pending()
1097                || doc.engine.mesh_imports_pending()
1098                || doc.engine.step_probes_pending()
1099                || doc.engine.topology_pending()
1100                || doc.engine.sheet_lines_pending();
1101            if own_library || (background && parts_library_revision() != revision) {
1102                self.install_active_library();
1103            }
1104        }
1105        // The hosted Work part (edited in place) is the foreground engine: the
1106        // store already holds its library.
1107        if let Some(doc) = self.hosted_mut() {
1108            doc.engine.pump();
1109            let engine = &doc.engine;
1110            work_in_flight |= engine.run_pending()
1111                || engine.queries_pending()
1112                || engine.mesh_imports_pending()
1113                || engine.step_probes_pending()
1114                || engine.topology_pending()
1115                || engine.sheet_lines_pending();
1116        }
1117        self.sync_work_part();
1118        self.begin_next_work_part();
1119        work_in_flight
1120    }
1121
1122    /// Compare identities the way the store does (the shell installs it).
1123    pub fn set_canonicalizer(&mut self, canon: crate::store::Canonicalizer) {
1124        self.canon = canon;
1125        self.forget_published();
1126    }
1127
1128    /// The tab holding the document `key` names, in the store's spelling.
1129    pub fn index_of_source(&self, key: &str) -> Option<usize> {
1130        let wanted = (self.canon)(key);
1131        self.open.iter().position(|doc| doc.name.as_deref().is_some_and(|name| (self.canon)(name) == wanted))
1132    }
1133
1134    /// The tab holding the document stored under `name`, if any.
1135    pub fn index_of(&self, name: &str) -> Option<usize> {
1136        self.open
1137            .iter()
1138            .position(|doc| doc.name.as_deref() == Some(name))
1139    }
1140
1141    /// Activate the tab already holding `name`; `false` when it is not open.
1142    /// The "focus the existing tab" half of open-or-focus.
1143    pub fn focus_named(&mut self, name: &str) -> bool {
1144        match self.index_of(name) {
1145            Some(index) => {
1146                self.activate(index);
1147                true
1148            }
1149            None => false,
1150        }
1151    }
1152
1153    /// Add `doc` as a new tab and make it active.
1154    pub fn open_document(&mut self, mut doc: Document) -> usize {
1155        if doc.name.is_none() {
1156            let taken: Vec<u32> = self.open.iter().filter(|d| d.name.is_none()).map(|d| d.untitled).collect();
1157            doc.untitled = (1..).find(|n| !taken.contains(n)).unwrap_or(1);
1158        }
1159        self.open.push(doc);
1160        let index = self.open.len() - 1;
1161        self.activate(index);
1162        index
1163    }
1164
1165    /// Number the untitled tabs 1, 2, … in tab order. Only for a session being
1166    /// built before anyone has seen its tabs — the autosave restore, which adds
1167    /// the recovered documents beside the boot seed and then closes the seed,
1168    /// so the seed's "untitled" must not push a recovered tab to "untitled 2".
1169    pub(crate) fn renumber_untitled(&mut self) {
1170        for (n, doc) in self.open.iter_mut().filter(|d| d.name.is_none()).enumerate() {
1171            doc.untitled = n as u32 + 1;
1172        }
1173    }
1174
1175    /// Make tab `index` active, carrying the session's display settings over to
1176    /// it. Out-of-range indices are ignored (a stale click never panics).
1177    pub fn activate(&mut self, index: usize) {
1178        if index >= self.open.len() || index == self.active {
1179            return;
1180        }
1181        if self.work_part.is_some() {
1182            if let Err(error) = self.finish_work_part() {
1183                self.engine_mut().push_notice(format!("Work part: {error}"));
1184                return;
1185            }
1186        }
1187        let settings = self.open[self.active].engine.settings_json();
1188        self.publish_live(self.active);
1189        self.active = index;
1190        carry_settings(&settings, &mut self.open[index].engine, false);
1191        self.pull_live(index);
1192        self.install_active_library();
1193    }
1194
1195    /// Put the ACTIVE document's parts library into the kernel's store.
1196    ///
1197    /// That store is one per thread, not one per document: every tab's engine
1198    /// resolves its ACOMP features against it and hands it to its runner, and a
1199    /// document seeds it only as it loads. So opening a second document — a New
1200    /// tab is enough — replaced it, and switching back left the assembly running
1201    /// against the other document's parts. Its components lost every connection
1202    /// point, and the next Diagram wire read "has no connection point named '2'
1203    /// in port 'Pins'" and took the harness connections already drawn with it
1204    /// (the eCAD workflow audit, issue 13). The block in the document's own
1205    /// history is the library that document last ran with, so it is the one to
1206    /// put back; an entry the store already holds unchanged is kept as it is,
1207    /// heal included (`install_parts_library`).
1208    fn install_active_library(&self) {
1209        install_library_of(self.hosted().unwrap_or(&self.open[self.active]));
1210    }
1211
1212    /// Close tab `index`. Closing the LAST document leaves a fresh untitled one
1213    /// (the always-one-document invariant); closing a tab before the active one
1214    /// keeps the same document active at its new index.
1215    ///
1216    /// The caller is responsible for the unsaved-changes prompt — this is the
1217    /// mechanical close (`panels::file` owns the confirmation).
1218    pub fn close(&mut self, index: usize) {
1219        if index >= self.open.len() {
1220            return;
1221        }
1222        if self.work_part.as_ref().is_some_and(|session| {
1223            let id = self.open[index].id();
1224            id == session.assembly_id || id == session.part_id
1225        }) {
1226            self.engine_mut().push_notice("Return to assembly before closing a Work part session document");
1227            return;
1228        }
1229        // Taken BEFORE the removal: if the closing tab was the active one, its
1230        // settings are the session's and must survive into whatever is shown next.
1231        let settings = self.open[self.active].engine.settings_json();
1232        // Same reason as `spawn_engine`: a refill built from the factory starts
1233        // on the 800x600 default camera, so carry the live viewport size too.
1234        let viewport = self.viewport_size();
1235        let closed_active = index == self.active;
1236        // A tab closed with its changes SAVED leaves them to the linked
1237        // documents; one closed with them discarded does not.
1238        if closed_active && !self.open[index].is_dirty() {
1239            self.publish_live(index);
1240        }
1241        let closed = self.open.remove(index);
1242        self.forget_live(closed.id());
1243        // The replacement for a session closed down to nothing is a brand-new
1244        // tab, so it takes the WHOLE of the session's settings — workbench
1245        // included — exactly as `New` does through `spawn_engine`.
1246        let refilled = self.open.is_empty();
1247        if refilled {
1248            let mut engine = (self.new_engine)();
1249            carry_settings(&settings, &mut engine, true);
1250            self.open.push(Document::fresh(engine, crate::document_class::DocumentClass::Normal));
1251            self.active = 0;
1252            self.open[0].engine.resize(viewport.0, viewport.1);
1253        } else if closed_active {
1254            self.active = index.min(self.open.len() - 1);
1255        } else if index < self.active {
1256            self.active -= 1;
1257        }
1258        if closed_active {
1259            let active = self.active;
1260            carry_settings(&settings, &mut self.open[active].engine, refilled);
1261            self.pull_live(active);
1262            self.install_active_library();
1263        }
1264    }
1265
1266    /// Refresh every tab's dirty dot (see [`Document::refresh_dirty_marker`]).
1267    /// The write of document `name` failed after its Save returned: the tabs
1268    /// showing it (any spelling of the same identity) read unsaved again.
1269    /// Whether one did.
1270    pub fn mark_write_failed(&mut self, store: &dyn crate::store::ModelStore, name: &str) -> bool {
1271        let failed = store.canonical_identity(name);
1272        let mut marked = false;
1273        for doc in &mut self.open {
1274            if doc.name().is_some_and(|n| store.canonical_identity(n) == failed) {
1275                doc.mark_write_failed();
1276                marked = true;
1277            }
1278        }
1279        marked
1280    }
1281
1282    pub fn refresh_dirty_markers(&mut self) {
1283        for doc in &mut self.open {
1284            owned_save::note_inserted_copies(doc);
1285            doc.settle_open_runs();
1286            doc.converge_runs();
1287            doc.refresh_dirty_marker();
1288        }
1289    }
1290}
1291
1292/// Put `doc`'s own parts library — the block in its history, the library it
1293/// last ran with — into the kernel's store (`Documents::install_active_library`).
1294fn install_library_of(doc: &Document) {
1295    let library = serde_json::from_value(doc.engine.history.parts_library().clone()).unwrap_or_default();
1296    brep_render::brep_kernel::install_parts_library(&library);
1297}
1298
1299/// Copy the SESSION-scoped display settings (theme, UI scale, colors, wireframe,
1300/// projection, lod…) from a `settings_json()` snapshot into `target`.
1301/// `with_workbench` is true ONLY when seeding a brand-new engine — see the
1302/// module header for why activation must never carry it.
1303fn carry_settings(settings_json: &str, target: &mut EngineState, with_workbench: bool) {
1304    let Ok(mut settings) = serde_json::from_str::<serde_json::Value>(settings_json) else {
1305        return;
1306    };
1307    if !with_workbench {
1308        if let Some(object) = settings.as_object_mut() {
1309            object.remove("workbench");
1310        }
1311    }
1312    let _ = target.apply_settings_json(&settings.to_string());
1313}
1314
1315/// Hand out the next document handle. A plain process counter: handles only ever
1316/// need to be distinct WITHIN a session, and they never reach storage.
1317fn next_document_id() -> u64 {
1318    use std::sync::atomic::{AtomicU64, Ordering};
1319    static NEXT: AtomicU64 = AtomicU64::new(1);
1320    NEXT.fetch_add(1, Ordering::Relaxed)
1321}
1322