1use 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#[derive(Default)]
45pub struct UpdateComponents {
46 outdated: Vec<String>,
49 missing: Vec<String>,
52 checked: Option<(u64, u64)>,
55}
56
57impl UpdateComponents {
58 pub fn new() -> Self {
59 Self::default()
60 }
61
62 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 pub fn invalidate(&mut self) {
84 self.checked = None;
85 }
86
87 pub fn outdated_count(&self) -> usize {
89 self.outdated.len()
90 }
91
92 pub fn is_outdated(&self, part_name: &str) -> bool {
95 self.outdated.iter().any(|part| part == part_name)
96 }
97
98 pub fn missing(&self) -> &[String] {
100 &self.missing
101 }
102
103 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 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
161fn 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 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; };
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 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 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 #[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 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 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 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 #[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 #[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 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 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 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 #[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 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 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}