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// BREP private tests: 20931f15c9188952