use crate::panels::parts_library::{document_signature, refresh_library_entry};
use crate::store::ModelStore;
use brep_render::engine_state::EngineState;
use serde_json::Value;
#[derive(Default)]
pub struct UpdateComponents {
outdated: Vec<String>,
missing: Vec<String>,
checked: Option<(u64, u64)>,
}
impl UpdateComponents {
pub fn new() -> Self {
Self::default()
}
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);
}
pub fn invalidate(&mut self) {
self.checked = None;
}
pub fn outdated_count(&self) -> usize {
self.outdated.len()
}
pub fn is_outdated(&self, part_name: &str) -> bool {
self.outdated.iter().any(|part| part == part_name)
}
pub fn missing(&self) -> &[String] {
&self.missing
}
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 {
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)
}
}
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);
}
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; };
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)
}
#[cfg(all(test, not(target_arch = "wasm32")))]
pub(crate) mod tests {
use super::*;
use crate::store::MemModelStore;
use brep_render::engine_state::ComponentInsert;
pub(crate) fn part_document(size_x: f64) -> String {
serde_json::json!({
"expressions": "",
"configurator": {},
"features": [{
"type": "P.CU",
"inputParams": {
"id": "Part",
"sizeX": size_x, "sizeY": 3.0, "sizeZ": 4.0,
"transform": {
"position": [0.0, 0.0, 0.0],
"rotationEuler": [0.0, 0.0, 0.0],
"scale": [1.0, 1.0, 1.0]
},
"boolean": { "targets": [], "operation": "NONE" }
},
"persistentData": {}
}]
})
.to_string()
}
pub(crate) fn assembly_with_store() -> (EngineState, MemModelStore) {
brep_render::brep_kernel::clear_history_cache();
let store = MemModelStore::new();
let content = part_document(10.0);
store.put("widget", &content);
let mut state = EngineState::new();
state
.insert_component(ComponentInsert::New {
name: "widget",
source_key: "widget",
source_signature: &document_signature(&content),
document_json: &content,
})
.expect("insert 1");
state
.insert_component(ComponentInsert::Existing { part_name: "widget" })
.expect("insert 2");
(state, store)
}
fn size_x_of(state: &EngineState, solid: &str) -> f64 {
let bbox = &state.scene.solid(solid).expect(solid).bbox;
bbox.max[0] - bbox.min[0]
}
#[test]
fn outdated_detection_counts_changed_and_notes_missing() {
let (mut state, store) = assembly_with_store();
let mut updates = UpdateComponents::new();
updates.ensure_current(&mut state, &store, 0);
assert_eq!(updates.outdated_count(), 0, "fresh insert compares up-to-date");
assert!(updates.missing().is_empty());
store.put("widget", &part_document(14.0));
updates.ensure_current(&mut state, &store, 1);
assert_eq!(updates.outdated_count(), 1);
assert!(updates.is_outdated("widget"));
assert!(!updates.is_outdated("other"));
let reordered = {
let value: Value = serde_json::from_str(&part_document(10.0)).unwrap();
serde_json::to_string_pretty(&value).unwrap()
};
store.put("widget", &reordered);
updates.ensure_current(&mut state, &store, 2);
assert_eq!(updates.outdated_count(), 0, "formatting-only resave is not outdated");
store.remove("widget").unwrap();
updates.ensure_current(&mut state, &store, 3);
assert_eq!(updates.outdated_count(), 0);
assert_eq!(updates.missing(), ["widget".to_string()]);
}
#[test]
fn ensure_current_caches_per_generation_key() {
let (mut state, store) = assembly_with_store();
let mut updates = UpdateComponents::new();
updates.ensure_current(&mut state, &store, 0);
let reads = store.reads();
assert!(reads > 0, "first ensure reads the store");
updates.ensure_current(&mut state, &store, 0);
assert_eq!(store.reads(), reads, "stable key: no re-read per frame");
updates.ensure_current(&mut state, &store, 1);
assert!(store.reads() > reads, "a save bump recomputes");
}
#[test]
fn run_refreshes_entries_instances_and_snapshot() {
let (mut state, store) = assembly_with_store();
let new_content = part_document(14.0);
store.put("widget", &new_content);
let mut updates = UpdateComponents::new();
updates.ensure_current(&mut state, &store, 1);
assert_eq!(updates.outdated_count(), 1);
let refreshed = updates.run(&mut state, &store).expect("run ok");
assert_eq!(refreshed, 1);
let document: Value = serde_json::from_str(&state.history_request_json()).unwrap();
let entry = &document["partsLibrary"]["widget"];
assert_eq!(entry["document"]["features"][0]["inputParams"]["sizeX"], 14.0);
assert_eq!(entry["sourceSignature"], document_signature(&new_content));
let snapshot = entry["snapshot"].as_str().unwrap_or_default();
assert!(!snapshot.is_empty(), "the sync captured the healed snapshot");
assert!(brep_render::brep_kernel::restore_solids(snapshot).is_ok());
assert!((size_x_of(&state, "ACOMP1:Part") - 14.0).abs() < 1e-6);
assert!((size_x_of(&state, "ACOMP2:Part") - 14.0).abs() < 1e-6);
updates.ensure_current(&mut state, &store, 1);
assert_eq!(updates.outdated_count(), 0, "everything up-to-date after the run");
let notices = state.take_notices();
assert!(
notices.iter().any(|n| n.contains("Updated 1 part")),
"{notices:?}"
);
}
#[test]
fn per_entry_failures_toast_and_skip() {
brep_render::brep_kernel::clear_history_cache();
let store = MemModelStore::new();
let widget = part_document(10.0);
let gadget = part_document(9.0);
store.put("widget", &widget);
store.put("gadget", &gadget);
let mut state = EngineState::new();
state
.insert_component(ComponentInsert::New {
name: "widget",
source_key: "widget",
source_signature: &document_signature(&widget),
document_json: &widget,
})
.unwrap();
state
.insert_component(ComponentInsert::New {
name: "gadget",
source_key: "gadget",
source_signature: &document_signature(&gadget),
document_json: &gadget,
})
.unwrap();
let mut updates = UpdateComponents::new();
store.put("widget", &part_document(14.0));
store.put("gadget", "definitely not json");
let refreshed = updates.run(&mut state, &store).expect("batch survives");
assert_eq!(refreshed, 1, "the good entry refreshed, the bad one skipped");
let notices = state.take_notices();
assert!(
notices
.iter()
.any(|n| n.contains("gadget") && n.contains("unreadable")),
"{notices:?}"
);
let document: Value = serde_json::from_str(&state.history_request_json()).unwrap();
assert_eq!(
document["partsLibrary"]["widget"]["document"]["features"][0]["inputParams"]["sizeX"],
14.0
);
assert_eq!(
document["partsLibrary"]["gadget"]["document"]["features"][0]["inputParams"]["sizeX"],
9.0,
"the skipped entry keeps its old content"
);
store.put("widget", &part_document(16.0));
store.remove("gadget").unwrap();
let refreshed = updates.run(&mut state, &store).expect("batch survives");
assert_eq!(refreshed, 1);
let notices = state.take_notices();
assert!(
notices
.iter()
.any(|n| n.contains("no source document") && n.contains("gadget")),
"{notices:?}"
);
}
}