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