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>(¤t)) {
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