BREP_app 0.4.0

The BREP CAD application: an eframe (egui + wgpu) host that draws the brep-render 3D engine into an egui frame — native + wasm from one codebase.
Documentation
//! Open documents and their active tab. Each document owns an engine, store
//! identity, and clean baseline for dirty tracking. Keeping engine access on
//! `Documents` allows separate mutable borrows of other app fields.
//!
//! Every engine has its own history runner and resident kernel state. All open
//! documents are pumped each frame, so switching tabs cannot redirect a pending
//! result to another document. Each open document retains its runner and scene.
//!
//! Display settings are shared across tabs by [`carry_settings`]; workbench
//! selection belongs to each document. A new engine initially inherits the
//! current workbench, which a loaded document can override.

use crate::store::ModelStore;
use brep_render::engine_state::EngineState;

/// Builds a fresh engine for a new document — the platform runner, the viewcube,
/// and the persisted display settings, all applied before anything is loaded
/// into it. Injected by the shell so tests (which need the SYNCHRONOUS inline
/// runner) can construct documents without a background thread.
pub type EngineFactory = Box<dyn Fn() -> EngineState>;

/// The empty model a **New** document starts from.
pub const EMPTY_DOCUMENT: &str = r#"{"expressions":"","configurator":{},"features":[]}"#;

/// ONE open model: the engine that owns it plus the identity the file lane needs
/// — the store name it was opened from / saved to, and the clean baseline.
pub struct Document {
    /// The windowing-agnostic brain for THIS document (scene, camera, history,
    /// settings, selection). Panels borrow it; it is never forked.
    pub engine: EngineState,
    /// The store identity (`None` = a never-saved "untitled" model). The RAW
    /// identity, not the display name — a native dialog hands back a full path
    /// and a plain Save must write back to it.
    name: Option<String>,
    /// The model request JSON as of the last New / Open / Save — the baseline
    /// the dirty flag compares the live history against.
    saved_signature: Option<String>,
    /// The cached tab-strip dirty dot. See [`Document::dirty_marker`] for why
    /// this is not simply `is_dirty()`.
    dirty_marker: bool,
    /// The applied-run generation `dirty_marker` was computed at.
    marker_generation: Option<u64>,
    /// A process-unique handle, so the shell can notice "the active document is
    /// a different one now" without comparing indices (closing a tab BEFORE the
    /// active one moves the index without changing the document).
    id: u64,
}

impl Document {
    /// Wrap `engine` as a document, taking its CURRENT model as the clean
    /// baseline — so a freshly loaded (or freshly emptied) document is clean and
    /// the first edit marks it dirty.
    pub fn new(engine: EngineState) -> Self {
        let saved_signature = Some(engine.history_request_json());
        Self {
            engine,
            name: None,
            saved_signature,
            dirty_marker: false,
            marker_generation: None,
            id: next_document_id(),
        }
    }

    /// Wrap `engine` as a document RESTORED from the autosave blob
    /// (`crate::recovery`): it carries the store identity it had (`None` for
    /// an untitled one) and NO clean baseline, so it is dirty from its first
    /// frame — the work it holds is exactly what was never saved, and the close
    /// guard and the tab dot must say so until a Save writes it somewhere.
    pub fn recovered(engine: EngineState, name: Option<String>) -> Self {
        Self {
            engine,
            name,
            saved_signature: None,
            dirty_marker: true,
            marker_generation: None,
            id: next_document_id(),
        }
    }

    /// The raw store identity, or `None` for a never-saved document.
    pub fn name(&self) -> Option<&str> {
        self.name.as_deref()
    }

    pub fn set_name(&mut self, name: Option<String>) {
        self.name = name;
    }

    /// The tab label: the bare display name, or `untitled`.
    pub fn title(&self) -> String {
        match &self.name {
            Some(name) => crate::store::model_display_name(name),
            None => "untitled".to_string(),
        }
    }

    /// Snapshot the current model as the clean baseline (after New / Open /
    /// Save, or a `?loadModel=` boot-load).
    pub fn mark_clean(&mut self) {
        self.saved_signature = Some(self.engine.history_request_json());
        self.dirty_marker = false;
        self.marker_generation = Some(self.engine.applied_generation());
    }

    /// Dirty = the live model differs from the last saved/opened baseline.
    /// (Rolling the history does NOT change the request document, so navigating
    /// steps never marks dirty — only real edits/add/delete/reorder do.)
    ///
    /// HONEST but not free: it serializes the whole request document, which for
    /// an assembly carrying an embedded parts library is megabytes. Every place
    /// where being wrong would cost the user work — the close prompt, Save —
    /// calls THIS. The per-frame tab dot calls [`Self::dirty_marker`] instead.
    pub fn is_dirty(&self) -> bool {
        match &self.saved_signature {
            Some(saved) => *saved != self.engine.history_request_json(),
            None => true,
        }
    }

