use crate::store::ModelStore;
use brep_render::engine_state::EngineState;
use eframe::egui;
#[cfg(target_arch = "wasm32")]
use std::collections::HashMap;
struct Stash {
assembly_doc: String,
part_name: String,
component_id: String,
part_baseline: String,
}
#[derive(Default)]
pub struct AssemblyEdit {
stash: Option<Stash>,
confirm_cancel: bool,
#[cfg(target_arch = "wasm32")]
hits: HashMap<String, egui::Rect>,
}
impl AssemblyEdit {
pub fn new() -> Self {
Self::default()
}
pub fn active(&self) -> bool {
self.stash.is_some()
}
pub fn label(&self) -> Option<String> {
self.stash
.as_ref()
.map(|stash| format!("Editing {} ({})", stash.part_name, stash.component_id))
}
pub fn dirty(&self, state: &EngineState) -> bool {
self.stash
.as_ref()
.map(|stash| stash.part_baseline != state.history_request_json())
.unwrap_or(false)
}
pub fn enter(&mut self, state: &mut EngineState, component_id: &str) {
if let Some(stash) = &self.stash {
state.push_notice(format!(
"Already editing {} ({}) — Finish or Cancel first",
stash.part_name, stash.component_id
));
return;
}
if state.sketch_mode() || state.ref_select_active() {
state.push_notice(
"Finish the active sketch / reference selection before editing a part".to_string(),
);
return;
}
let Some(info) = state.component_info(component_id) else {
state.push_notice(format!("'{component_id}' is not an assembly component"));
return;
};
let assembly_doc = state.history_request_json();
let document: serde_json::Value = match serde_json::from_str(&assembly_doc) {
Ok(value) => value,
Err(error) => {
state.push_notice(format!("Edit in place: assembly document unreadable: {error}"));
return;
}
};
let entry = &document["partsLibrary"][&info.part_name];
let part_doc = &entry["document"];
if !part_doc.is_object() {
state.push_notice(format!(
"Edit in place: part '{}' has no parts-library document",
info.part_name
));
return;
}
state.clear_selection();
state.disarm_transform();
match state.set_history_json(&part_doc.to_string()) {
Ok(_) => {
self.stash = Some(Stash {
assembly_doc,
part_name: info.part_name.clone(),
component_id: component_id.to_string(),
part_baseline: state.history_request_json(),
});
self.confirm_cancel = false;
state.zoom_to_fit();
}
Err(error) => {
state.push_notice(format!(
"Edit in place: part '{}' failed to load: {error}",
info.part_name
));
}
}
}
pub fn finish(&mut self, state: &mut EngineState, store: &dyn ModelStore) {
let Some(stash) = self.stash.take() else {
return;
};
self.confirm_cancel = false;
let edited = state.history_request_json();
if edited == stash.part_baseline {
self.restore(state, &stash.assembly_doc);
return;
}
let mut assembly: serde_json::Value = match serde_json::from_str(&stash.assembly_doc) {
Ok(value) => value,
Err(_) => {
state.push_notice("Edit in place: stashed assembly unreadable — restoring as-is".to_string());
self.restore(state, &stash.assembly_doc);
return;
}
};
let target = source_target(&assembly, &stash.part_name);
if refresh_library_entry(&mut assembly, &stash.part_name, &edited).is_err() {
state.push_notice(format!(
"Edit in place: part '{}' vanished from the library — assembly restored, edits dropped",
stash.part_name
));
self.restore(state, &stash.assembly_doc);
return;
}
write_through(state, store, &stash.part_name, target.as_ref(), &edited);
self.restore(state, &assembly.to_string());
}
pub fn cancel(&mut self, state: &mut EngineState) {
if self.dirty(state) {
self.confirm_cancel = true;
} else {
self.discard(state);
}
}
fn discard(&mut self, state: &mut EngineState) {
self.confirm_cancel = false;
if let Some(stash) = self.stash.take() {
self.restore(state, &stash.assembly_doc);
}
}
fn restore(&mut self, state: &mut EngineState, doc: &str) {
if let Err(error) = state.set_history_json(doc) {
state.push_notice(format!("Edit in place: assembly restore failed: {error}"));
}
state.zoom_to_fit();
}
pub fn card(&mut self, ui: &mut egui::Ui, state: &mut EngineState, store: &dyn ModelStore) {
if !self.active() {
return;
}
#[cfg(target_arch = "wasm32")]
self.hits.clear();
let label = self.label().unwrap_or_default();
let dirty = self.dirty(state);
let mut do_finish = false;
let mut do_cancel = false;
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.label(
egui::RichText::new(if dirty {
format!("{label} \u{25CF}")
} else {
label
})
.strong(),
);
ui.label(
egui::RichText::new("Finish saves the part and updates every instance")
.weak()
.small(),
);
ui.horizontal(|ui| {
let finish = ui.button("Finish").on_hover_text(
"Save the part and refresh it — every instance updates, the assembly re-solves",
);
self.hit("assembly-edit:finish", &finish);
if finish.clicked() {
do_finish = true;
}
let cancel = ui
.button("Cancel")
.on_hover_text("Discard the part edits and restore the assembly");
self.hit("assembly-edit:cancel", &cancel);
if cancel.clicked() {
do_cancel = true;
}
});
});
if do_finish {
self.finish(state, store);
} else if do_cancel {
self.cancel(state);
}
}
pub fn show_confirm(&mut self, ctx: &egui::Context, state: &mut EngineState) {
if !self.confirm_cancel || !self.active() {
return;
}
let label = self.label().unwrap_or_default();
let mut discard = false;
let mut keep = false;
let modal = egui::Modal::new(egui::Id::new("brep-assembly-edit-cancel")).show(ctx, |ui| {
ui.set_width(340.0);
ui.heading("Discard part edits?");
ui.add_space(4.0);
ui.label(format!("{label} has unsaved part edits."));
ui.add_space(6.0);
ui.horizontal(|ui| {
let d = ui.button("Discard");
self.hit("assembly-edit:confirm-discard", &d);
if d.clicked() {
discard = true;
}
let k = ui.button("Keep editing");
self.hit("assembly-edit:keep", &k);
if k.clicked() {
keep = true;
}
});
});
if discard {
self.discard(state);
} else if keep || modal.should_close() {
self.confirm_cancel = false;
}
}
#[cfg(target_arch = "wasm32")]
pub fn state_json(&self, state: &EngineState) -> String {
serde_json::json!({
"active": self.active(),
"label": self.label(),
"dirty": self.dirty(state),
"confirmCancel": self.confirm_cancel,
})
.to_string()
}
#[cfg(target_arch = "wasm32")]
pub fn hits_json(&self) -> String {
let map: serde_json::Map<String, serde_json::Value> = self
.hits
.iter()
.map(|(k, r)| {
(
k.clone(),
serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]),
)
})
.collect();
serde_json::Value::Object(map).to_string()
}
#[cfg(target_arch = "wasm32")]
fn hit(&mut self, key: &str, resp: &egui::Response) {
self.hits.insert(key.to_string(), resp.rect);
}
#[cfg(not(target_arch = "wasm32"))]
#[inline]
fn hit(&mut self, _key: &str, _resp: &egui::Response) {}
}
pub fn refresh_library_entry(
assembly: &mut serde_json::Value,
part_name: &str,
document_json: &str,
) -> Result<(), String> {
let document: serde_json::Value = serde_json::from_str(document_json)
.map_err(|error| format!("part document unreadable: {error}"))?;
if !document.is_object() {
return Err("part document unreadable: not a JSON object".to_string());
}
match assembly["partsLibrary"].get_mut(part_name) {
Some(entry) if entry.is_object() => {
entry["document"] = document;
entry["sourceSignature"] =
serde_json::Value::String(document_signature(document_json));
entry["snapshot"] = serde_json::Value::String(String::new());
Ok(())
}
_ => Err(format!("no parts-library entry '{part_name}'")),
}
}
fn source_target(assembly: &serde_json::Value, part_name: &str) -> Option<(String, String)> {
let entry = assembly.get("partsLibrary")?.get(part_name)?;
let key = entry.get("sourceKey")?.as_str()?;
if key.is_empty() {
return None;
}
let signature = entry
.get("sourceSignature")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
Some((key.to_string(), signature.to_string()))
}
pub(crate) fn write_through(
state: &mut EngineState,
store: &dyn ModelStore,
part_name: &str,
target: Option<&(String, String)>,
edited: &str,
) {
let Some((source_key, inserted_signature)) = target else {
return;
};
let Some(current) = store.read(source_key) else {
state.push_notice(format!(
"Edit in place: source '{source_key}' is no longer in storage — \
'{part_name}' updated in the assembly only, the file was NOT written"
));
return;
};
if document_signature(¤t) != *inserted_signature {
state.push_notice(format!(
"Edit in place: source '{source_key}' changed since the assembly last synced '{part_name}' — \
NOT overwritten; the assembly holds your edit (Update Components takes the file's version instead)"
));
return;
}
match store.write(source_key, edited) {
Ok(()) => state.push_notice(format!("Saved '{part_name}' to '{source_key}'")),
Err(error) => state.push_notice(format!(
"Edit in place: could not save '{source_key}': {error} — \
'{part_name}' updated in the assembly only"
)),
}
}
pub use brep_render::engine_state::document_signature;
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
use crate::panels::component_actions::tests::{assembly_engine, two_instance_assembly_json};
use crate::panels::update_components::tests::{assembly_with_store, part_document};
use crate::panels::update_components::UpdateComponents;
use crate::store::MemModelStore;
struct FailingStore(MemModelStore);
impl ModelStore for FailingStore {
fn read(&self, name: &str) -> Option<String> {
self.0.read(name)
}
fn write(&self, _name: &str, _contents: &str) -> Result<(), String> {
Err("storage quota exceeded".into())
}
}
fn stored_size_x(store: &dyn ModelStore, key: &str) -> f64 {
let contents = store.read(key).unwrap_or_else(|| panic!("no '{key}' stored"));
let document: serde_json::Value = serde_json::from_str(&contents).expect("stored json");
document["features"][0]["inputParams"]["sizeX"]
.as_f64()
.expect("sizeX")
}
fn edit_width(edit: &mut AssemblyEdit, state: &mut EngineState, component_id: &str, size_x: f64) {
edit.enter(state, component_id);
assert!(edit.active(), "edit-in-place entered");
let mut params: serde_json::Value =
serde_json::from_str(&state.feature_params_json(0)).unwrap();
params["sizeX"] = serde_json::json!(size_x);
state
.update_feature_params("Part", ¶ms.to_string())
.unwrap();
assert!(edit.dirty(state), "the part edit marks the session dirty");
}
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 enter_swaps_to_the_part_document_and_finish_updates_every_instance() {
let mut engine = assembly_engine();
let pre_entry = engine.history_request_json();
let mut edit = AssemblyEdit::new();
assert!(!edit.active());
edit.enter(&mut engine, "ACOMP2");
assert!(edit.active(), "editing session active");
assert_eq!(edit.label().as_deref(), Some("Editing widget (ACOMP2)"));
assert_eq!(engine.history_len(), 1);
assert_eq!(engine.feature_id_at(0).as_deref(), Some("Part"));
assert!(engine.scene.solid("Part").is_some(), "part solid, no namespace");
assert!(!edit.dirty(&engine), "freshly-entered edit is clean");
let mut params: serde_json::Value =
serde_json::from_str(&engine.feature_params_json(0)).unwrap();
params["sizeX"] = serde_json::json!(14.0);
engine.update_feature_params("Part", ¶ms.to_string()).unwrap();
assert!(edit.dirty(&engine), "a part edit marks the session dirty");
edit.finish(&mut engine, &MemModelStore::new());
assert!(!edit.active());
assert_eq!(engine.history_len(), 2, "assembly features restored");
assert!((size_x_of(&engine, "ACOMP1:Part") - 14.0).abs() < 1e-6);
assert!((size_x_of(&engine, "ACOMP2:Part") - 14.0).abs() < 1e-6);
let doc: serde_json::Value =
serde_json::from_str(&engine.history_request_json()).unwrap();
let entry = &doc["partsLibrary"]["widget"];
assert_ne!(entry["sourceSignature"], "sig-1", "signature refreshed");
assert_eq!(
entry["document"]["features"][0]["inputParams"]["sizeX"], 14.0,
"embedded document carries the edit"
);
let snapshot = entry["snapshot"].as_str().unwrap_or_default();
assert!(
!snapshot.is_empty(),
"the main-side sync captures the healed snapshot into the document"
);
assert!(
brep_render::brep_kernel::restore_solids(snapshot).is_ok(),
"captured snapshot restores cleanly"
);
let _ = pre_entry;
}
#[test]
fn cancel_restores_the_assembly_unchanged_with_a_dirty_guard() {
let mut engine = assembly_engine();
let pre_entry = engine.history_request_json();
let mut edit = AssemblyEdit::new();
edit.enter(&mut engine, "ACOMP1");
let mut params: serde_json::Value =
serde_json::from_str(&engine.feature_params_json(0)).unwrap();
params["sizeX"] = serde_json::json!(99.0);
engine.update_feature_params("Part", ¶ms.to_string()).unwrap();
assert!(edit.dirty(&engine));
edit.cancel(&mut engine);
assert!(edit.confirm_cancel, "dirty cancel raises the confirm modal");
assert!(edit.active(), "still editing until the user decides");
assert_eq!(engine.history_len(), 1, "still the part document");
edit.discard(&mut engine);
assert!(!edit.active());
assert_eq!(engine.history_request_json(), pre_entry, "assembly untouched");
assert!((size_x_of(&engine, "ACOMP1:Part") - 10.0).abs() < 1e-6);
}
#[test]
fn clean_cancel_and_clean_finish_skip_the_refresh() {
let mut engine = assembly_engine();
let pre_entry = engine.history_request_json();
let mut edit = AssemblyEdit::new();
edit.enter(&mut engine, "ACOMP2");
edit.cancel(&mut engine);
assert!(!edit.confirm_cancel && !edit.active());
assert_eq!(engine.history_request_json(), pre_entry);
let store = MemModelStore::new();
store.put("widget", &two_instance_assembly_json());
let before = store.read("widget");
edit.enter(&mut engine, "ACOMP2");
edit.finish(&mut engine, &store);
assert_eq!(store.read("widget"), before, "an unchanged part is not saved");
assert!(!edit.active());
let doc: serde_json::Value =
serde_json::from_str(&engine.history_request_json()).unwrap();
assert_eq!(doc["partsLibrary"]["widget"]["sourceSignature"], "sig-1");
}
#[test]
fn nested_activation_refuses_with_a_toast() {
let mut engine = assembly_engine();
let mut edit = AssemblyEdit::new();
edit.enter(&mut engine, "ACOMP2");
let _ = engine.take_notices();
edit.enter(&mut engine, "ACOMP1");
assert_eq!(
edit.label().as_deref(),
Some("Editing widget (ACOMP2)"),
"the first session survives"
);
let notices = engine.take_notices();
assert!(
notices.iter().any(|n| n.contains("Already editing")),
"{notices:?}"
);
}
#[test]
fn missing_library_entry_toasts_and_stays_in_the_assembly() {
let mut doc: serde_json::Value =
serde_json::from_str(&two_instance_assembly_json()).unwrap();
doc.as_object_mut().unwrap().remove("partsLibrary");
let mut engine = EngineState::new();
let _ = engine.set_history_json(&doc.to_string());
let mut edit = AssemblyEdit::new();
edit.enter(&mut engine, "ACOMP2");
assert!(!edit.active());
assert_eq!(engine.history_len(), 2, "assembly document untouched");
let notices = engine.take_notices();
assert!(
notices.iter().any(|n| n.contains("no parts-library document")),
"{notices:?}"
);
}
#[test]
fn refresh_library_entry_rewrites_or_errors() {
let mut assembly: serde_json::Value =
serde_json::from_str(&two_instance_assembly_json()).unwrap();
let new_doc = r#"{"features":[{"type":"P.CU","inputParams":{"id":"Part","sizeX":14}}]}"#;
refresh_library_entry(&mut assembly, "widget", new_doc).expect("refresh ok");
let entry = &assembly["partsLibrary"]["widget"];
assert_eq!(entry["document"]["features"][0]["inputParams"]["sizeX"], 14);
assert_eq!(entry["sourceSignature"], document_signature(new_doc));
assert_eq!(entry["snapshot"], "", "blanked snapshot = the heal trigger");
let before = assembly.clone();
let err = refresh_library_entry(&mut assembly, "widget", "not json").unwrap_err();
assert!(err.contains("unreadable"), "{err}");
let err = refresh_library_entry(&mut assembly, "gone", new_doc).unwrap_err();
assert!(err.contains("no parts-library entry"), "{err}");
assert_eq!(assembly, before, "failed refreshes leave the document alone");
}
#[test]
fn finish_writes_the_edit_back_to_the_source_and_clears_the_outdated_badge() {
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, "a fresh insert starts clean");
let mut edit = AssemblyEdit::new();
edit_width(&mut edit, &mut state, "ACOMP2", 14.0);
assert!((stored_size_x(&store, "widget") - 10.0).abs() < 1e-9);
edit.finish(&mut state, &store);
assert!(!edit.active());
assert!((stored_size_x(&store, "widget") - 14.0).abs() < 1e-9);
let stored = store.read("widget").expect("source document");
let document: serde_json::Value =
serde_json::from_str(&state.history_request_json()).unwrap();
assert_eq!(
document["partsLibrary"]["widget"]["sourceSignature"],
document_signature(&stored)
);
let mut after = UpdateComponents::new();
after.ensure_current(&mut state, &store, 0);
assert_eq!(after.outdated_count(), 0, "a written-through edit is not outdated");
assert!(!after.is_outdated("widget"));
assert!(after.missing().is_empty());
assert!((size_x_of(&state, "ACOMP1:Part") - 14.0).abs() < 1e-6);
assert!((size_x_of(&state, "ACOMP2:Part") - 14.0).abs() < 1e-6);
let notices = state.take_notices();
assert!(
notices.iter().any(|n| n.contains("Saved") && n.contains("widget")),
"the success notice names the part and the file: {notices:?}"
);
}
#[test]
fn a_failed_write_keeps_the_edit_and_reports_the_file_was_not_saved() {
let (mut state, store) = assembly_with_store();
let store = FailingStore(store);
let mut edit = AssemblyEdit::new();
edit_width(&mut edit, &mut state, "ACOMP1", 14.0);
edit.finish(&mut state, &store);
assert!(!edit.active());
assert!((size_x_of(&state, "ACOMP1:Part") - 14.0).abs() < 1e-6);
assert!((size_x_of(&state, "ACOMP2:Part") - 14.0).abs() < 1e-6);
assert!((stored_size_x(&store, "widget") - 10.0).abs() < 1e-9);
let notices = state.take_notices();
assert!(
notices
.iter()
.any(|n| n.contains("could not save 'widget'") && n.contains("quota")),
"{notices:?}"
);
}
#[test]
fn a_vanished_source_is_noted_and_never_recreated() {
let (mut state, store) = assembly_with_store();
store.remove("widget").unwrap();
let mut edit = AssemblyEdit::new();
edit_width(&mut edit, &mut state, "ACOMP2", 14.0);
edit.finish(&mut state, &store);
assert_eq!(store.read("widget"), None, "a removed source is not resurrected");
assert!((size_x_of(&state, "ACOMP2:Part") - 14.0).abs() < 1e-6);
let notices = state.take_notices();
assert!(
notices
.iter()
.any(|n| n.contains("no longer in storage") && n.contains("widget")),
"{notices:?}"
);
let mut updates = UpdateComponents::new();
updates.ensure_current(&mut state, &store, 0);
assert_eq!(updates.outdated_count(), 0);
assert_eq!(updates.missing(), ["widget".to_string()]);
}
#[test]
fn a_source_changed_since_insert_is_not_overwritten() {
let (mut state, store) = assembly_with_store();
let elsewhere = part_document(7.0);
store.put("widget", &elsewhere);
let mut edit = AssemblyEdit::new();
edit_width(&mut edit, &mut state, "ACOMP1", 14.0);
edit.finish(&mut state, &store);
assert_eq!(store.read("widget").as_deref(), Some(elsewhere.as_str()));
assert!((size_x_of(&state, "ACOMP1:Part") - 14.0).abs() < 1e-6);
let notices = state.take_notices();
assert!(
notices
.iter()
.any(|n| n.contains("changed since") && n.contains("widget")),
"{notices:?}"
);
let mut updates = UpdateComponents::new();
updates.ensure_current(&mut state, &store, 0);
assert!(
updates.is_outdated("widget"),
"the file really does differ — the badge stays lit"
);
}
#[test]
fn embedded_only_parts_never_touch_the_store() {
let mut document: serde_json::Value =
serde_json::from_str(&two_instance_assembly_json()).unwrap();
document["partsLibrary"]["widget"]["sourceKey"] = serde_json::json!("");
let mut engine = EngineState::new();
engine.set_history_json(&document.to_string()).expect("assembly loads");
let store = MemModelStore::new();
store.put("widget", &part_document(10.0));
let before = store.read("widget");
let reads = store.reads();
let mut edit = AssemblyEdit::new();
edit_width(&mut edit, &mut engine, "ACOMP2", 14.0);
edit.finish(&mut engine, &store);
assert_eq!(store.list(), vec!["widget".to_string()], "no document invented");
assert_eq!(store.read("widget"), before, "the source lane never wrote");
assert_eq!(store.reads(), reads + 1, "…and only this test's own assert read it");
assert!((size_x_of(&engine, "ACOMP1:Part") - 14.0).abs() < 1e-6);
assert!((size_x_of(&engine, "ACOMP2:Part") - 14.0).abs() < 1e-6);
let notices = engine.take_notices();
assert!(
notices
.iter()
.all(|n| !n.contains("Saved") && !n.contains("storage")),
"an embedded-only part says nothing about a source: {notices:?}"
);
}
}