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, History::revision,
17//! FileDialog::save_generation, ModelStore::mutation_generation)`:
18//!
19//! * an applied history run bumps the first half (covers opening an assembly,
20//! inserting components, an update-components refresh, constraint solves);
21//! * a successful store save bumps the third (covers Edit Part → edit → save
22//! in the part's own tab → back to the assembly tab: the badge lights
23//! without a restart);
24//! * ANY other write or delete through the store bumps the fourth.
25//!
26//! The fourth is not redundant with the third. `save_generation` counts what
27//! the Save dialog did; the comparison depends on what the STORE holds, and
28//! the dialog is not the only thing that writes to it. `kicad_bulk::write_part`
29//! re-imports a part onto a document name an open assembly may already
30//! reference, and the explorer's delete row (`panels::file`'s `del:` handler)
31//! takes a source away — neither touches the assembly document, so neither
32//! moved any of the other three, and the badge stayed dark until something
33//! unrelated moved. Keying on the store's own mutation counter covers every
34//! door into it by construction, including doors not written yet.
35//!
36//! Entries with an EMPTY `sourceKey` (embedded-only parts) are skipped;
37//! entries whose key has no store document are NOTED (surfaced in the header
38//! hover + a run-time notice) but never counted — they cannot be refreshed.
39//!
40//! The comparison only means "outdated" because every writer keeps the entry's
41//! `sourceSignature` equal to what the FILE holds: the insert lane hashes the
42//! file it read, [`UpdateComponents::run`] hashes the file it just pulled in,
43//! and an in-document part edit SAVES the part back to its `sourceKey` before
44//! stamping the signature (`panels::parts_library`'s write-through lane).
45//! Without that last one an in-context edit read as outdated against a file it
46//! was newer than — the badge meaning the opposite of what happened.
47
48use crate::panels::parts_library::{document_signature, refresh_library_entry};
49use crate::store::ModelStore;
50use brep_render::engine_state::{EngineState, NoticeSeverity};
51use crate::store::{ReadNow, Residency};
52use serde_json::Value;
53
54/// The shell-owned outdated-parts checker + batch refresher. One instance on
55/// the app; the constraints panel reads the count (and runs the refresh), the
56/// structure tree reads per-part flags.
57#[derive(Default)]
58pub struct UpdateComponents {
59 /// Parts-library entry names whose store content no longer matches their
60 /// `sourceSignature` — the badge count.
61 outdated: Vec<String>,
62 /// Entry names with a `sourceKey` but NO store document under it (noted,
63 /// never counted — nothing to refresh from).
64 missing: Vec<String>,
65 /// The `(applied_generation, history_revision, save_generation,
66 /// store_mutation_generation)` key the cache was computed against; `None`
67 /// forces a recompute on the next ensure.
68 checked: Option<(u64, u64, u64, u64)>,
69}
70
71impl UpdateComponents {
72 pub fn new() -> Self {
73 Self::default()
74 }
75
76 /// Recompute the outdated/missing sets when the staleness key moved (or
77 /// after [`Self::invalidate`]); otherwise a cheap compare. The shell calls
78 /// this once per frame BEFORE the assembly panels draw.
79 pub fn ensure_current(
80 &mut self,
81 state: &mut EngineState,
82 model_store: &dyn ModelStore,
83 save_generation: u64,
84 ) {
85 let key = (
86 state.applied_generation(),
87 state.history.revision(),
88 save_generation,
89 model_store.mutation_generation(),
90 );
91 if self.checked == Some(key) {
92 return;
93 }
94 let (outdated, missing) = compute(state, model_store);
95 self.outdated = outdated;
96 self.missing = missing;
97 self.checked = Some(key);
98 }
99
100 /// Drop the cache so the next [`Self::ensure_current`] recomputes even on
101 /// an unmoved key (after a run, or an external store change).
102 pub fn invalidate(&mut self) {
103 self.checked = None;
104 }
105
106 /// The outdated-entry count — the constraints-header badge number.
107 pub fn outdated_count(&self) -> usize {
108 self.outdated.len()
109 }
110
111 /// The outdated entry names — what the Diagram and PCB workbenches mark on
112 /// the components placed from them.
113 pub fn outdated(&self) -> &[String] {
114 &self.outdated
115 }
116
117 /// Whether `part_name`'s library entry is outdated — the structure tree's
118 /// per-node badge (every instance of the part lights).
119 pub fn is_outdated(&self, part_name: &str) -> bool {
120 self.outdated.iter().any(|part| part == part_name)
121 }
122
123 /// Entry names noted as source-less (header hover text).
124 pub fn missing(&self) -> &[String] {
125 &self.missing
126 }
127
128 /// RUN the update (the header button): recompute against the LIVE document
129 /// (never a stale cache), rewrite every outdated entry from its store
130 /// content through [`refresh_library_entry`], then reload ONCE — the
131 /// kernel self-heals every instance + re-solves, and the main-side sync
132 /// captures healed snapshots back. Per-entry failures (missing store key,
133 /// unreadable content) toast and SKIP — the batch never aborts. Returns
134 /// the number of entries refreshed.
135 pub fn run(
136 &mut self,
137 state: &mut EngineState,
138 model_store: &dyn ModelStore,
139 ) -> Result<usize, String> {
140 let (outdated, missing) = compute(state, model_store);
141 if !missing.is_empty() {
142 state.push_notice_as(NoticeSeverity::Warning, format!(
143 "Update components: no source document for {} — skipped",
144 missing.join(", ")
145 ));
146 }
147 if outdated.is_empty() {
148 self.invalidate();
149 return Ok(0);
150 }
151 let mut document: Value = serde_json::from_str(&state.history_request_json())
152 .map_err(|error| format!("assembly document unreadable: {error}"))?;
153 let mut refreshed = 0usize;
154 for part_name in &outdated {
155 let source_key = document["partsLibrary"][part_name]["sourceKey"]
156 .as_str()
157 .unwrap_or_default()
158 .to_string();
159 let contents = match crate::store::read_now(model_store, &source_key) {
160 ReadNow::Ready(contents) => contents,
161 ReadNow::Loading => {
162 state.push_notice_as(NoticeSeverity::Info, format!(
163 "Update components: '{part_name}' is still loading from the store — run the update again in a moment"
164 ));
165 continue;
166 }
167 ReadNow::Absent => {
168 state.push_notice_as(NoticeSeverity::Warning, format!(
169 "Update components: '{part_name}' source '{source_key}' missing — skipped"
170 ));
171 continue;
172 }
173 };
174 match refresh_library_entry(&mut document, part_name, &contents) {
175 Ok(unlinked) => {
176 refreshed += 1;
177 // Per-component, not per-refresh: a sheet component the
178 // assembly can no longer place is named and the rest of the
179 // batch still lands.
180 for line in unlinked {
181 state.push_notice_as(NoticeSeverity::Warning, format!("Update components: '{part_name}': {line}"));
182 }
183 }
184 Err(error) => state.push_notice(format!(
185 "Update components: '{part_name}': {error} — skipped"
186 )),
187 }
188 }
189 if refreshed > 0 {
190 // The ONE reload for the whole batch (no zoom — the camera stays).
191 state
192 .edit_document_json(&document.to_string())
193 .map_err(|error| format!("assembly reload failed: {error}"))?;
194 state.push_notice_as(NoticeSeverity::Info, format!(
195 "Updated {refreshed} part(s) from source — instances and eCAD snapshots refreshed"
196 ));
197 }
198 self.invalidate();
199 Ok(refreshed)
200 }
201}
202
203/// The signature comparison over the LIVE document's `partsLibrary` block →
204/// `(outdated, missing)` entry-name sets. Componentless documents short-circuit
205/// to empty (zero cost for modeling files).
206fn compute(state: &mut EngineState, model_store: &dyn ModelStore) -> (Vec<String>, Vec<String>) {
207 let mut outdated = Vec::new();
208 let mut missing = Vec::new();
209 if !state.history_has_assembly() {
210 return (outdated, missing);
211 }
212 // The document's partsLibrary block mirrors the kernel store (heals + GC
213 // included); `apply_run_output` installs and mirrors the store of any run
214 // that moved it, so the block is already current for this generation.
215 let Ok(document) = serde_json::from_str::<Value>(&state.history_request_json()) else {
216 return (outdated, missing);
217 };
218 let Some(library) = document.get("partsLibrary").and_then(Value::as_object) else {
219 return (outdated, missing);
220 };
221 for (part_name, entry) in library {
222 let Some(source_key) = entry
223 .get("sourceKey")
224 .and_then(Value::as_str)
225 .filter(|key| !key.is_empty())
226 else {
227 continue; // embedded-only part: no source to compare against
228 };
229 match crate::store::read_now(model_store, source_key) {
230 // Listed but not loaded yet (an index-hydrated store, S0): neither
231 // missing nor outdated — there is nothing to judge until the bytes
232 // land, and their landing moves the store generation this cache is
233 // keyed on, so the entry is judged then.
234 ReadNow::Loading => {}
235 ReadNow::Absent => missing.push(part_name.clone()),
236 ReadNow::Ready(contents) => {
237 let signature = entry
238 .get("sourceSignature")
239 .and_then(Value::as_str)
240 .unwrap_or_default();
241 // A component's version belongs to the DEVICE it was placed
242 // from, and a nested device's key is a key of ITS OWN parent's
243 // library — the same spelling in a different namespace. Judging
244 // one against a top-level entry read it as stale against a part
245 // it was never placed from, and NOTHING could clear the badge:
246 // `parts_library::refresh_library_entry` binds by occurrence
247 // CHAIN and correctly declines to touch a device that is not
248 // this entry's. So a chain (`ACOMP9:ACOMP1`) is not this
249 // entry's to judge. A top-level occurrence is, and so is a
250 // legacy component with no occurrence at all.
251 let stale_sheet = ["diagram", "pcb"].iter().any(|block| {
252 document[*block]["components"].as_array().is_some_and(|components| {
253 components.iter().any(|component| component["part"]["key"].as_str() == Some(part_name.as_str())
254 && !component["part"]["instance"].as_str().is_some_and(|id| id.contains(':'))
255 && component["part"]["signature"].as_str() != Some(signature))
256 })
257 });
258 if document_signature(&contents) != signature || stale_sheet {
259 outdated.push(part_name.clone());
260 }
261 }
262 }
263 }
264 (outdated, missing)
265}
266