    /// The cached dirty flag the tab strip draws, recomputed only when the
    /// document's APPLIED RUN generation moved. Every ordinary edit (a param
    /// change, add, delete, reorder, undo) re-runs the history and bumps that
    /// generation, so the dot tracks editing; a mutation that changes the
    /// document WITHOUT a re-run (an object-metadata write) can leave the dot
    /// one edit behind. That is a marker being briefly optimistic, never a lost
    /// edit: [`Self::is_dirty`] is recomputed honestly at the close prompt.
    ///
    /// The alternative — serializing every open document every frame — is a
    /// per-frame multi-megabyte cost per tab, and the app pays no such cost today.
    pub fn refresh_dirty_marker(&mut self) {
        let generation = self.engine.applied_generation();
        if self.marker_generation == Some(generation) {
            return;
        }
        self.marker_generation = Some(generation);
        self.dirty_marker = self.is_dirty();
    }

    pub fn dirty_marker(&self) -> bool {
        self.dirty_marker
    }

    /// The document's process-unique handle (identity across index shuffles).
    pub fn id(&self) -> u64 {
        self.id
    }
}

/// Every open document + which one is active. Always holds AT LEAST ONE
/// document: closing the last tab leaves a fresh untitled one in its place, so
/// [`Documents::engine_mut`] is infallible and the shell never has to render a
/// "no document" state that would be an empty viewport with extra steps.
pub struct Documents {
    open: Vec<Document>,
    active: usize,
    /// How a new tab's engine is built (platform runner + persisted settings).
    new_engine: EngineFactory,
}

impl Documents {
    /// A session holding ONE empty document built by `new_engine`.
    pub fn new(new_engine: EngineFactory) -> Self {
        let first = Document::new(new_engine());
        Self {
            open: vec![first],
            active: 0,
            new_engine,
        }
    }

    /// A fresh engine for a document about to be opened — the caller loads into
    /// it and hands the result to [`Self::open_document`].
    ///
    /// Seeded with the WHOLE of the session's settings, workbench included, so
    /// a new tab continues what you were doing. The load that follows overrides
    /// the workbench if the document names one (see the module header).
    pub fn spawn_engine(&self) -> EngineState {
        let mut engine = (self.new_engine)();
        carry_settings(&self.engine().settings_json(), &mut engine, true);
        engine
    }

    pub fn engine(&self) -> &EngineState {
        &self.open[self.active].engine
    }

    pub fn engine_mut(&mut self) -> &mut EngineState {
        &mut self.open[self.active].engine
    }

    pub fn active(&self) -> &Document {
        &self.open[self.active]
    }

    pub fn active_mut(&mut self) -> &mut Document {
        &mut self.open[self.active]
    }

    pub fn active_index(&self) -> usize {
        self.active
    }

    /// The ACTIVE document's handle — the shell compares it frame to frame to
    /// notice a switch from ANY source (a tab click, a close, New, Open, Open
    /// Part, the session restore) with one check instead of a hook per site.
    pub fn active_id(&self) -> u64 {
        self.open[self.active].id
    }

    pub fn len(&self) -> usize {
        self.open.len()
    }

    pub fn get(&self, index: usize) -> Option<&Document> {
        self.open.get(index)
    }

    pub fn get_mut(&mut self, index: usize) -> Option<&mut Document> {
        self.open.get_mut(index)
    }

