Skip to main content

brep_app/
document.rs

1//! The MULTI-DOCUMENT model — the app holds N open models, exactly ONE of which
2//! is active.
3//!
4//! Before this, the shell owned a single `EngineState` and the document's
5//! IDENTITY (its store name + the clean baseline the dirty flag compares
6//! against) lived on the file dialog. That pairing is what a document IS, so it
7//! moves here: [`Document`] = one engine + its identity, [`Documents`] = the
8//! open list + the active index.
9//!
10//! # Why the accessor lives on `Documents` and not on `BrepApp`
11//!
12//! Half the shell passes DISJOINT `&mut` borrows of its own fields in one call
13//! (`self.history.show(ui, self.docs.engine_mut(), …)`, and the whole
14//! [`DockContext`](crate::panels::dock::DockContext)). A `BrepApp::engine_mut()`
15//! would borrow ALL of `self` and every one of those sites would stop compiling.
16//! `self.docs.engine_mut()` borrows only the `docs` FIELD, so it composes with a
17//! borrow of any other field exactly as `self.state` used to.
18//!
19//! # One runner per document
20//!
21//! Each engine installs its own history runner (a native thread, or — on wasm —
22//! its own web worker), because a runner owns the RESIDENT kernel state for the
23//! document it executes: sharing one would make a background document's reply
24//! land against the wrong resident registry. The shell therefore pumps EVERY
25//! open document each frame, not just the active one, so a run that outlives a
26//! tab switch is applied to the engine that submitted it. The cost is real and
27//! deliberately unpaid-for here: N documents means N runner threads/workers and
28//! N full scenes in memory. Opening a large assembly in a second tab costs what
29//! opening it in a second window would.
30//!
31//! # Display settings are the SESSION's; the workbench is the DOCUMENT's
32//!
33//! Theme / UI scale / colors / wireframe / lod live on `EngineState.settings`,
34//! so without a rule they would silently become per-tab (change the theme, switch
35//! tabs, watch it flip back). [`carry_settings`] copies them from the outgoing
36//! engine to the incoming one on every activation, and `workbench` is EXCLUDED
37//! there: a saved document remembers the workbench it was authored in and
38//! restores it on open, which is what makes the Assembly panes appear when you
39//! switch to an assembly tab and vanish when you switch back to a part.
40//!
41//! A BRAND NEW tab is the one place the workbench IS carried — by
42//! [`Documents::spawn_engine`], BEFORE the caller loads anything into the
43//! engine, so a document that declares a workbench still overrides it during
44//! the load and one that does not (a New document, a file saved before the
45//! field existed) simply continues the session you were working in.
46
47use crate::store::ModelStore;
48use brep_render::engine_state::EngineState;
49
50/// Builds a fresh engine for a new document — the platform runner, the viewcube,
51/// and the persisted display settings, all applied before anything is loaded
52/// into it. Injected by the shell so tests (which need the SYNCHRONOUS inline
53/// runner) can construct documents without a background thread.
54pub type EngineFactory = Box<dyn Fn() -> EngineState>;
55
56/// The empty model a **New** document starts from.
57pub const EMPTY_DOCUMENT: &str = r#"{"expressions":"","configurator":{},"features":[]}"#;
58
59/// ONE open model: the engine that owns it plus the identity the file lane needs
60/// — the store name it was opened from / saved to, and the clean baseline.
61pub struct Document {
62    /// The windowing-agnostic brain for THIS document (scene, camera, history,
63    /// settings, selection). Panels borrow it; it is never forked.
64    pub engine: EngineState,
65    /// The store identity (`None` = a never-saved "untitled" model). The RAW
66    /// identity, not the display name — a native dialog hands back a full path
67    /// and a plain Save must write back to it.
68    name: Option<String>,
69    /// The model request JSON as of the last New / Open / Save — the baseline
70    /// the dirty flag compares the live history against.
71    saved_signature: Option<String>,
72    /// The cached tab-strip dirty dot. See [`Document::dirty_marker`] for why
73    /// this is not simply `is_dirty()`.
74    dirty_marker: bool,
75    /// The applied-run generation `dirty_marker` was computed at.
76    marker_generation: Option<u64>,
77    /// A process-unique handle, so the shell can notice "the active document is
78    /// a different one now" without comparing indices (closing a tab BEFORE the
79    /// active one moves the index without changing the document).
80    id: u64,
81}
82
83impl Document {
84    /// Wrap `engine` as a document, taking its CURRENT model as the clean
85    /// baseline — so a freshly loaded (or freshly emptied) document is clean and
86    /// the first edit marks it dirty.
87    pub fn new(engine: EngineState) -> Self {
88        let saved_signature = Some(engine.history_request_json());
89        Self {
90            engine,
91            name: None,
92            saved_signature,
93            dirty_marker: false,
94            marker_generation: None,
95            id: next_document_id(),
96        }
97    }
98
99    /// The raw store identity, or `None` for a never-saved document.
100    pub fn name(&self) -> Option<&str> {
101        self.name.as_deref()
102    }
103
104    pub fn set_name(&mut self, name: Option<String>) {
105        self.name = name;
106    }
107
108    /// The tab label: the bare display name, or `untitled`.
109    pub fn title(&self) -> String {
110        match &self.name {
111            Some(name) => crate::store::model_display_name(name),
112            None => "untitled".to_string(),
113        }
114    }
115
116    /// Snapshot the current model as the clean baseline (after New / Open /
117    /// Save, or a `?loadModel=` boot-load).
118    pub fn mark_clean(&mut self) {
119        self.saved_signature = Some(self.engine.history_request_json());
120        self.dirty_marker = false;
121        self.marker_generation = Some(self.engine.applied_generation());
122    }
123
124    /// Dirty = the live model differs from the last saved/opened baseline.
125    /// (Rolling the history does NOT change the request document, so navigating
126    /// steps never marks dirty — only real edits/add/delete/reorder do.)
127    ///
128    /// HONEST but not free: it serializes the whole request document, which for
129    /// an assembly carrying an embedded parts library is megabytes. Every place
130    /// where being wrong would cost the user work — the close prompt, Save —
131    /// calls THIS. The per-frame tab dot calls [`Self::dirty_marker`] instead.
132    pub fn is_dirty(&self) -> bool {
133        match &self.saved_signature {
134            Some(saved) => *saved != self.engine.history_request_json(),
135            None => true,
136        }
137    }
138
139    /// The cached dirty flag the tab strip draws, recomputed only when the
140    /// document's APPLIED RUN generation moved. Every ordinary edit (a param
141    /// change, add, delete, reorder, undo) re-runs the history and bumps that
142    /// generation, so the dot tracks editing; a mutation that changes the
143    /// document WITHOUT a re-run (an object-metadata write) can leave the dot
144    /// one edit behind. That is a marker being briefly optimistic, never a lost
145    /// edit: [`Self::is_dirty`] is recomputed honestly at the close prompt.
146    ///
147    /// The alternative — serializing every open document every frame — is a
148    /// per-frame multi-megabyte cost per tab, and the app pays no such cost today.
149    pub fn refresh_dirty_marker(&mut self) {
150        let generation = self.engine.applied_generation();
151        if self.marker_generation == Some(generation) {
152            return;
153        }
154        self.marker_generation = Some(generation);
155        self.dirty_marker = self.is_dirty();
156    }
157
158    pub fn dirty_marker(&self) -> bool {
159        self.dirty_marker
160    }
161
162    /// The document's process-unique handle (identity across index shuffles).
163    pub fn id(&self) -> u64 {
164        self.id
165    }
166}
167
168/// Every open document + which one is active. Always holds AT LEAST ONE
169/// document: closing the last tab leaves a fresh untitled one in its place, so
170/// [`Documents::engine_mut`] is infallible and the shell never has to render a
171/// "no document" state that would be an empty viewport with extra steps.
172pub struct Documents {
173    open: Vec<Document>,
174    active: usize,
175    /// How a new tab's engine is built (platform runner + persisted settings).
176    new_engine: EngineFactory,
177}
178
179impl Documents {
180    /// A session holding ONE empty document built by `new_engine`.
181    pub fn new(new_engine: EngineFactory) -> Self {
182        let first = Document::new(new_engine());
183        Self {
184            open: vec![first],
185            active: 0,
186            new_engine,
187        }
188    }
189
190    /// A fresh engine for a document about to be opened — the caller loads into
191    /// it and hands the result to [`Self::open_document`].
192    ///
193    /// Seeded with the WHOLE of the session's settings, workbench included, so
194    /// a new tab continues what you were doing. The load that follows overrides
195    /// the workbench if the document names one (see the module header).
196    pub fn spawn_engine(&self) -> EngineState {
197        let mut engine = (self.new_engine)();
198        carry_settings(&self.engine().settings_json(), &mut engine, true);
199        engine
200    }
201
202    pub fn engine(&self) -> &EngineState {
203        &self.open[self.active].engine
204    }
205
206    pub fn engine_mut(&mut self) -> &mut EngineState {
207        &mut self.open[self.active].engine
208    }
209
210    pub fn active(&self) -> &Document {
211        &self.open[self.active]
212    }
213
214    pub fn active_mut(&mut self) -> &mut Document {
215        &mut self.open[self.active]
216    }
217
218    pub fn active_index(&self) -> usize {
219        self.active
220    }
221
222    /// The ACTIVE document's handle — the shell compares it frame to frame to
223    /// notice a switch from ANY source (a tab click, a close, New, Open, Open
224    /// Part, the session restore) with one check instead of a hook per site.
225    pub fn active_id(&self) -> u64 {
226        self.open[self.active].id
227    }
228
229    pub fn len(&self) -> usize {
230        self.open.len()
231    }
232
233    pub fn get(&self, index: usize) -> Option<&Document> {
234        self.open.get(index)
235    }
236
237    pub fn get_mut(&mut self, index: usize) -> Option<&mut Document> {
238        self.open.get_mut(index)
239    }
240
241    pub fn iter(&self) -> std::slice::Iter<'_, Document> {
242        self.open.iter()
243    }
244
245    pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Document> {
246        self.open.iter_mut()
247    }
248
249    /// The tab holding the document stored under `name`, if any.
250    pub fn index_of(&self, name: &str) -> Option<usize> {
251        self.open
252            .iter()
253            .position(|doc| doc.name.as_deref() == Some(name))
254    }
255
256    /// Activate the tab already holding `name`; `false` when it is not open.
257    /// The "focus the existing tab" half of open-or-focus.
258    pub fn focus_named(&mut self, name: &str) -> bool {
259        match self.index_of(name) {
260            Some(index) => {
261                self.activate(index);
262                true
263            }
264            None => false,
265        }
266    }
267
268    /// Add `doc` as a new tab and make it active.
269    pub fn open_document(&mut self, doc: Document) -> usize {
270        self.open.push(doc);
271        let index = self.open.len() - 1;
272        self.activate(index);
273        index
274    }
275
276    /// Make tab `index` active, carrying the session's display settings over to
277    /// it. Out-of-range indices are ignored (a stale click never panics).
278    pub fn activate(&mut self, index: usize) {
279        if index >= self.open.len() || index == self.active {
280            return;
281        }
282        let settings = self.open[self.active].engine.settings_json();
283        self.active = index;
284        carry_settings(&settings, &mut self.open[index].engine, false);
285    }
286
287    /// Close tab `index`. Closing the LAST document leaves a fresh untitled one
288    /// (the always-one-document invariant); closing a tab before the active one
289    /// keeps the same document active at its new index.
290    ///
291    /// The caller is responsible for the unsaved-changes prompt — this is the
292    /// mechanical close (`panels::file` owns the confirmation).
293    pub fn close(&mut self, index: usize) {
294        if index >= self.open.len() {
295            return;
296        }
297        // Taken BEFORE the removal: if the closing tab was the active one, its
298        // settings are the session's and must survive into whatever is shown next.
299        let settings = self.open[self.active].engine.settings_json();
300        let closed_active = index == self.active;
301        self.open.remove(index);
302        // The replacement for a session closed down to nothing is a brand-new
303        // tab, so it takes the WHOLE of the session's settings — workbench
304        // included — exactly as `New` does through `spawn_engine`.
305        let refilled = self.open.is_empty();
306        if refilled {
307            self.open.push(Document::new((self.new_engine)()));
308            self.active = 0;
309        } else if closed_active {
310            self.active = index.min(self.open.len() - 1);
311        } else if index < self.active {
312            self.active -= 1;
313        }
314        if closed_active {
315            let active = self.active;
316            carry_settings(&settings, &mut self.open[active].engine, refilled);
317        }
318    }
319
320    /// Refresh every tab's dirty dot (see [`Document::refresh_dirty_marker`]).
321    pub fn refresh_dirty_markers(&mut self) {
322        for doc in &mut self.open {
323            doc.refresh_dirty_marker();
324        }
325    }
326
327    // --- session persistence --------------------------------------------------
328
329    /// The persisted session: the NAMED documents in tab order plus the active
330    /// one's name. A never-saved document has no store identity, so it cannot be
331    /// restored and is not listed — reloading loses an unsaved scratch document
332    /// exactly as it did when the app held one document and persisted none.
333    pub fn session_json(&self) -> String {
334        let open: Vec<&str> = self.open.iter().filter_map(Document::name).collect();
335        serde_json::json!({
336            "open": open,
337            "active": self.active().name(),
338        })
339        .to_string()
340    }
341
342    /// Restore a persisted session, REPLACING the current open list. Names the
343    /// store no longer holds (or cannot load) are skipped — a deleted file must
344    /// not stop the rest of the session coming back — and the count of documents
345    /// actually restored is returned, so the caller can seed instead when it is 0.
346    pub fn restore_session(&mut self, store: &dyn ModelStore, json: &str) -> usize {
347        let Ok(session) = serde_json::from_str::<serde_json::Value>(json) else {
348            return 0;
349        };
350        let names: Vec<String> = session["open"]
351            .as_array()
352            .map(|list| {
353                list.iter()
354                    .filter_map(|name| name.as_str().map(str::to_string))
355                    .collect()
356            })
357            .unwrap_or_default();
358        let wanted_active = session["active"].as_str().unwrap_or_default().to_string();
359
360        let mut restored: Vec<Document> = Vec::new();
361        let mut active = 0usize;
362        for name in names {
363            let Some(contents) = store.read(&name) else {
364                continue;
365            };
366            let mut engine = (self.new_engine)();
367            if engine.load_model_and_fit(&contents).is_err() {
368                continue;
369            }
370            let mut doc = Document::new(engine);
371            doc.set_name(Some(name.clone()));
372            if name == wanted_active {
373                active = restored.len();
374            }
375            restored.push(doc);
376        }
377        if restored.is_empty() {
378            return 0;
379        }
380        self.open = restored;
381        self.active = active;
382        self.open.len()
383    }
384}
385
386/// Copy the SESSION-scoped display settings (theme, UI scale, colors, wireframe,
387/// projection, lod…) from a `settings_json()` snapshot into `target`.
388/// `with_workbench` is true ONLY when seeding a brand-new engine — see the
389/// module header for why activation must never carry it.
390fn carry_settings(settings_json: &str, target: &mut EngineState, with_workbench: bool) {
391    let Ok(mut settings) = serde_json::from_str::<serde_json::Value>(settings_json) else {
392        return;
393    };
394    if !with_workbench {
395        if let Some(object) = settings.as_object_mut() {
396            object.remove("workbench");
397        }
398    }
399    let _ = target.apply_settings_json(&settings.to_string());
400}
401
402/// Hand out the next document handle. A plain process counter: handles only ever
403/// need to be distinct WITHIN a session, and they never reach storage.
404fn next_document_id() -> u64 {
405    use std::sync::atomic::{AtomicU64, Ordering};
406    static NEXT: AtomicU64 = AtomicU64::new(1);
407    NEXT.fetch_add(1, Ordering::Relaxed)
408}
409
410#[cfg(all(test, not(target_arch = "wasm32")))]
411mod tests {
412    use super::*;
413    use crate::store::MemModelStore;
414
415    /// The test factory: the SYNCHRONOUS inline runner, so a load's solids are
416    /// resident by the time the call returns.
417    fn documents() -> Documents {
418        Documents::new(Box::new(EngineState::new))
419    }
420
421    const BOX_SEED: &str = r#"{"expressions":"","configurator":{},"features":[
422        {"type":"P.CU","inputParams":{"id":"Box","sizeX":8.0,"sizeY":8.0,"sizeZ":8.0,
423         "transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},
424         "boolean":{"targets":[],"operation":"NONE"}},"persistentData":{}}
425    ]}"#;
426
427    fn named(docs: &mut Documents, name: &str) -> usize {
428        let mut engine = docs.spawn_engine();
429        engine.set_history_json(BOX_SEED).unwrap();
430        let mut doc = Document::new(engine);
431        doc.set_name(Some(name.to_string()));
432        docs.open_document(doc)
433    }
434
435    /// A session starts with exactly one (empty, untitled, clean) document, and
436    /// every opened document becomes the active tab.
437    #[test]
438    fn a_session_always_holds_at_least_one_document() {
439        let mut docs = documents();
440        assert_eq!(docs.len(), 1);
441        assert_eq!(docs.active_index(), 0);
442        assert_eq!(docs.active().title(), "untitled");
443        assert!(!docs.active().is_dirty(), "a fresh document is clean");
444
445        named(&mut docs, "part-a");
446        assert_eq!(docs.len(), 2);
447        assert_eq!(docs.active_index(), 1, "an opened document takes focus");
448        assert_eq!(docs.active().title(), "part-a");
449    }
450
451    /// Open-or-focus: a name already open activates ITS tab instead of adding a
452    /// second one.
453    #[test]
454    fn focus_named_activates_the_existing_tab() {
455        let mut docs = documents();
456        named(&mut docs, "part-a");
457        named(&mut docs, "part-b");
458        assert_eq!(docs.active_index(), 2);
459
460        assert!(docs.focus_named("part-a"), "already open");
461        assert_eq!(docs.active_index(), 1);
462        assert_eq!(docs.len(), 3, "focusing never adds a tab");
463        assert!(!docs.focus_named("part-z"), "never opened");
464        assert_eq!(docs.active_index(), 1, "a miss leaves the focus alone");
465    }
466
467    /// The index fixup: closing a tab BEFORE the active one keeps the same
468    /// document active (its handle is unchanged); closing the active one falls
469    /// to its neighbour; closing the last one leaves a fresh untitled document.
470    #[test]
471    fn closing_fixes_up_the_active_index() {
472        let mut docs = documents();
473        named(&mut docs, "part-a"); // index 1
474        named(&mut docs, "part-b"); // index 2
475        docs.activate(2);
476        let active_id = docs.active_id();
477
478        // Close the untitled tab BEFORE the active one: same document, new index.
479        docs.close(0);
480        assert_eq!(docs.len(), 2);
481        assert_eq!(docs.active_index(), 1);
482        assert_eq!(docs.active_id(), active_id, "the same document stays active");
483        assert_eq!(docs.active().title(), "part-b");
484
485        // Close the ACTIVE (last) tab: focus falls back to its neighbour.
486        docs.close(1);
487        assert_eq!(docs.len(), 1);
488        assert_eq!(docs.active_index(), 0);
489        assert_eq!(docs.active().title(), "part-a");
490        assert_ne!(docs.active_id(), active_id, "a different document is active");
491
492        // Close the ONLY tab: a fresh untitled document takes its place.
493        docs.close(0);
494        assert_eq!(docs.len(), 1, "there is always a document");
495        assert_eq!(docs.active().title(), "untitled");
496        assert_eq!(docs.active().engine.history_len(), 0);
497
498        // Out-of-range indices are ignored rather than panicking.
499        docs.close(7);
500        assert_eq!(docs.len(), 1);
501    }
502
503    /// Dirty tracking is per document: editing one tab never marks another, and
504    /// the cached tab-strip marker follows the honest flag across a re-run.
505    #[test]
506    fn dirty_tracking_is_per_document() {
507        let mut docs = documents();
508        named(&mut docs, "part-a");
509        named(&mut docs, "part-b");
510        docs.refresh_dirty_markers();
511        assert!(docs.iter().all(|doc| !doc.is_dirty()), "all clean");
512        assert!(docs.iter().all(|doc| !doc.dirty_marker()));
513
514        // Edit the ACTIVE document (part-b).
515        docs.engine_mut()
516            .update_feature_params(
517                "Box",
518                r#"{"id":"Box","sizeX":19.0,"sizeY":8.0,"sizeZ":8.0,"transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},"boolean":{"targets":[],"operation":"NONE"}}"#,
519            )
520            .unwrap();
521        docs.refresh_dirty_markers();
522        assert!(docs.active().is_dirty(), "the edited document is dirty");
523        assert!(docs.active().dirty_marker(), "…and its tab dot lights");
524        assert!(!docs.get(1).unwrap().is_dirty(), "the other tab is untouched");
525        assert!(!docs.get(1).unwrap().dirty_marker());
526
527        // Saving (marking clean) clears both the flag and the dot.
528        docs.active_mut().mark_clean();
529        docs.refresh_dirty_markers();
530        assert!(!docs.active().is_dirty());
531        assert!(!docs.active().dirty_marker());
532    }
533
534    /// Display settings are the SESSION's: a theme change follows a tab switch.
535    /// The workbench is the DOCUMENT's and must NOT follow it — that is what
536    /// makes the Assembly panes appear on an assembly tab and vanish on a part.
537    #[test]
538    fn switching_carries_display_settings_but_not_the_workbench() {
539        let mut docs = documents();
540        docs.engine_mut()
541            .apply_settings_json(r#"{"workbench":"assembly","wireframe":true}"#)
542            .unwrap();
543        named(&mut docs, "part-a");
544        docs.engine_mut()
545            .apply_settings_json(r#"{"workbench":"sheetMetal"}"#)
546            .unwrap();
547        assert!(
548            docs.engine().settings.wireframe,
549            "the new tab inherited the session's display settings"
550        );
551
552        // Back to the first tab: it keeps its own workbench, and picks up the
553        // display setting changed while it was in the background.
554        docs.engine_mut()
555            .apply_settings_json(r#"{"wireframe":false}"#)
556            .unwrap();
557        docs.activate(0);
558        assert_eq!(docs.engine().settings.workbench, "assembly", "per document");
559        assert!(!docs.engine().settings.wireframe, "per session");
560
561        docs.activate(1);
562        assert_eq!(docs.engine().settings.workbench, "sheetMetal");
563    }
564
565    /// The session round-trips through the store: only NAMED documents are
566    /// listed, the active one comes back active, and a name whose file has gone
567    /// is skipped rather than failing the whole restore.
568    #[test]
569    fn the_session_round_trips_and_tolerates_a_missing_file() {
570        let store = MemModelStore::new();
571        store.put("part-a", BOX_SEED);
572        store.put("part-b", BOX_SEED);
573
574        let mut docs = documents();
575        named(&mut docs, "part-a");
576        named(&mut docs, "part-b");
577        docs.activate(1);
578        let session = docs.session_json();
579        let parsed: serde_json::Value = serde_json::from_str(&session).unwrap();
580        assert_eq!(
581            parsed["open"].as_array().unwrap().len(),
582            2,
583            "the untitled tab is not persistable: {session}"
584        );
585        assert_eq!(parsed["active"], "part-a");
586
587        let mut restored = documents();
588        assert_eq!(restored.restore_session(&store, &session), 2);
589        assert_eq!(restored.len(), 2);
590        assert_eq!(restored.active().title(), "part-a");
591        assert!(!restored.active().is_dirty(), "a restored document is clean");
592        assert_eq!(restored.active().engine.scene.solids().len(), 1);
593
594        // One file removed: the rest of the session still comes back.
595        store.remove("part-a").unwrap();
596        let mut partial = documents();
597        assert_eq!(partial.restore_session(&store, &session), 1);
598        assert_eq!(partial.active().title(), "part-b");
599
600        // Nothing restorable → 0, and the caller seeds instead.
601        store.remove("part-b").unwrap();
602        let mut none = documents();
603        assert_eq!(none.restore_session(&store, &session), 0);
604        assert_eq!(none.len(), 1, "the untouched boot document survives");
605        assert_eq!(none.restore_session(&store, "not json"), 0);
606    }
607}