Skip to main content

brep_app/panels/
update_components.rs

1//! UPDATE COMPONENTS — the assemblies build-spec §8.6 flow: compare each
2//! parts-library entry's `sourceSignature` against the ModelStore's CURRENT
3//! content for its `sourceKey` (with [`document_signature`], the ONE signature
4//! fn), badge the outdated count on the Assembly Constraints header and the
5//! per-node rows of the structure tree, and refresh every outdated entry
6//! through the ESTABLISHED document-transport lane
7//! ([`refresh_library_entry`]): rewrite the embedded `document` + a fresh
8//! signature + a BLANKED snapshot per entry, then ONE reload — the kernel
9//! self-heals every instance from the refreshed documents, re-solves, and the
10//! main-side sync captures the healed snapshots back into the document.
11//!
12//! # Staleness (recompute on a cheap trigger, never per frame)
13//!
14//! The comparison reads the store once per entry — too hot to recompute on
15//! every panel draw. [`UpdateComponents::ensure_current`] caches the result
16//! keyed on `(EngineState::applied_generation, FileDialog::save_generation)`:
17//!
18//! * an applied history run bumps the first half (covers opening an assembly,
19//!   inserting components, an update-components refresh, constraint solves);
20//! * a successful store save bumps the second (covers Edit Part → edit → save
21//!   in the part's own tab → back to the assembly tab: the badge lights
22//!   without a restart).
23//!
24//! Entries with an EMPTY `sourceKey` (embedded-only parts) are skipped;
25//! entries whose key has no store document are NOTED (surfaced in the header
26//! hover + a run-time notice) but never counted — they cannot be refreshed.
27//!
28//! The comparison only means "outdated" because every writer keeps the entry's
29//! `sourceSignature` equal to what the FILE holds: the insert lane hashes the
30//! file it read, [`UpdateComponents::run`] hashes the file it just pulled in,
31//! and an in-document part edit SAVES the part back to its `sourceKey` before
32//! stamping the signature (`panels::parts_library`'s write-through lane).
33//! Without that last one an in-context edit read as outdated against a file it
34//! was newer than — the badge meaning the opposite of what happened.
35
36use crate::panels::parts_library::{document_signature, refresh_library_entry};
37use crate::store::ModelStore;
38use brep_render::engine_state::EngineState;
39use serde_json::Value;
40
41/// The shell-owned outdated-parts checker + batch refresher. One instance on
42/// the app; the constraints panel reads the count (and runs the refresh), the
43/// structure tree reads per-part flags.
44#[derive(Default)]
45pub struct UpdateComponents {
46    /// Parts-library entry names whose store content no longer matches their
47    /// `sourceSignature` — the badge count.
48    outdated: Vec<String>,
49    /// Entry names with a `sourceKey` but NO store document under it (noted,
50    /// never counted — nothing to refresh from).
51    missing: Vec<String>,
52    /// The `(applied_generation, save_generation)` key the cache was computed
53    /// against; `None` forces a recompute on the next ensure.
54    checked: Option<(u64, u64)>,
55}
56
57impl UpdateComponents {
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Recompute the outdated/missing sets when the staleness key moved (or
63    /// after [`Self::invalidate`]); otherwise a cheap compare. The shell calls
64    /// this once per frame BEFORE the assembly panels draw.
65    pub fn ensure_current(
66        &mut self,
67        state: &mut EngineState,
68        model_store: &dyn ModelStore,
69        save_generation: u64,
70    ) {
71        let key = (state.applied_generation(), save_generation);
72        if self.checked == Some(key) {
73            return;
74        }
75        let (outdated, missing) = compute(state, model_store);
76        self.outdated = outdated;
77        self.missing = missing;
78        self.checked = Some(key);
79    }
80
81    /// Drop the cache so the next [`Self::ensure_current`] recomputes even on
82    /// an unmoved key (after a run, or an external store change).
83    pub fn invalidate(&mut self) {
84        self.checked = None;
85    }
86
87    /// The outdated-entry count — the constraints-header badge number.
88    pub fn outdated_count(&self) -> usize {
89        self.outdated.len()
90    }
91
92    /// Whether `part_name`'s library entry is outdated — the structure tree's
93    /// per-node badge (every instance of the part lights).
94    pub fn is_outdated(&self, part_name: &str) -> bool {
95        self.outdated.iter().any(|part| part == part_name)
96    }
97
98    /// Entry names noted as source-less (header hover text).
99    pub fn missing(&self) -> &[String] {
100        &self.missing
101    }
102
103    /// RUN the update (the header button): recompute against the LIVE document
104    /// (never a stale cache), rewrite every outdated entry from its store
105    /// content through [`refresh_library_entry`], then reload ONCE — the
106    /// kernel self-heals every instance + re-solves, and the main-side sync
107    /// captures healed snapshots back. Per-entry failures (missing store key,
108    /// unreadable content) toast and SKIP — the batch never aborts. Returns
109    /// the number of entries refreshed.
110    pub fn run(
111        &mut self,
112        state: &mut EngineState,
113        model_store: &dyn ModelStore,
114    ) -> Result<usize, String> {
115        let (outdated, missing) = compute(state, model_store);
116        if !missing.is_empty() {
117            state.push_notice(format!(
118                "Update components: no source document for {} — skipped",
119                missing.join(", ")
120            ));
121        }
122        if outdated.is_empty() {
123            self.invalidate();
124            return Ok(0);
125        }
126        let mut document: Value = serde_json::from_str(&state.history_request_json())
127            .map_err(|error| format!("assembly document unreadable: {error}"))?;
128        let mut refreshed = 0usize;
129        for part_name in &outdated {
130            let source_key = document["partsLibrary"][part_name]["sourceKey"]
131                .as_str()
132                .unwrap_or_default()
133                .to_string();
134            let Some(contents) = model_store.read(&source_key) else {
135                state.push_notice(format!(
136                    "Update components: '{part_name}' source '{source_key}' missing — skipped"
137                ));
138                continue;
139            };
140            match refresh_library_entry(&mut document, part_name, &contents) {
141                Ok(()) => refreshed += 1,
142                Err(error) => state.push_notice(format!(
143                    "Update components: '{part_name}': {error} — skipped"
144                )),
145            }
146        }
147        if refreshed > 0 {
148            // The ONE reload for the whole batch (no zoom — the camera stays).
149            state
150                .set_history_json(&document.to_string())
151                .map_err(|error| format!("assembly reload failed: {error}"))?;
152            state.push_notice(format!(
153                "Updated {refreshed} part(s) from source — every instance rebuilt"
154            ));
155        }
156        self.invalidate();
157        Ok(refreshed)
158    }
159}
160
161/// The signature comparison over the LIVE document's `partsLibrary` block →
162/// `(outdated, missing)` entry-name sets. Componentless documents short-circuit
163/// to empty (zero cost for modeling files).
164fn compute(state: &mut EngineState, model_store: &dyn ModelStore) -> (Vec<String>, Vec<String>) {
165    let mut outdated = Vec::new();
166    let mut missing = Vec::new();
167    if !state.history_has_assembly() {
168        return (outdated, missing);
169    }
170    // The document's partsLibrary block mirrors the kernel store post-sync
171    // (heals + GC included) — make sure that fold ran for this generation.
172    state.ensure_assembly_synced();
173    let Ok(document) = serde_json::from_str::<Value>(&state.history_request_json()) else {
174        return (outdated, missing);
175    };
176    let Some(library) = document.get("partsLibrary").and_then(Value::as_object) else {
177        return (outdated, missing);
178    };
179    for (part_name, entry) in library {
180        let Some(source_key) = entry
181            .get("sourceKey")
182            .and_then(Value::as_str)
183            .filter(|key| !key.is_empty())
184        else {
185            continue; // embedded-only part: no source to compare against
186        };
187        match model_store.read(source_key) {
188            None => missing.push(part_name.clone()),
189            Some(contents) => {
190                let signature = entry
191                    .get("sourceSignature")
192                    .and_then(Value::as_str)
193                    .unwrap_or_default();
194                if document_signature(&contents) != signature {
195                    outdated.push(part_name.clone());
196                }
197            }
198        }
199    }
200    (outdated, missing)
201}
202
203#[cfg(all(test, not(target_arch = "wasm32")))]
204pub(crate) mod tests {
205    use super::*;
206    use crate::store::MemModelStore;
207    use brep_render::engine_state::ComponentInsert;
208
209    /// A one-cube part document with a parameterized width (the edit under
210    /// test), matching the shared assembly fixtures.
211    pub(crate) fn part_document(size_x: f64) -> String {
212        serde_json::json!({
213            "expressions": "",
214            "configurator": {},
215            "features": [{
216                "type": "P.CU",
217                "inputParams": {
218                    "id": "Part",
219                    "sizeX": size_x, "sizeY": 3.0, "sizeZ": 4.0,
220                    "transform": {
221                        "position": [0.0, 0.0, 0.0],
222                        "rotationEuler": [0.0, 0.0, 0.0],
223                        "scale": [1.0, 1.0, 1.0]
224                    },
225                    "boolean": { "targets": [], "operation": "NONE" }
226                },
227                "persistentData": {}
228            }]
229        })
230        .to_string()
231    }
232
233    /// A two-instance assembly inserted THROUGH the real flow, with its part
234    /// content resident in an in-memory store under the entry's `sourceKey`
235    /// and the signature written by the ONE signature fn (as the insert lane
236    /// does) — the fresh-insert-compares-up-to-date invariant's ground truth.
237    pub(crate) fn assembly_with_store() -> (EngineState, MemModelStore) {
238        brep_render::brep_kernel::clear_history_cache();
239        let store = MemModelStore::new();
240        let content = part_document(10.0);
241        store.put("widget", &content);
242        let mut state = EngineState::new();
243        state
244            .insert_component(ComponentInsert::New {
245                name: "widget",
246                source_key: "widget",
247                source_signature: &document_signature(&content),
248                document_json: &content,
249            })
250            .expect("insert 1");
251        state
252            .insert_component(ComponentInsert::Existing { part_name: "widget" })
253            .expect("insert 2");
254        (state, store)
255    }
256
257    fn size_x_of(state: &EngineState, solid: &str) -> f64 {
258        let bbox = &state.scene.solid(solid).expect(solid).bbox;
259        bbox.max[0] - bbox.min[0]
260    }
261
262    /// DETECTION: fresh insert → 0; changed store content → counted; a
263    /// missing store key → not counted but noted. The save-generation half of
264    /// the staleness key drives each recheck (the after-save trigger).
265    #[test]
266    fn outdated_detection_counts_changed_and_notes_missing() {
267        let (mut state, store) = assembly_with_store();
268        let mut updates = UpdateComponents::new();
269
270        updates.ensure_current(&mut state, &store, 0);
271        assert_eq!(updates.outdated_count(), 0, "fresh insert compares up-to-date");
272        assert!(updates.missing().is_empty());
273
274        // The part's source changes (edited + saved elsewhere) → counted, and
275        // BOTH instances of the one entry light through is_outdated.
276        store.put("widget", &part_document(14.0));
277        updates.ensure_current(&mut state, &store, 1);
278        assert_eq!(updates.outdated_count(), 1);
279        assert!(updates.is_outdated("widget"));
280        assert!(!updates.is_outdated("other"));
281
282        // A key-order-only variant of the ORIGINAL content is NOT outdated
283        // (the signature walks parsed JSON with sorted keys, not raw text).
284        let reordered = {
285            let value: Value = serde_json::from_str(&part_document(10.0)).unwrap();
286            serde_json::to_string_pretty(&value).unwrap()
287        };
288        store.put("widget", &reordered);
289        updates.ensure_current(&mut state, &store, 2);
290        assert_eq!(updates.outdated_count(), 0, "formatting-only resave is not outdated");
291
292        // The store document vanishes: noted, never counted.
293        store.remove("widget").unwrap();
294        updates.ensure_current(&mut state, &store, 3);
295        assert_eq!(updates.outdated_count(), 0);
296        assert_eq!(updates.missing(), ["widget".to_string()]);
297    }
298
299    /// STALENESS: a stable `(applied, save)` key never re-reads the store; a
300    /// moved save-generation (or an applied run) recomputes.
301    #[test]
302    fn ensure_current_caches_per_generation_key() {
303        let (mut state, store) = assembly_with_store();
304        let mut updates = UpdateComponents::new();
305
306        updates.ensure_current(&mut state, &store, 0);
307        let reads = store.reads();
308        assert!(reads > 0, "first ensure reads the store");
309        updates.ensure_current(&mut state, &store, 0);
310        assert_eq!(store.reads(), reads, "stable key: no re-read per frame");
311        updates.ensure_current(&mut state, &store, 1);
312        assert!(store.reads() > reads, "a save bump recomputes");
313    }
314
315    /// THE REFRESH ROUND-TRIP: run() rewrites the outdated entry from the
316    /// store (document + signature), the reload rebuilds EVERY instance with
317    /// the edit, the main-side sync captures a restorable snapshot back into
318    /// the document (non-blank), and the badge recomputes to 0.
319    #[test]
320    fn run_refreshes_entries_instances_and_snapshot() {
321        let (mut state, store) = assembly_with_store();
322        let new_content = part_document(14.0);
323        store.put("widget", &new_content);
324        let mut updates = UpdateComponents::new();
325        updates.ensure_current(&mut state, &store, 1);
326        assert_eq!(updates.outdated_count(), 1);
327
328        let refreshed = updates.run(&mut state, &store).expect("run ok");
329        assert_eq!(refreshed, 1);
330
331        // Entry document + signature updated; snapshot captured back healed.
332        let document: Value = serde_json::from_str(&state.history_request_json()).unwrap();
333        let entry = &document["partsLibrary"]["widget"];
334        assert_eq!(entry["document"]["features"][0]["inputParams"]["sizeX"], 14.0);
335        assert_eq!(entry["sourceSignature"], document_signature(&new_content));
336        let snapshot = entry["snapshot"].as_str().unwrap_or_default();
337        assert!(!snapshot.is_empty(), "the sync captured the healed snapshot");
338        assert!(brep_render::brep_kernel::restore_solids(snapshot).is_ok());
339
340        // Both instances rebuilt with the edit.
341        assert!((size_x_of(&state, "ACOMP1:Part") - 14.0).abs() < 1e-6);
342        assert!((size_x_of(&state, "ACOMP2:Part") - 14.0).abs() < 1e-6);
343
344        // The run invalidated the cache; a recheck lands on 0.
345        updates.ensure_current(&mut state, &store, 1);
346        assert_eq!(updates.outdated_count(), 0, "everything up-to-date after the run");
347
348        let notices = state.take_notices();
349        assert!(
350            notices.iter().any(|n| n.contains("Updated 1 part")),
351            "{notices:?}"
352        );
353    }
354
355    /// FAILURE TOLERANCE: an unreadable source toasts + skips while the good
356    /// entry refreshes; a vanished source key is noted + skipped — the batch
357    /// never aborts and never damages the skipped entry.
358    #[test]
359    fn per_entry_failures_toast_and_skip() {
360        brep_render::brep_kernel::clear_history_cache();
361        let store = MemModelStore::new();
362        let widget = part_document(10.0);
363        let gadget = part_document(9.0);
364        store.put("widget", &widget);
365        store.put("gadget", &gadget);
366        let mut state = EngineState::new();
367        state
368            .insert_component(ComponentInsert::New {
369                name: "widget",
370                source_key: "widget",
371                source_signature: &document_signature(&widget),
372                document_json: &widget,
373            })
374            .unwrap();
375        state
376            .insert_component(ComponentInsert::New {
377                name: "gadget",
378                source_key: "gadget",
379                source_signature: &document_signature(&gadget),
380                document_json: &gadget,
381            })
382            .unwrap();
383        let mut updates = UpdateComponents::new();
384
385        // widget's source changes VALIDLY; gadget's becomes unreadable.
386        store.put("widget", &part_document(14.0));
387        store.put("gadget", "definitely not json");
388        let refreshed = updates.run(&mut state, &store).expect("batch survives");
389        assert_eq!(refreshed, 1, "the good entry refreshed, the bad one skipped");
390        let notices = state.take_notices();
391        assert!(
392            notices
393                .iter()
394                .any(|n| n.contains("gadget") && n.contains("unreadable")),
395            "{notices:?}"
396        );
397        let document: Value = serde_json::from_str(&state.history_request_json()).unwrap();
398        assert_eq!(
399            document["partsLibrary"]["widget"]["document"]["features"][0]["inputParams"]["sizeX"],
400            14.0
401        );
402        assert_eq!(
403            document["partsLibrary"]["gadget"]["document"]["features"][0]["inputParams"]["sizeX"],
404            9.0,
405            "the skipped entry keeps its old content"
406        );
407
408        // Second wave: gadget's source key vanishes entirely (missing lane).
409        store.put("widget", &part_document(16.0));
410        store.remove("gadget").unwrap();
411        let refreshed = updates.run(&mut state, &store).expect("batch survives");
412        assert_eq!(refreshed, 1);
413        let notices = state.take_notices();
414        assert!(
415            notices
416                .iter()
417                .any(|n| n.contains("no source document") && n.contains("gadget")),
418            "{notices:?}"
419        );
420    }
421}