    pub fn iter(&self) -> std::slice::Iter<'_, Document> {
        self.open.iter()
    }

    pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Document> {
        self.open.iter_mut()
    }

    /// The tab holding the document stored under `name`, if any.
    pub fn index_of(&self, name: &str) -> Option<usize> {
        self.open
            .iter()
            .position(|doc| doc.name.as_deref() == Some(name))
    }

    /// Activate the tab already holding `name`; `false` when it is not open.
    /// The "focus the existing tab" half of open-or-focus.
    pub fn focus_named(&mut self, name: &str) -> bool {
        match self.index_of(name) {
            Some(index) => {
                self.activate(index);
                true
            }
            None => false,
        }
    }

    /// Add `doc` as a new tab and make it active.
    pub fn open_document(&mut self, doc: Document) -> usize {
        self.open.push(doc);
        let index = self.open.len() - 1;
        self.activate(index);
        index
    }

    /// Make tab `index` active, carrying the session's display settings over to
    /// it. Out-of-range indices are ignored (a stale click never panics).
    pub fn activate(&mut self, index: usize) {
        if index >= self.open.len() || index == self.active {
            return;
        }
        let settings = self.open[self.active].engine.settings_json();
        self.active = index;
        carry_settings(&settings, &mut self.open[index].engine, false);
    }

    /// Close tab `index`. Closing the LAST document leaves a fresh untitled one
    /// (the always-one-document invariant); closing a tab before the active one
    /// keeps the same document active at its new index.
    ///
    /// The caller is responsible for the unsaved-changes prompt — this is the
    /// mechanical close (`panels::file` owns the confirmation).
    pub fn close(&mut self, index: usize) {
        if index >= self.open.len() {
            return;
        }
        // Taken BEFORE the removal: if the closing tab was the active one, its
        // settings are the session's and must survive into whatever is shown next.
        let settings = self.open[self.active].engine.settings_json();
        let closed_active = index == self.active;
        self.open.remove(index);
        // The replacement for a session closed down to nothing is a brand-new
        // tab, so it takes the WHOLE of the session's settings — workbench
        // included — exactly as `New` does through `spawn_engine`.
        let refilled = self.open.is_empty();
        if refilled {
            self.open.push(Document::new((self.new_engine)()));
            self.active = 0;
        } else if closed_active {
            self.active = index.min(self.open.len() - 1);
        } else if index < self.active {
            self.active -= 1;
        }
        if closed_active {
            let active = self.active;
            carry_settings(&settings, &mut self.open[active].engine, refilled);
        }
    }

    /// Refresh every tab's dirty dot (see [`Document::refresh_dirty_marker`]).
    pub fn refresh_dirty_markers(&mut self) {
        for doc in &mut self.open {
            doc.refresh_dirty_marker();
        }
    }

    // --- session persistence --------------------------------------------------

    /// The persisted session: the NAMED documents in tab order plus the active
    /// one's name. A never-saved document has no store identity, so it cannot be
    /// restored and is not listed — reloading loses an unsaved scratch document
    /// exactly as it did when the app held one document and persisted none.
    pub fn session_json(&self) -> String {
        let open: Vec<&str> = self.open.iter().filter_map(Document::name).collect();
        serde_json::json!({
            "open": open,
            "active": self.active().name(),
        })
        .to_string()
    }

    /// Restore a persisted session, REPLACING the current open list. Names the
    /// store no longer holds (or cannot load) are skipped — a deleted file must
    /// not stop the rest of the session coming back — and the count of documents
    /// actually restored is returned, so the caller can seed instead when it is 0.
    pub fn restore_session(&mut self, store: &dyn ModelStore, json: &str) -> usize {
        let Ok(session) = serde_json::from_str::<serde_json::Value>(json) else {
            return 0;
        };
        let names: Vec<String> = session["open"]
            .as_array()
            .map(|list| {
                list.iter()
                    .filter_map(|name| name.as_str().map(str::to_string))
                    .collect()
            })
            .unwrap_or_default();
        let wanted_active = session["active"].as_str().unwrap_or_default().to_string();

        let mut restored: Vec<Document> = Vec::new();
        let mut active = 0usize;
        for name in names {
            let Some(contents) = store.read(&name) else {
                continue;
            };
            let mut engine = (self.new_engine)();
            if engine.load_model_and_fit(&contents).is_err() {
                continue;
            }
            let mut doc = Document::new(engine);
            doc.set_name(Some(name.clone()));
            if name == wanted_active {
                active = restored.len();
            }
            restored.push(doc);
        }
        if restored.is_empty() {
            return 0;
        }
        self.open = restored;
        self.active = active;
        self.open.len()
    }
}

/// Copy the SESSION-scoped display settings (theme, UI scale, colors, wireframe,
/// projection, lod…) from a `settings_json()` snapshot into `target`.
/// `with_workbench` is true ONLY when seeding a brand-new engine — see the
/// module header for why activation must never carry it.
fn carry_settings(settings_json: &str, target: &mut EngineState, with_workbench: bool) {
    let Ok(mut settings) = serde_json::from_str::<serde_json::Value>(settings_json) else {
        return;
    };
    if !with_workbench {
        if let Some(object) = settings.as_object_mut() {
            object.remove("workbench");
        }
    }
    let _ = target.apply_settings_json(&settings.to_string());
}

/// Hand out the next document handle. A plain process counter: handles only ever
/// need to be distinct WITHIN a session, and they never reach storage.
fn next_document_id() -> u64 {
    use std::sync::atomic::{AtomicU64, Ordering};
    static NEXT: AtomicU64 = AtomicU64::new(1);
    NEXT.fetch_add(1, Ordering::Relaxed)
}

// BREP private tests: 85869bb1b2ce9045