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
//! UPDATE COMPONENTS — the assemblies build-spec §8.6 flow: compare each
//! parts-library entry's `sourceSignature` against the ModelStore's CURRENT
//! content for its `sourceKey` (with [`document_signature`], the ONE signature
//! fn), badge the outdated count on the Assembly Constraints header and the
//! per-node rows of the structure tree, and refresh every outdated entry
//! through the ESTABLISHED document-transport lane
//! ([`refresh_library_entry`]): rewrite the embedded `document` + a fresh
//! signature + a BLANKED snapshot per entry, then ONE reload — the kernel
//! self-heals every instance from the refreshed documents, re-solves, and the
//! main-side sync captures the healed snapshots back into the document.
//!
//! # Staleness (recompute on a cheap trigger, never per frame)
//!
//! The comparison reads the store once per entry — too hot to recompute on
//! every panel draw. [`UpdateComponents::ensure_current`] caches the result
//! keyed on `(EngineState::applied_generation, FileDialog::save_generation)`:
//!
//! * an applied history run bumps the first half (covers opening an assembly,
//!   inserting components, an update-components refresh, constraint solves);
//! * a successful store save bumps the second (covers Edit Part → edit → save
//!   in the part's own tab → back to the assembly tab: the badge lights
//!   without a restart).
//!
//! Entries with an EMPTY `sourceKey` (embedded-only parts) are skipped;
//! entries whose key has no store document are NOTED (surfaced in the header
//! hover + a run-time notice) but never counted — they cannot be refreshed.
//!
//! The comparison only means "outdated" because every writer keeps the entry's
//! `sourceSignature` equal to what the FILE holds: the insert lane hashes the
//! file it read, [`UpdateComponents::run`] hashes the file it just pulled in,
//! and an in-document part edit SAVES the part back to its `sourceKey` before
//! stamping the signature (`panels::parts_library`'s write-through lane).
//! Without that last one an in-context edit read as outdated against a file it
//! was newer than — the badge meaning the opposite of what happened.

use crate::panels::parts_library::{document_signature, refresh_library_entry};
use crate::store::ModelStore;
use brep_render::engine_state::EngineState;
use serde_json::Value;

/// The shell-owned outdated-parts checker + batch refresher. One instance on
/// the app; the constraints panel reads the count (and runs the refresh), the
/// structure tree reads per-part flags.
#[derive(Default)]
pub struct UpdateComponents {
    /// Parts-library entry names whose store content no longer matches their
    /// `sourceSignature` — the badge count.
    outdated: Vec<String>,
    /// Entry names with a `sourceKey` but NO store document under it (noted,
    /// never counted — nothing to refresh from).
    missing: Vec<String>,
    /// The `(applied_generation, save_generation)` key the cache was computed
    /// against; `None` forces a recompute on the next ensure.
    checked: Option<(u64, u64)>,
}

impl UpdateComponents {
    pub fn new() -> Self {
        Self::default()
    }

    /// Recompute the outdated/missing sets when the staleness key moved (or
    /// after [`Self::invalidate`]); otherwise a cheap compare. The shell calls
    /// this once per frame BEFORE the assembly panels draw.
    pub fn ensure_current(
        &mut self,
        state: &mut EngineState,
        model_store: &dyn ModelStore,
        save_generation: u64,
    ) {
        let key = (state.applied_generation(), save_generation);
        if self.checked == Some(key) {
            return;
        }
        let (outdated, missing) = compute(state, model_store);
        self.outdated = outdated;
        self.missing = missing;
        self.checked = Some(key);
    }

    /// Drop the cache so the next [`Self::ensure_current`] recomputes even on
    /// an unmoved key (after a run, or an external store change).
    pub fn invalidate(&mut self) {
        self.checked = None;
    }

    /// The outdated-entry count — the constraints-header badge number.
    pub fn outdated_count(&self) -> usize {
        self.outdated.len()
    }

