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