1use crate::column_tree::{self, CellEdit, ColumnLayout, ColumnTreeSpec, RowAction, RowNode};
61use crate::panels::parts_library;
62use crate::panels::component_actions::{
63 run_component_action, ComponentAction, ComponentActionRequest,
64};
65use crate::panels::assembly_components::{self, ChainNode, ComponentRow};
66use crate::panels::update_components::UpdateComponents;
67use crate::panels::bom_columns::{
68 self, ParsedColumns, Scope, FLAGS_KEY, ITEM_KEY, QUANTITY_KEY, VISIBLE_KEY,
69};
70use crate::store::ModelStore;
71use brep_render::engine_state::EngineState;
72use eframe::egui;
73use serde_json::Value;
74use std::collections::{BTreeMap, HashMap, HashSet};
75
76const EDIT_FEATURE: &str = "edit-feature";
82
83#[derive(Default)]
85pub struct BomOutcome {
86 pub focus: Option<String>,
90 pub component: Option<ComponentActionRequest>,
95}
96
97#[derive(Clone)]
99struct Occurrence {
100 id: String,
102 part_name: String,
103 attributes: Value,
105 selected: bool,
106 fixed: bool,
108 outdated: bool,
110 status: Option<String>,
112 visible: bool,
114 solids: Vec<String>,
116 children: Vec<ChainNode>,
119}
120
121pub struct BomPanel {
125 hits: HashMap<String, egui::Rect>,
126 layout: ColumnLayout,
129 layout_source: String,
131 parsed: ParsedColumns,
133 packed: bool,
136 collapsed: HashSet<String>,
138}
139
140impl Default for BomPanel {
141 fn default() -> Self {
142 Self::new()
143 }
144}
145
146impl BomPanel {
147 pub fn new() -> Self {
148 Self {
149 hits: HashMap::new(),
150 layout: ColumnLayout::default(),
151 layout_source: String::new(),
152 parsed: ParsedColumns::default(),
153 packed: true,
156 collapsed: HashSet::new(),
157 }
158 }
159
160 pub fn show(
164 &mut self,
165 ui: &mut egui::Ui,
166 state: &mut EngineState,
167 store: &dyn ModelStore,
168 updates: &UpdateComponents,
169 ) -> BomOutcome {
170 self.hits.clear();
171 self.hits.insert("bom:panel:clip".into(), ui.clip_rect());
177 let mut outcome = BomOutcome::default();
178 state.ensure_assembly_synced();
179
180 self.sync_columns(state);
181 let component_rows = assembly_components::snapshot(state, updates);
182 let occurrences = occurrences_from(state, &component_rows);
183 let groups = group(&occurrences, self.packed, &self.packing_fields());
184
185 ui.horizontal(|ui| {
187 let packed = ui
188 .selectable_label(self.packed, "Packed")
189 .on_hover_text("One row per part, rolled up where every occurrence field matches");
190 self.hits.insert("bom:packed".into(), packed.rect);
191 if packed.clicked() {
192 self.packed = true;
193 }
194 let unpacked = ui
195 .selectable_label(!self.packed, "Unpacked")
196 .on_hover_text("One row per individual instance");
197 self.hits.insert("bom:unpacked".into(), unpacked.rect);
198 if unpacked.clicked() {
199 self.packed = false;
200 }
201 let expand = ui
202 .button("Expand all")
203 .on_hover_text("Expand every row with nested components");
204 self.hits.insert("bom:expand-all".into(), expand.rect);
205 if expand.clicked() {
206 self.collapsed.clear();
207 }
208 let collapse = ui
209 .button("Collapse all")
210 .on_hover_text("Collapse every row with nested components");
211 self.hits.insert("bom:collapse-all".into(), collapse.rect);
212 if collapse.clicked() {
213 self.collapsed = collapsible_keys(&groups);
217 }
218 ui.label(
219 egui::RichText::new(format!("{} rows / {} occurrences", groups.len(), occurrences.len()))
220 .weak(),
221 );
222 });
223 ui.add_space(2.0);
224
225 let rows: Vec<RowNode> = groups
227 .iter()
228 .map(|group| self.row_for(state, group))
229 .collect();
230 let specs = bom_columns::column_specs(&self.parsed);
231 let mut root_cells: HashMap<String, Value> = HashMap::new();
232 root_cells.insert(
233 QUANTITY_KEY.to_string(),
234 Value::from(occurrences.len() as u64),
235 );
236 let spec = ColumnTreeSpec {
237 id: "bom",
238 columns: &specs,
239 root_label: Some("Assembly"),
240 root_cells: Some(&root_cells),
241 empty_hint: Some("(no components — insert one via Add new feature)"),
242 hits_prefix: "",
243 };
244 let out = column_tree::column_tree(
245 ui,
246 &spec,
247 &mut self.layout,
248 &rows,
249 Some(&mut self.hits),
250 );
251
252 if out.layout_changed {
254 self.persist_layout(state, store);
255 }
256 if let Some(id) = &out.toggled {
257 if !self.collapsed.remove(id) {
258 self.collapsed.insert(id.clone());
259 }
260 }
261 if let Some(id) = &out.clicked {
262 if let Some(group) = groups.iter().find(|group| group.key == *id) {
263 state.select_components(&group.ids);
264 }
265 }
266 let mut acted = false;
271 for click in &out.actions {
272 let Some(group) = groups.iter().find(|group| group.key == click.row_id) else {
273 continue;
274 };
275 let Some(first) = group.ids.first() else {
276 continue;
277 };
278 acted = true;
279 if click.action == EDIT_FEATURE {
280 if let Some(index) = state.history.index_of(first) {
281 state.roll_to(index);
282 }
283 outcome.focus = Some(first.clone());
284 } else if let Some(action) = ComponentAction::from_id(&click.action) {
285 outcome.component = run_component_action(state, action, first);
286 }
287 }
288 if !acted {
294 if let Some(edit) = out.edits.first() {
295 if edit.column == VISIBLE_KEY {
296 let visible = edit.value.as_bool().unwrap_or(true);
299 if let Some(group) = groups.iter().find(|g| g.key == edit.row_id) {
300 for solid in &group.solids {
301 state.set_visible(solid, visible);
302 }
303 }
304 } else {
305 self.apply_edit(state, store, &groups, edit);
306 }
307 }
308 }
309
310 assembly_components::publish_tree(&component_rows);
316
317 #[cfg(target_arch = "wasm32")]
318 {
319 let listing: Vec<Value> = groups
320 .iter()
321 .map(|group| {
322 serde_json::json!({
323 "key": group.key,
324 "partName": group.part_name,
325 "ids": group.ids,
326 "quantity": group.ids.len(),
327 })
328 })
329 .collect();
330 publish("__brepBom", &Value::Array(listing).to_string());
331 publish("__brepBomHit", &self.hits_json());
332 }
333
334 outcome
335 }
336
337 fn sync_columns(&mut self, state: &EngineState) {
341 let text = bom_columns::effective_text(&state.settings.bom_columns);
342 if text == self.layout_source {
343 return;
344 }
345 self.parsed = bom_columns::parse(&text);
346 self.layout = bom_columns::layout_from(&self.parsed, &self.layout);
347 self.layout_source = text;
348 }
349
350 fn persist_layout(&mut self, state: &mut EngineState, store: &dyn ModelStore) {
353 let columns = bom_columns::columns_from_layout(&self.parsed, &self.layout);
354 let text = bom_columns::serialize(
355 &columns,
356 &self.parsed.preserved,
357 bom_columns::frozen_from_layout(&self.layout),
360 );
361 let mut settings: Value =
362 serde_json::from_str(&state.settings_json()).unwrap_or(Value::Null);
363 let Some(object) = settings.as_object_mut() else {
364 return;
365 };
366 object.insert("bomColumns".into(), Value::String(text.clone()));
367 let json = settings.to_string();
368 let _ = state.apply_settings_json(&json);
369 let _ = store.write(crate::store::SETTINGS_KEY, &json);
370 self.parsed = bom_columns::parse(&text);
373 self.layout_source = text;
374 }
375
376 fn packing_fields(&self) -> Vec<String> {
385 self.parsed
386 .columns
387 .iter()
388 .filter(|column| column.scope == Scope::Occurrence)
389 .filter(|column| column.key() != QUANTITY_KEY)
390 .filter(|column| !self.layout.hidden.contains(&column.key()))
391 .map(|column| column.field.clone())
392 .collect()
393 }
394
395 fn row_for(&self, state: &EngineState, group: &Group) -> RowNode {
398 let mut cells: HashMap<String, Value> = HashMap::new();
399 let label = if self.packed {
400 group.part_name.clone()
401 } else {
402 format!("{} ({})", group.part_name, group.key)
403 };
404 cells.insert(ITEM_KEY.to_string(), Value::String(label));
405 cells.insert(VISIBLE_KEY.to_string(), Value::Bool(group.visible));
406 cells.insert(FLAGS_KEY.to_string(), Value::Array(badges(group)));
407 cells.insert(
409 QUANTITY_KEY.to_string(),
410 Value::from(group.ids.len() as u64),
411 );
412
413 let part_attributes = state.part_attributes(&group.part_name);
414 for column in &self.parsed.columns {
415 let key = column.key();
416 if key == QUANTITY_KEY {
417 continue;
418 }
419 let source = match column.scope {
420 Scope::Part => &part_attributes,
421 Scope::Occurrence => &group.attributes,
422 };
423 if let Some(value) = source.get(&column.field) {
424 cells.insert(key, value.clone());
425 }
426 }
427
428 RowNode {
429 id: group.key.clone(),
430 cells,
431 editable: true,
432 selected: group.selected,
433 expanded: !self.collapsed.contains(&group.key),
434 actions: actions_for(state, group),
435 children: chain_rows(&group.key, &group.children),
440 }
441 }
442
443 fn apply_edit(
448 &self,
449 state: &mut EngineState,
450 store: &dyn ModelStore,
451 groups: &[Group],
452 edit: &CellEdit,
453 ) {
454 let Some(group) = groups.iter().find(|group| group.key == edit.row_id) else {
455 return; };
457 let Some(column) = self
458 .parsed
459 .columns
460 .iter()
461 .find(|column| column.key() == edit.column)
462 else {
463 return;
464 };
465 match column.scope {
466 Scope::Occurrence => {
467 if let Err(error) =
470 state.set_occurrence_attribute(&group.ids, &column.field, edit.value.clone())
471 {
472 state.push_notice(format!("BOM: {error}"));
473 }
474 }
475 Scope::Part => {
476 let target = state.part_source(&group.part_name).and_then(|(key, sig)| {
480 (!key.is_empty()).then_some((key, sig))
481 });
482 if let Err(error) =
483 state.set_part_attribute(&group.part_name, &column.field, edit.value.clone())
484 {
485 state.push_notice(format!("BOM: {error}"));
486 return;
487 }
488 if let Some(document) = state.part_document_json(&group.part_name) {
492 parts_library::write_through(
493 state,
494 store,
495 &group.part_name,
496 target.as_ref(),
497 &document,
498 );
499 }
500 }
501 }
502 }
503
504 #[cfg(target_arch = "wasm32")]
506 pub fn hits_json(&self) -> String {
507 let map: serde_json::Map<String, Value> = self
508 .hits
509 .iter()
510 .map(|(key, rect)| {
511 (
512 key.clone(),
513 serde_json::json!([rect.min.x, rect.min.y, rect.width(), rect.height()]),
514 )
515 })
516 .collect();
517 Value::Object(map).to_string()
518 }
519}
520
521fn actions_for(state: &EngineState, group: &Group) -> Vec<RowAction> {
533 let Some(first) = group.ids.first() else {
534 return Vec::new();
535 };
536 let fixed = state
537 .component_info(first)
538 .map(|info| info.fixed)
539 .unwrap_or(false);
540 let rolled_up = group.ids.len() > 1;
541 let unpack = |verb: &str| {
542 format!(
543 "{} placements on this row — switch to Unpacked to {verb} one",
544 group.ids.len()
545 )
546 };
547 let embedded = !state
549 .part_source(&group.part_name)
550 .is_some_and(|(key, _)| !key.is_empty());
551
552 let mut actions = vec![RowAction::new(EDIT_FEATURE, "\u{270E} Edit feature")
553 .tooltip("Roll to this component's feature and open it in the history")];
554 for action in ComponentAction::ALL {
555 let entry = RowAction::new(action.id(), action.label(fixed)).tooltip(action.tooltip());
556 let entry = match action {
557 ComponentAction::Move if fixed => {
558 entry.disabled("This component is fixed — unfix it before moving it")
559 }
560 ComponentAction::Move if rolled_up => entry.disabled(unpack("move")),
561 ComponentAction::ToggleFixed if rolled_up => entry.disabled(unpack("fix or unfix")),
562 ComponentAction::Delete if rolled_up => entry.disabled(unpack("delete")),
563 ComponentAction::OpenPart if embedded => {
564 entry.disabled("This part is embedded in the assembly — it has no source document")
565 }
566 _ => entry,
567 };
568 actions.push(match action {
569 ComponentAction::Delete => entry.separator_above().destructive(),
571 _ => entry,
572 });
573 }
574 actions
575}
576
577fn collapsible_keys(groups: &[Group]) -> HashSet<String> {
581 fn owns_component(nodes: &[ChainNode]) -> bool {
585 nodes
586 .iter()
587 .any(|node| assembly_components::is_acomp_segment(&node.label))
588 }
589 fn walk(parent: &str, nodes: &[ChainNode], out: &mut HashSet<String>) {
590 for node in nodes
591 .iter()
592 .filter(|node| assembly_components::is_acomp_segment(&node.label))
593 {
594 let id = format!("{parent}:{}", node.label);
595 if owns_component(&node.children) {
596 out.insert(id.clone());
597 }
598 walk(&id, &node.children, out);
599 }
600 }
601 let mut out = HashSet::new();
602 for group in groups {
603 if owns_component(&group.children) {
604 out.insert(group.key.clone());
605 }
606 walk(&group.key, &group.children, &mut out);
607 }
608 out
609}
610
611fn badges(group: &Group) -> Vec<Value> {
615 let mut out = Vec::new();
616 if group.fixed {
617 out.push(serde_json::json!({
618 "glyph": assembly_components::FIXED_GLYPH,
619 "tooltip": "Grounded — unfix it before moving it",
620 }));
621 }
622 if group.outdated {
623 out.push(serde_json::json!({
624 "glyph": assembly_components::OUTDATED_GLYPH,
625 "color": color_hex(assembly_components::OUTDATED_AMBER),
626 "tooltip": "The source part has changed since this was inserted",
627 }));
628 }
629 if let Some(status) = &group.status {
630 out.push(serde_json::json!({
631 "glyph": "\u{25CF}",
632 "color": brep_render::assembly_status::status_color_hex(status),
633 "tooltip": format!("Constraint status: {status}"),
634 }));
635 }
636 out
637}
638
639fn color_hex(color: egui::Color32) -> String {
643 format!("#{:02x}{:02x}{:02x}", color.r(), color.g(), color.b())
644}
645
646fn chain_rows(parent: &str, nodes: &[ChainNode]) -> Vec<RowNode> {
656 nodes
657 .iter()
658 .filter(|node| assembly_components::is_acomp_segment(&node.label))
659 .map(|node| {
660 let id = format!("{parent}:{}", node.label);
661 let mut cells = HashMap::new();
662 cells.insert(ITEM_KEY.to_string(), Value::String(node.label.clone()));
663 RowNode {
664 children: chain_rows(&id, &node.children),
665 id,
666 cells,
667 editable: false,
668 selected: false,
669 expanded: false,
670 actions: Vec::new(),
671 }
672 })
673 .collect()
674}
675
676fn worse_status(current: Option<&str>, candidate: Option<&str>) -> bool {
680 let Some(candidate) = candidate else {
681 return false;
682 };
683 match current {
684 None => true,
685 Some(current) => {
686 brep_render::assembly_status::status_severity(candidate)
687 > brep_render::assembly_status::status_severity(current)
688 }
689 }
690}
691
692struct Group {
695 key: String,
698 part_name: String,
699 ids: Vec<String>,
701 attributes: Value,
704 selected: bool,
705 fixed: bool,
707 outdated: bool,
708 status: Option<String>,
710 visible: bool,
712 solids: Vec<String>,
714 children: Vec<ChainNode>,
715}
716
717fn occurrences_from(state: &mut EngineState, rows: &[ComponentRow]) -> Vec<Occurrence> {
725 rows.iter()
726 .map(|row| Occurrence {
727 attributes: state.occurrence_attributes(&row.id),
728 selected: row.selected,
729 fixed: row.fixed,
730 outdated: row.outdated,
731 status: row.rollup_status.clone(),
732 visible: row.visible,
733 solids: row.solids.clone(),
734 children: row.children.clone(),
735 part_name: row.part_name.clone(),
736 id: row.id.clone(),
737 })
738 .collect()
739}
740
741fn group(occurrences: &[Occurrence], packed: bool, fields: &[String]) -> Vec<Group> {
751 if !packed {
752 return occurrences
753 .iter()
754 .map(|occurrence| Group {
755 key: occurrence.id.clone(),
756 part_name: occurrence.part_name.clone(),
757 ids: vec![occurrence.id.clone()],
758 attributes: occurrence.attributes.clone(),
759 selected: occurrence.selected,
760 fixed: occurrence.fixed,
761 outdated: occurrence.outdated,
762 status: occurrence.status.clone(),
763 visible: occurrence.visible,
764 solids: occurrence.solids.clone(),
765 children: occurrence.children.clone(),
766 })
767 .collect();
768 }
769 let mut order: Vec<String> = Vec::new();
770 let mut groups: HashMap<String, Group> = HashMap::new();
771 for occurrence in occurrences {
772 let key = format!(
773 "{}\u{1}{}",
774 occurrence.part_name,
775 canonical_over(&occurrence.attributes, fields)
776 );
777 match groups.get_mut(&key) {
778 Some(group) => {
779 group.ids.push(occurrence.id.clone());
780 group.selected |= occurrence.selected;
781 group.fixed &= occurrence.fixed;
786 group.visible &= occurrence.visible;
787 group.outdated |= occurrence.outdated;
788 group.solids.extend(occurrence.solids.iter().cloned());
789 if worse_status(group.status.as_deref(), occurrence.status.as_deref()) {
790 group.status = occurrence.status.clone();
791 }
792 for child in &occurrence.children {
793 if !group.children.iter().any(|kept| kept == child) {
794 group.children.push(child.clone());
795 }
796 }
797 }
798 None => {
799 order.push(key.clone());
800 groups.insert(
801 key,
802 Group {
803 key: String::new(), part_name: occurrence.part_name.clone(),
805 ids: vec![occurrence.id.clone()],
806 attributes: occurrence.attributes.clone(),
807 selected: occurrence.selected,
808 fixed: occurrence.fixed,
809 outdated: occurrence.outdated,
810 status: occurrence.status.clone(),
811 visible: occurrence.visible,
812 solids: occurrence.solids.clone(),
813 children: occurrence.children.clone(),
814 },
815 );
816 }
817 }
818 }
819 order
820 .into_iter()
821 .filter_map(|key| groups.remove(&key))
822 .map(|mut group| {
823 group.key = format!(
828 "pack:{}",
829 group.ids.first().cloned().unwrap_or_default()
830 );
831 group
832 })
833 .collect()
834}
835
836fn canonical_over(attributes: &Value, fields: &[String]) -> String {
842 fields
843 .iter()
844 .map(|field| {
845 let value = attributes
846 .get(field)
847 .map(Value::to_string)
848 .unwrap_or_default();
849 format!("{field}={value}")
850 })
851 .collect::<Vec<_>>()
852 .join("\u{2}")
853}
854
855#[cfg(target_arch = "wasm32")]
857fn publish(name: &str, json: &str) {
858 if let Some(win) = web_sys::window() {
859 let _ = js_sys::Reflect::set(
860 &win,
861 &wasm_bindgen::JsValue::from_str(name),
862 &wasm_bindgen::JsValue::from_str(json),
863 );
864 }
865}
866
867#[cfg(all(test, not(target_arch = "wasm32")))]
871mod tests {
872 use super::*;
873
874 fn by(fields: &[&str]) -> Vec<String> {
877 fields.iter().map(|field| field.to_string()).collect()
878 }
879
880 fn snapshot(state: &mut EngineState) -> Vec<Occurrence> {
884 let rows = assembly_components::snapshot(state, &UpdateComponents::new());
885 occurrences_from(state, &rows)
886 }
887 use crate::panels::update_components::tests::part_document;
888 use crate::store::MemModelStore;
889 use brep_render::engine_state::ComponentInsert;
890
891 fn assembly() -> EngineState {
894 brep_render::brep_kernel::clear_history_cache();
895 let mut state = EngineState::new();
896 state
897 .insert_component(ComponentInsert::New {
898 name: "widget",
899 source_key: "",
900 source_signature: "sig-w",
901 document_json: &part_document(4.0),
902 })
903 .expect("widget inserts");
904 state
905 .insert_component(ComponentInsert::Existing { part_name: "widget" })
906 .expect("second widget");
907 state
908 .insert_component(ComponentInsert::New {
909 name: "gadget",
910 source_key: "",
911 source_signature: "sig-g",
912 document_json: &part_document(7.0),
913 })
914 .expect("gadget inserts");
915 state
916 }
917
918 fn panel_with(columns: &str) -> (BomPanel, EngineState) {
919 let mut state = assembly();
920 state
921 .apply_settings_json(&serde_json::json!({ "bomColumns": columns }).to_string())
922 .expect("columns apply");
923 let mut panel = BomPanel::new();
926 panel.sync_columns(&state);
927 (panel, state)
928 }
929
930 fn frame(
932 ctx: &egui::Context,
933 panel: &mut BomPanel,
934 state: &mut EngineState,
935 store: &dyn ModelStore,
936 events: Vec<egui::Event>,
937 ) -> BomOutcome {
938 let raw = egui::RawInput {
939 screen_rect: Some(egui::Rect::from_min_size(
940 egui::pos2(0.0, 0.0),
941 egui::vec2(900.0, 600.0),
942 )),
943 events,
944 ..Default::default()
945 };
946 let mut outcome = BomOutcome::default();
947 let _ = ctx.run_ui(raw, |ui| {
948 outcome = panel.show(ui, state, store, &UpdateComponents::new());
949 });
950 outcome
951 }
952
953 fn settle(
957 ctx: &egui::Context,
958 panel: &mut BomPanel,
959 state: &mut EngineState,
960 store: &dyn ModelStore,
961 ) {
962 frame(ctx, panel, state, store, vec![]);
963 frame(ctx, panel, state, store, vec![]);
964 }
965
966 fn right_click_at(
967 ctx: &egui::Context,
968 panel: &mut BomPanel,
969 state: &mut EngineState,
970 store: &dyn ModelStore,
971 pos: egui::Pos2,
972 ) -> BomOutcome {
973 press_release(ctx, panel, state, store, pos, egui::PointerButton::Secondary)
974 }
975
976 fn click_at(
977 ctx: &egui::Context,
978 panel: &mut BomPanel,
979 state: &mut EngineState,
980 store: &dyn ModelStore,
981 pos: egui::Pos2,
982 ) -> BomOutcome {
983 press_release(ctx, panel, state, store, pos, egui::PointerButton::Primary)
984 }
985
986 fn press_release(
987 ctx: &egui::Context,
988 panel: &mut BomPanel,
989 state: &mut EngineState,
990 store: &dyn ModelStore,
991 pos: egui::Pos2,
992 button: egui::PointerButton,
993 ) -> BomOutcome {
994 frame(
995 ctx,
996 panel,
997 state,
998 store,
999 vec![
1000 egui::Event::PointerMoved(pos),
1001 egui::Event::PointerButton {
1002 pos,
1003 button,
1004 pressed: true,
1005 modifiers: egui::Modifiers::default(),
1006 },
1007 ],
1008 );
1009 frame(
1010 ctx,
1011 panel,
1012 state,
1013 store,
1014 vec![egui::Event::PointerButton {
1015 pos,
1016 button,
1017 pressed: false,
1018 modifiers: egui::Modifiers::default(),
1019 }],
1020 )
1021 }
1022
1023 #[test]
1026 fn packed_rolls_up_identical_occurrences_and_unpacked_does_not() {
1027 let mut state = assembly();
1028 let occurrences = snapshot(&mut state);
1029 assert_eq!(occurrences.len(), 3);
1030
1031 let packed = group(&occurrences, true, &by(&[]));
1032 assert_eq!(packed.len(), 2, "widget x2 rolled up, gadget alone");
1033 assert_eq!(packed[0].part_name, "widget");
1034 assert_eq!(packed[0].ids, vec!["ACOMP1", "ACOMP2"]);
1035 assert_eq!(packed[1].ids, vec!["ACOMP3"]);
1036
1037 let unpacked = group(&occurrences, false, &by(&[]));
1038 assert_eq!(unpacked.len(), 3, "one row per placement");
1039 assert!(unpacked.iter().all(|group| group.ids.len() == 1));
1040 assert_eq!(unpacked[0].key, "ACOMP1", "the row IS the occurrence");
1041 }
1042
1043 #[test]
1049 fn a_differing_visible_field_splits_the_packed_row_and_a_hidden_one_does_not() {
1050 let mut state = assembly();
1051 state
1052 .set_occurrence_attribute(
1053 &["ACOMP2".to_string()],
1054 "Reference_Designator",
1055 Value::String("R2".into()),
1056 )
1057 .unwrap();
1058 let shown = by(&["Reference_Designator"]);
1060 let packed = group(&snapshot(&mut state), true, &shown);
1061 assert_eq!(packed.len(), 3, "the two widgets no longer match");
1062 assert_eq!(packed[0].ids, vec!["ACOMP1"]);
1063 assert_eq!(packed[1].ids, vec!["ACOMP2"]);
1064
1065 let packed = group(&snapshot(&mut state), true, &by(&["Notes"]));
1068 assert_eq!(packed.len(), 2, "a hidden difference does not split a row");
1069 assert_eq!(packed[0].ids, vec!["ACOMP1", "ACOMP2"]);
1070
1071 state
1074 .set_occurrence_attribute(
1075 &["ACOMP1".to_string()],
1076 "Reference_Designator",
1077 Value::String("R2".into()),
1078 )
1079 .unwrap();
1080 let packed = group(&snapshot(&mut state), true, &shown);
1081 assert_eq!(packed.len(), 2, "identical again");
1082 assert_eq!(packed[0].ids, vec!["ACOMP1", "ACOMP2"]);
1083 }
1084
1085 #[test]
1090 fn the_packing_key_ignores_attribute_write_order() {
1091 let mut state = assembly();
1092 let one = vec!["ACOMP1".to_string()];
1093 let two = vec!["ACOMP2".to_string()];
1094 state.set_occurrence_attribute(&one, "Notes", Value::String("a".into())).unwrap();
1095 state.set_occurrence_attribute(&one, "Find_Number", Value::String("1".into())).unwrap();
1096 state.set_occurrence_attribute(&two, "Find_Number", Value::String("1".into())).unwrap();
1098 state.set_occurrence_attribute(&two, "Notes", Value::String("a".into())).unwrap();
1099
1100 let packed = group(&snapshot(&mut state), true, &by(&["Notes", "Find_Number"]));
1101 assert_eq!(packed.len(), 2, "still widget x2 + gadget");
1102 assert_eq!(
1103 packed[0].ids,
1104 vec!["ACOMP1", "ACOMP2"],
1105 "same content, different write order, one row"
1106 );
1107 }
1108
1109 #[test]
1112 fn a_packed_edit_fans_out_and_undoes_in_one_step() {
1113 let ctx = egui::Context::default();
1114 let store = MemModelStore::new();
1115 let (mut panel, mut state) = panel_with("*occurrence.Notes\n");
1116 frame(&ctx, &mut panel, &mut state, &store, vec![]);
1117
1118 let cell = *panel
1119 .hits
1120 .get("cell:pack:ACOMP1:occurrence.Notes")
1121 .expect("the packed widget row's Notes cell");
1122 click_at(&ctx, &mut panel, &mut state, &store, cell.center());
1123 frame(&ctx, &mut panel, &mut state, &store, vec![egui::Event::Text("chk".into())]);
1124 frame(
1125 &ctx,
1126 &mut panel,
1127 &mut state,
1128 &store,
1129 vec![
1130 egui::Event::Key {
1131 key: egui::Key::Enter,
1132 physical_key: None,
1133 pressed: true,
1134 repeat: false,
1135 modifiers: egui::Modifiers::default(),
1136 },
1137 egui::Event::Key {
1138 key: egui::Key::Enter,
1139 physical_key: None,
1140 pressed: false,
1141 repeat: false,
1142 modifiers: egui::Modifiers::default(),
1143 },
1144 ],
1145 );
1146
1147 assert_eq!(state.occurrence_attributes("ACOMP1")["Notes"], "chk");
1148 assert_eq!(
1149 state.occurrence_attributes("ACOMP2")["Notes"], "chk",
1150 "the edit fanned out to the whole packed row"
1151 );
1152 assert_eq!(
1153 state.occurrence_attributes("ACOMP3"),
1154 serde_json::json!({}),
1155 "and only to that row"
1156 );
1157
1158 state.undo();
1159 assert_eq!(
1160 state.occurrence_attributes("ACOMP1"),
1161 serde_json::json!({}),
1162 "ONE undo takes the whole fan-out back"
1163 );
1164 assert_eq!(state.occurrence_attributes("ACOMP2"), serde_json::json!({}));
1165 }
1166
1167 #[test]
1171 fn a_part_edit_writes_the_part_document_and_writes_through_to_its_file() {
1172 let ctx = egui::Context::default();
1173 let store = MemModelStore::new();
1174 let document = part_document(4.0);
1175 store.write("widget", &document).unwrap();
1176
1177 brep_render::brep_kernel::clear_history_cache();
1178 let mut state = EngineState::new();
1179 state
1180 .insert_component(ComponentInsert::New {
1181 name: "widget",
1182 source_key: "widget",
1183 source_signature: &parts_library::document_signature(&document),
1184 document_json: &document,
1185 })
1186 .unwrap();
1187 state
1188 .insert_component(ComponentInsert::Existing { part_name: "widget" })
1189 .unwrap();
1190 state
1191 .apply_settings_json(r##"{"bomColumns": "*part.Material\n"}"##)
1192 .unwrap();
1193 let mut panel = BomPanel::new();
1194 frame(&ctx, &mut panel, &mut state, &store, vec![]);
1195
1196 let cell = *panel
1197 .hits
1198 .get("cell:pack:ACOMP1:part.Material")
1199 .expect("the Material cell");
1200 click_at(&ctx, &mut panel, &mut state, &store, cell.center());
1201 frame(&ctx, &mut panel, &mut state, &store, vec![egui::Event::Text("6061".into())]);
1202 frame(
1203 &ctx,
1204 &mut panel,
1205 &mut state,
1206 &store,
1207 vec![
1208 egui::Event::Key {
1209 key: egui::Key::Enter,
1210 physical_key: None,
1211 pressed: true,
1212 repeat: false,
1213 modifiers: egui::Modifiers::default(),
1214 },
1215 egui::Event::Key {
1216 key: egui::Key::Enter,
1217 physical_key: None,
1218 pressed: false,
1219 repeat: false,
1220 modifiers: egui::Modifiers::default(),
1221 },
1222 ],
1223 );
1224
1225 assert_eq!(state.part_attributes("widget")["Material"], "6061");
1226 let stored = store.read("widget").expect("the part file");
1230 let stored_document: Value = serde_json::from_str(&stored).unwrap();
1231 assert_eq!(stored_document["partAttributes"]["Material"], "6061");
1232 let (_, signature) = state.part_source("widget").unwrap();
1233 assert_eq!(
1234 signature,
1235 parts_library::document_signature(&stored),
1236 "the entry's signature and the file describe the same content"
1237 );
1238 }
1239
1240 #[test]
1243 fn quantity_is_derived_read_only_and_never_stored() {
1244 let (panel, mut state) = panel_with("*occurrence.Quantity\n");
1245 let groups = group(&snapshot(&mut state), true, &by(&[]));
1246 let row = panel.row_for(&state, &groups[0]);
1247 assert_eq!(row.cells[QUANTITY_KEY], serde_json::json!(2));
1248
1249 let mut unpacked = BomPanel::new();
1250 unpacked.packed = false;
1251 unpacked.parsed = panel.parsed.clone();
1252 let groups = group(&snapshot(&mut state), false, &by(&[]));
1253 let row = unpacked.row_for(&state, &groups[0]);
1254 assert_eq!(row.cells[QUANTITY_KEY], serde_json::json!(1));
1255
1256 assert_eq!(state.occurrence_attributes("ACOMP1"), serde_json::json!({}));
1258 assert_eq!(
1259 panel.parsed.columns[0].kind(),
1260 crate::column_tree::CellKind::ReadOnly
1261 );
1262 }
1263
1264 #[test]
1267 fn the_settings_text_drives_the_columns() {
1268 let ctx = egui::Context::default();
1269 let store = MemModelStore::new();
1270 let (mut panel, mut state) =
1271 panel_with("*occurrence.Notes\n*part.Part_Number\npart.Mass\n*occurrence.Torque\n");
1272 frame(&ctx, &mut panel, &mut state, &store, vec![]);
1273
1274 assert!(panel.hits.contains_key("col:occurrence.Notes"));
1275 assert!(panel.hits.contains_key("col:part.Part_Number"));
1276 assert!(panel.hits.contains_key("col:occurrence.Torque"), "custom field");
1277 assert!(
1278 !panel.hits.contains_key("col:part.Mass"),
1279 "unstarred = hidden"
1280 );
1281 assert!(
1282 panel.hits["col:occurrence.Notes"].left() < panel.hits["col:part.Part_Number"].left(),
1283 "the text's order is the table's order"
1284 );
1285 panel.layout.widths.insert("occurrence.Notes".into(), 300.0);
1287 frame(&ctx, &mut panel, &mut state, &store, vec![]);
1288 assert_eq!(panel.layout.widths["occurrence.Notes"], 300.0);
1289 }
1290
1291 #[test]
1294 fn a_layout_change_persists_into_the_settings_text() {
1295 let ctx = egui::Context::default();
1296 let store = MemModelStore::new();
1297 let (mut panel, mut state) = panel_with("*occurrence.Notes\n*part.Part_Number\n");
1298 frame(&ctx, &mut panel, &mut state, &store, vec![]);
1299
1300 panel.layout.hidden.insert("part.Part_Number".into());
1301 panel.persist_layout(&mut state, &store);
1302 assert_eq!(
1303 state.settings.bom_columns, "*occurrence.Notes\npart.Part_Number\n",
1304 "the star came off the hidden column"
1305 );
1306 frame(&ctx, &mut panel, &mut state, &store, vec![]);
1308 assert!(!panel.hits.contains_key("col:part.Part_Number"));
1309 }
1310
1311 #[test]
1315 fn the_actions_cell_opens_the_menu_and_edit_feature_focuses_the_row() {
1316 let ctx = egui::Context::default();
1317 let store = MemModelStore::new();
1318 let (mut panel, mut state) = panel_with("*occurrence.Notes\n");
1319 frame(&ctx, &mut panel, &mut state, &store, vec![]);
1320 let trigger = *panel
1321 .hits
1322 .get("menu:pack:ACOMP3")
1323 .expect("the gadget row's menu trigger");
1324
1325 click_at(&ctx, &mut panel, &mut state, &store, trigger.center());
1326 settle(&ctx, &mut panel, &mut state, &store);
1327 let entry = *panel
1328 .hits
1329 .get("menuitem:pack:ACOMP3:edit-feature")
1330 .expect("Edit feature is on the menu");
1331 let outcome = click_at(&ctx, &mut panel, &mut state, &store, entry.center());
1332 assert_eq!(outcome.focus.as_deref(), Some("ACOMP3"));
1333 }
1334
1335 #[test]
1340 fn a_right_click_on_a_row_runs_a_shared_component_action() {
1341 let ctx = egui::Context::default();
1342 let store = MemModelStore::new();
1343 let (mut panel, mut state) = panel_with("*occurrence.Notes\n");
1344 frame(&ctx, &mut panel, &mut state, &store, vec![]);
1345 let cell = *panel
1346 .hits
1347 .get("cell:pack:ACOMP3:occurrence.Notes")
1348 .expect("the gadget row's Notes cell");
1349
1350 right_click_at(&ctx, &mut panel, &mut state, &store, cell.center());
1351 settle(&ctx, &mut panel, &mut state, &store);
1352 let entry = *panel
1353 .hits
1354 .get("menuitem:pack:ACOMP3:move")
1355 .expect("Move is on the menu opened by right-click");
1356 click_at(&ctx, &mut panel, &mut state, &store, entry.center());
1357
1358 assert!(state.component_move_armed(), "the gizmo armed");
1359 assert_eq!(state.component_move_armed_feature(), "ACOMP3");
1360 assert_eq!(
1361 state.occurrence_attributes("ACOMP3"),
1362 serde_json::json!({}),
1363 "and the right-click wrote nothing into the cell it landed on"
1364 );
1365 }
1366
1367 #[test]
1373 fn the_menu_is_the_shared_action_set_refused_per_row() {
1374 let (_, mut state) = panel_with("*occurrence.Notes\n");
1375 let groups = group(&snapshot(&mut state), true, &by(&[]));
1376
1377 let ids = |actions: &[RowAction]| -> Vec<String> {
1378 actions.iter().map(|action| action.id.clone()).collect()
1379 };
1380 let refused = |actions: &[RowAction]| -> Vec<String> {
1381 actions
1382 .iter()
1383 .filter(|action| !action.enabled)
1384 .map(|action| action.id.clone())
1385 .collect()
1386 };
1387
1388 let packed = groups.iter().find(|g| g.ids.len() == 2).expect("two widgets");
1391 let actions = actions_for(&state, packed);
1392 assert_eq!(
1393 ids(&actions),
1394 vec![
1395 EDIT_FEATURE,
1396 "move",
1397 "open-part",
1398 "toggle-fixed",
1399 "delete"
1400 ],
1401 "Edit feature, then ComponentAction::ALL in bar order"
1402 );
1403 assert_eq!(
1404 refused(&actions),
1405 vec!["move", "open-part", "toggle-fixed", "delete"],
1406 "per-instance actions on a rolled-up row, and the embedded part"
1407 );
1408 assert!(
1409 actions.iter().all(|action| !action.tooltip.is_empty()),
1410 "every entry says what it does — a refused one says why not"
1411 );
1412 assert!(
1413 actions.last().is_some_and(|action| action.destructive
1414 && action.separator_above
1415 && action.id == "delete"),
1416 "Delete is the destructive tail, fenced off"
1417 );
1418
1419 let single = groups.iter().find(|g| g.ids == ["ACOMP3"]).expect("the gadget");
1421 assert_eq!(
1422 refused(&actions_for(&state, single)),
1423 vec!["open-part"],
1424 "only the embedded-part refusal survives on an unpacked row"
1425 );
1426
1427 let panel = BomPanel::new();
1429 let row = panel.row_for(&state, single);
1430 assert!(!row.actions.is_empty(), "the component row offers its menu");
1431 }
1432
1433 #[test]
1437 fn nested_sub_assembly_rows_are_children_and_read_only() {
1438 let (panel, mut state) = panel_with("*occurrence.Notes\n");
1439 let mut groups = group(&snapshot(&mut state), true, &by(&[]));
1440 groups[0].children = vec![ChainNode {
1441 label: "ACOMP9".into(),
1442 children: vec![ChainNode { label: "ACOMP3".into(), children: vec![] }],
1443 }];
1444 let row = panel.row_for(&state, &groups[0]);
1445 assert_eq!(row.children.len(), 1);
1446 assert!(
1447 !row.children[0].editable,
1448 "a nested row belongs to another document"
1449 );
1450 assert!(row.editable, "the top-level row is still editable");
1451 assert_eq!(row.children[0].children.len(), 1, "depth 2 renders");
1456 assert_eq!(
1457 row.children[0].children[0].cells.get(ITEM_KEY),
1458 Some(&Value::String("ACOMP3".into()))
1459 );
1460 }
1461
1462 #[test]
1466 fn visibility_toggle_hides_every_member_solid_of_the_row() {
1467 let ctx = egui::Context::default();
1468 let (mut panel, mut state) = panel_with("*occurrence.Notes\n");
1469 let store = MemModelStore::new();
1470 panel.packed = false;
1471 settle(&ctx, &mut panel, &mut state, &store);
1472 let cell = *panel
1473 .hits
1474 .get(&format!("cell:ACOMP1:{VISIBLE_KEY}"))
1475 .expect("a visibility cell for the first row");
1476 click_at(&ctx, &mut panel, &mut state, &store, cell.center());
1477 assert!(
1478 !state.scene.solid("ACOMP1:Part").unwrap().visible,
1479 "the row's member is hidden"
1480 );
1481 assert!(
1482 state.scene.solid("ACOMP2:Part").unwrap().visible,
1483 "the other instance is untouched"
1484 );
1485 }
1486
1487 #[test]
1491 fn badges_report_the_grounded_instance() {
1492 let (_panel, mut state) = panel_with("*occurrence.Notes\n");
1493 let groups = group(&snapshot(&mut state), false, &by(&[]));
1494 let grounded = groups.iter().find(|g| g.fixed).expect("one is grounded");
1495 let glyphs: Vec<String> = badges(grounded)
1496 .iter()
1497 .filter_map(|badge| badge.get("glyph").and_then(Value::as_str))
1498 .map(str::to_string)
1499 .collect();
1500 assert!(
1501 glyphs.contains(&assembly_components::FIXED_GLYPH.to_string()),
1502 "the grounded row shows ⏚, got {glyphs:?}"
1503 );
1504 let free = groups.iter().find(|g| !g.fixed).expect("one is free");
1505 assert!(badges(free).is_empty(), "a plain instance carries no badge");
1506 }
1507
1508 #[test]
1513 fn a_packed_row_is_grounded_only_when_every_placement_is() {
1514 let (_panel, mut state) = panel_with("*occurrence.Notes\n");
1515 let unpacked = group(&snapshot(&mut state), false, &by(&[]));
1518 assert_eq!(unpacked.len(), 3);
1519 assert_eq!(
1520 unpacked.iter().filter(|g| g.fixed).count(),
1521 1,
1522 "exactly one instance is grounded"
1523 );
1524 let packed = group(&snapshot(&mut state), true, &by(&[]));
1525 let widget = packed
1526 .iter()
1527 .find(|g| g.part_name == "widget")
1528 .expect("the two widgets roll up");
1529 assert_eq!(widget.ids.len(), 2, "same part, same fields — one row");
1530 assert!(!widget.fixed, "not grounded, because not ALL of it is");
1531 assert!(widget.visible, "all are visible, so the row is");
1532 assert_eq!(
1533 widget.solids.len(),
1534 2,
1535 "the toggle writes to every member of every placement"
1536 );
1537 }
1538
1539 #[test]
1543 fn a_viewport_pick_marks_the_component_row_selected() {
1544 let (panel, mut state) = panel_with("*occurrence.Notes\n");
1545 assert!(group(&snapshot(&mut state), false, &by(&[]))
1547 .iter()
1548 .all(|group| !group.selected));
1549
1550 state.select_components(&["ACOMP2".to_string()]);
1552 let unpacked = group(&snapshot(&mut state), false, &by(&[]));
1553 let picked: Vec<&str> = unpacked
1554 .iter()
1555 .filter(|group| group.selected)
1556 .map(|group| group.key.as_str())
1557 .collect();
1558 assert_eq!(picked, vec!["ACOMP2"], "that row, and only that row");
1559 assert!(
1560 panel.row_for(&state, unpacked.iter().find(|g| g.selected).unwrap()).selected,
1561 "and the widget row carries it, so the band is drawn"
1562 );
1563
1564 let packed = group(&snapshot(&mut state), true, &by(&[]));
1567 let widget = packed.iter().find(|g| g.part_name == "widget").unwrap();
1568 assert_eq!(widget.ids, vec!["ACOMP1", "ACOMP2"]);
1569 assert!(widget.selected, "any placement selected selects the row");
1570 assert!(
1571 !packed.iter().find(|g| g.part_name == "gadget").unwrap().selected,
1572 "and an unrelated part's row is left alone"
1573 );
1574 }
1575
1576 #[test]
1581 fn packing_fields_follow_the_visible_occurrence_columns() {
1582 let (mut panel, _state) =
1583 panel_with("*occurrence.Reference_Designator\n*part.Mass\n*occurrence.Notes\n");
1584 assert_eq!(
1585 panel.packing_fields(),
1586 vec!["Reference_Designator".to_string(), "Notes".to_string()],
1587 "occurrence columns only, in the arrangement's order"
1588 );
1589
1590 panel
1591 .layout
1592 .hidden
1593 .insert("occurrence.Reference_Designator".into());
1594 assert_eq!(
1595 panel.packing_fields(),
1596 vec!["Notes".to_string()],
1597 "hiding a column drops it from the key"
1598 );
1599
1600 panel.layout.hidden.insert("occurrence.Notes".into());
1602 assert!(panel.packing_fields().is_empty());
1603 }
1604
1605 #[test]
1609 fn body_leaves_are_not_rows_and_a_plain_part_has_no_children() {
1610 let (panel, mut state) = panel_with("*occurrence.Notes\n");
1611 let mut groups = group(&snapshot(&mut state), false, &by(&[]));
1612 groups[0].children = vec![
1615 ChainNode { label: "Body".into(), children: vec![] },
1616 ChainNode { label: "Rim".into(), children: vec![] },
1617 ChainNode {
1618 label: "ACOMP9".into(),
1619 children: vec![
1620 ChainNode { label: "Cap".into(), children: vec![] },
1621 ChainNode { label: "ACOMP3".into(), children: vec![] },
1622 ],
1623 },
1624 ];
1625 let row = panel.row_for(&state, &groups[0]);
1626 let labels: Vec<&Value> = row
1627 .children
1628 .iter()
1629 .filter_map(|child| child.cells.get(ITEM_KEY))
1630 .collect();
1631 assert_eq!(
1632 labels,
1633 vec![&Value::String("ACOMP9".into())],
1634 "the bodies are not rows — only the nested component is"
1635 );
1636 assert_eq!(
1637 row.children[0]
1638 .children
1639 .iter()
1640 .filter_map(|c| c.cells.get(ITEM_KEY))
1641 .collect::<Vec<_>>(),
1642 vec![&Value::String("ACOMP3".into())],
1643 "and the same rule applies at depth"
1644 );
1645
1646 groups[0].children = vec![ChainNode { label: "Body".into(), children: vec![] }];
1649 assert!(panel.row_for(&state, &groups[0]).children.is_empty());
1650 assert!(
1651 collapsible_keys(&groups).is_empty(),
1652 "and it claims no collapse key"
1653 );
1654 }
1655
1656 #[test]
1658 fn collapse_all_collects_keys_at_every_depth() {
1659 let groups = vec![Group {
1660 key: "G".into(),
1661 part_name: "sub".into(),
1662 ids: vec!["ACOMP1".into()],
1663 attributes: Value::Null,
1664 selected: false,
1665 fixed: false,
1666 outdated: false,
1667 status: None,
1668 visible: true,
1669 solids: vec![],
1670 children: vec![ChainNode {
1671 label: "ACOMP9".into(),
1672 children: vec![ChainNode {
1673 label: "ACOMP3".into(),
1674 children: vec![
1675 ChainNode { label: "ACOMP7".into(), children: vec![] },
1676 ChainNode { label: "Body".into(), children: vec![] },
1677 ],
1678 }],
1679 }],
1680 }];
1681 let keys = collapsible_keys(&groups);
1682 assert!(keys.contains("G"), "the group row");
1683 assert!(keys.contains("G:ACOMP9"), "the nested component");
1684 assert!(keys.contains("G:ACOMP9:ACOMP3"), "and the one inside THAT");
1685 assert!(
1686 !keys.contains("G:ACOMP9:ACOMP3:ACOMP7"),
1687 "a component holding no further COMPONENT has nothing to collapse"
1688 );
1689 assert!(
1690 !keys.contains("G:ACOMP9:ACOMP3:Body"),
1691 "and a body is never a row at all"
1692 );
1693 }
1694}