    /// Whether `part_name`'s library entry is outdated — the structure tree's
    /// per-node badge (every instance of the part lights).
    pub fn is_outdated(&self, part_name: &str) -> bool {
        self.outdated.iter().any(|part| part == part_name)
    }

    /// Entry names noted as source-less (header hover text).
    pub fn missing(&self) -> &[String] {
        &self.missing
    }

    /// RUN the update (the header button): recompute against the LIVE document
    /// (never a stale cache), rewrite every outdated entry from its store
    /// content through [`refresh_library_entry`], then reload ONCE — the
    /// kernel self-heals every instance + re-solves, and the main-side sync
    /// captures healed snapshots back. Per-entry failures (missing store key,
    /// unreadable content) toast and SKIP — the batch never aborts. Returns
    /// the number of entries refreshed.
    pub fn run(
        &mut self,
        state: &mut EngineState,
        model_store: &dyn ModelStore,
    ) -> Result<usize, String> {
        let (outdated, missing) = compute(state, model_store);
        if !missing.is_empty() {
            state.push_notice(format!(
                "Update components: no source document for {} — skipped",
                missing.join(", ")
            ));
        }
        if outdated.is_empty() {
            self.invalidate();
            return Ok(0);
        }
        let mut document: Value = serde_json::from_str(&state.history_request_json())
            .map_err(|error| format!("assembly document unreadable: {error}"))?;
        let mut refreshed = 0usize;
        for part_name in &outdated {
            let source_key = document["partsLibrary"][part_name]["sourceKey"]
                .as_str()
                .unwrap_or_default()
                .to_string();
            let Some(contents) = model_store.read(&source_key) else {
                state.push_notice(format!(
                    "Update components: '{part_name}' source '{source_key}' missing — skipped"
                ));
                continue;
            };
            match refresh_library_entry(&mut document, part_name, &contents) {
                Ok(()) => refreshed += 1,
                Err(error) => state.push_notice(format!(
                    "Update components: '{part_name}': {error} — skipped"
                )),
            }
        }
        if refreshed > 0 {
            // The ONE reload for the whole batch (no zoom — the camera stays).
            state
                .set_history_json(&document.to_string())
                .map_err(|error| format!("assembly reload failed: {error}"))?;
            state.push_notice(format!(
                "Updated {refreshed} part(s) from source — every instance rebuilt"
            ));
        }
        self.invalidate();
        Ok(refreshed)
    }
}

/// The signature comparison over the LIVE document's `partsLibrary` block →
/// `(outdated, missing)` entry-name sets. Componentless documents short-circuit
/// to empty (zero cost for modeling files).
fn compute(state: &mut EngineState, model_store: &dyn ModelStore) -> (Vec<String>, Vec<String>) {
    let mut outdated = Vec::new();
    let mut missing = Vec::new();
    if !state.history_has_assembly() {
        return (outdated, missing);
    }
    // The document's partsLibrary block mirrors the kernel store post-sync
    // (heals + GC included) — make sure that fold ran for this generation.
    state.ensure_assembly_synced();
    let Ok(document) = serde_json::from_str::<Value>(&state.history_request_json()) else {
        return (outdated, missing);
    };
    let Some(library) = document.get("partsLibrary").and_then(Value::as_object) else {
        return (outdated, missing);
    };
    for (part_name, entry) in library {
        let Some(source_key) = entry
            .get("sourceKey")
            .and_then(Value::as_str)
            .filter(|key| !key.is_empty())
        else {
            continue; // embedded-only part: no source to compare against
        };
        match model_store.read(source_key) {
            None => missing.push(part_name.clone()),
            Some(contents) => {
                let signature = entry
                    .get("sourceSignature")
                    .and_then(Value::as_str)
                    .unwrap_or_default();
                if document_signature(&contents) != signature {
                    outdated.push(part_name.clone());
                }
            }
        }
    }
    (outdated, missing)
}

// BREP private tests: 20931f15c9188952