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