use crate::column_tree::{self, CellEdit, ColumnLayout, ColumnTreeSpec, RowAction, RowNode};
use crate::panels::assembly_edit;
use crate::panels::component_actions::{
run_component_action, ComponentAction, ComponentActionRequest,
};
use crate::panels::assembly_components::{self, ChainNode, ComponentRow};
use crate::panels::update_components::UpdateComponents;
use crate::panels::bom_columns::{
self, ParsedColumns, Scope, FLAGS_KEY, ITEM_KEY, QUANTITY_KEY, VISIBLE_KEY,
};
use crate::store::ModelStore;
use brep_render::engine_state::EngineState;
use eframe::egui;
use serde_json::Value;
use std::collections::{BTreeMap, HashMap, HashSet};
const EDIT_FEATURE: &str = "edit-feature";
#[derive(Default)]
pub struct BomOutcome {
pub focus: Option<String>,
pub component: Option<ComponentActionRequest>,
}
#[derive(Clone)]
struct Occurrence {
id: String,
part_name: String,
attributes: Value,
selected: bool,
fixed: bool,
outdated: bool,
status: Option<String>,
visible: bool,
solids: Vec<String>,
children: Vec<ChainNode>,
}
pub struct BomPanel {
hits: HashMap<String, egui::Rect>,
layout: ColumnLayout,
layout_source: String,
parsed: ParsedColumns,
packed: bool,
collapsed: HashSet<String>,
}
impl Default for BomPanel {
fn default() -> Self {
Self::new()
}
}
impl BomPanel {
pub fn new() -> Self {
Self {
hits: HashMap::new(),
layout: ColumnLayout::default(),
layout_source: String::new(),
parsed: ParsedColumns::default(),
packed: true,
collapsed: HashSet::new(),
}
}
pub fn show(
&mut self,
ui: &mut egui::Ui,
state: &mut EngineState,
store: &dyn ModelStore,
updates: &UpdateComponents,
) -> BomOutcome {
self.hits.clear();
self.hits.insert("bom:panel:clip".into(), ui.clip_rect());
let mut outcome = BomOutcome::default();
state.ensure_assembly_synced();
self.sync_columns(state);
let component_rows = assembly_components::snapshot(state, updates);
let occurrences = occurrences_from(state, &component_rows);
let groups = group(&occurrences, self.packed, &self.packing_fields());
ui.horizontal(|ui| {
let packed = ui
.selectable_label(self.packed, "Packed")
.on_hover_text("One row per part, rolled up where every occurrence field matches");
self.hits.insert("bom:packed".into(), packed.rect);
if packed.clicked() {
self.packed = true;
}
let unpacked = ui
.selectable_label(!self.packed, "Unpacked")
.on_hover_text("One row per individual instance");
self.hits.insert("bom:unpacked".into(), unpacked.rect);
if unpacked.clicked() {
self.packed = false;
}
let expand = ui
.button("Expand all")
.on_hover_text("Expand every row with nested components");
self.hits.insert("bom:expand-all".into(), expand.rect);
if expand.clicked() {
self.collapsed.clear();
}
let collapse = ui
.button("Collapse all")
.on_hover_text("Collapse every row with nested components");
self.hits.insert("bom:collapse-all".into(), collapse.rect);
if collapse.clicked() {
self.collapsed = collapsible_keys(&groups);
}
ui.label(
egui::RichText::new(format!("{} rows / {} occurrences", groups.len(), occurrences.len()))
.weak(),
);
});
ui.add_space(2.0);
let rows: Vec<RowNode> = groups
.iter()
.map(|group| self.row_for(state, group))
.collect();
let specs = bom_columns::column_specs(&self.parsed);
let mut root_cells: HashMap<String, Value> = HashMap::new();
root_cells.insert(
QUANTITY_KEY.to_string(),
Value::from(occurrences.len() as u64),
);
let spec = ColumnTreeSpec {
id: "bom",
columns: &specs,
root_label: Some("Assembly"),
root_cells: Some(&root_cells),
empty_hint: Some("(no components — insert one via Add new feature)"),
hits_prefix: "",
};
let out = column_tree::column_tree(
ui,
&spec,
&mut self.layout,
&rows,
Some(&mut self.hits),
);
if out.layout_changed {
self.persist_layout(state, store);
}
if let Some(id) = &out.toggled {
if !self.collapsed.remove(id) {
self.collapsed.insert(id.clone());
}
}
if let Some(id) = &out.clicked {
if let Some(group) = groups.iter().find(|group| group.key == *id) {
state.select_components(&group.ids);
}
}
let mut acted = false;
for click in &out.actions {
let Some(group) = groups.iter().find(|group| group.key == click.row_id) else {
continue;
};
let Some(first) = group.ids.first() else {
continue;
};
acted = true;
if click.action == EDIT_FEATURE {
if let Some(index) = state.history.index_of(first) {
state.roll_to(index);
}
outcome.focus = Some(first.clone());
} else if let Some(action) = ComponentAction::from_id(&click.action) {
outcome.component = run_component_action(state, action, first);
}
}
if !acted {
if let Some(edit) = out.edits.first() {
if edit.column == VISIBLE_KEY {
let visible = edit.value.as_bool().unwrap_or(true);
if let Some(group) = groups.iter().find(|g| g.key == edit.row_id) {
for solid in &group.solids {
state.set_visible(solid, visible);
}
}
} else {
self.apply_edit(state, store, &groups, edit);
}
}
}
assembly_components::publish_tree(&component_rows);
#[cfg(target_arch = "wasm32")]
{
let listing: Vec<Value> = groups
.iter()
.map(|group| {
serde_json::json!({
"key": group.key,
"partName": group.part_name,
"ids": group.ids,
"quantity": group.ids.len(),
})
})
.collect();
publish("__brepBom", &Value::Array(listing).to_string());
publish("__brepBomHit", &self.hits_json());
}
outcome
}
fn sync_columns(&mut self, state: &EngineState) {
let text = bom_columns::effective_text(&state.settings.bom_columns);
if text == self.layout_source {
return;
}
self.parsed = bom_columns::parse(&text);
self.layout = bom_columns::layout_from(&self.parsed, &self.layout);
self.layout_source = text;
}
fn persist_layout(&mut self, state: &mut EngineState, store: &dyn ModelStore) {
let columns = bom_columns::columns_from_layout(&self.parsed, &self.layout);
let text = bom_columns::serialize(
&columns,
&self.parsed.preserved,
bom_columns::frozen_from_layout(&self.layout),
);
let mut settings: Value =
serde_json::from_str(&state.settings_json()).unwrap_or(Value::Null);
let Some(object) = settings.as_object_mut() else {
return;
};
object.insert("bomColumns".into(), Value::String(text.clone()));
let json = settings.to_string();
let _ = state.apply_settings_json(&json);
let _ = store.write(crate::store::SETTINGS_KEY, &json);
self.parsed = bom_columns::parse(&text);
self.layout_source = text;
}
fn packing_fields(&self) -> Vec<String> {
self.parsed
.columns
.iter()
.filter(|column| column.scope == Scope::Occurrence)
.filter(|column| column.key() != QUANTITY_KEY)
.filter(|column| !self.layout.hidden.contains(&column.key()))
.map(|column| column.field.clone())
.collect()
}
fn row_for(&self, state: &EngineState, group: &Group) -> RowNode {
let mut cells: HashMap<String, Value> = HashMap::new();
let label = if self.packed {
group.part_name.clone()
} else {
format!("{} ({})", group.part_name, group.key)
};
cells.insert(ITEM_KEY.to_string(), Value::String(label));
cells.insert(VISIBLE_KEY.to_string(), Value::Bool(group.visible));
cells.insert(FLAGS_KEY.to_string(), Value::Array(badges(group)));
cells.insert(
QUANTITY_KEY.to_string(),
Value::from(group.ids.len() as u64),
);
let part_attributes = state.part_attributes(&group.part_name);
for column in &self.parsed.columns {
let key = column.key();
if key == QUANTITY_KEY {
continue;
}
let source = match column.scope {
Scope::Part => &part_attributes,
Scope::Occurrence => &group.attributes,
};
if let Some(value) = source.get(&column.field) {
cells.insert(key, value.clone());
}
}
RowNode {
id: group.key.clone(),
cells,
editable: true,
selected: group.selected,
expanded: !self.collapsed.contains(&group.key),
actions: actions_for(state, group),
children: chain_rows(&group.key, &group.children),
}
}
fn apply_edit(
&self,
state: &mut EngineState,
store: &dyn ModelStore,
groups: &[Group],
edit: &CellEdit,
) {
let Some(group) = groups.iter().find(|group| group.key == edit.row_id) else {
return; };
let Some(column) = self
.parsed
.columns
.iter()
.find(|column| column.key() == edit.column)
else {
return;
};
match column.scope {
Scope::Occurrence => {
if let Err(error) =
state.set_occurrence_attribute(&group.ids, &column.field, edit.value.clone())
{
state.push_notice(format!("BOM: {error}"));
}
}
Scope::Part => {
let target = state.part_source(&group.part_name).and_then(|(key, sig)| {
(!key.is_empty()).then_some((key, sig))
});
if let Err(error) =
state.set_part_attribute(&group.part_name, &column.field, edit.value.clone())
{
state.push_notice(format!("BOM: {error}"));
return;
}
if let Some(document) = state.part_document_json(&group.part_name) {
assembly_edit::write_through(
state,
store,
&group.part_name,
target.as_ref(),
&document,
);
}
}
}
}
#[cfg(target_arch = "wasm32")]
pub fn hits_json(&self) -> String {
let map: serde_json::Map<String, Value> = self
.hits
.iter()
.map(|(key, rect)| {
(
key.clone(),
serde_json::json!([rect.min.x, rect.min.y, rect.width(), rect.height()]),
)
})
.collect();
Value::Object(map).to_string()
}
}
fn actions_for(state: &EngineState, group: &Group) -> Vec<RowAction> {
let Some(first) = group.ids.first() else {
return Vec::new();
};
let fixed = state
.component_info(first)
.map(|info| info.fixed)
.unwrap_or(false);
let rolled_up = group.ids.len() > 1;
let unpack = |verb: &str| {
format!(
"{} placements on this row — switch to Unpacked to {verb} one",
group.ids.len()
)
};
let embedded = !state
.part_source(&group.part_name)
.is_some_and(|(key, _)| !key.is_empty());
let mut actions = vec![RowAction::new(EDIT_FEATURE, "\u{270E} Edit feature")
.tooltip("Roll to this component's feature and open it in the history")];
for action in ComponentAction::ALL {
let entry = RowAction::new(action.id(), action.label(fixed)).tooltip(action.tooltip());
let entry = match action {
ComponentAction::Move if fixed => {
entry.disabled("This component is fixed — unfix it before moving it")
}
ComponentAction::Move if rolled_up => entry.disabled(unpack("move")),
ComponentAction::ToggleFixed if rolled_up => entry.disabled(unpack("fix or unfix")),
ComponentAction::Delete if rolled_up => entry.disabled(unpack("delete")),
ComponentAction::OpenPart if embedded => {
entry.disabled("This part is embedded in the assembly — it has no source document")
}
_ => entry,
};
actions.push(match action {
ComponentAction::Delete => entry.separator_above().destructive(),
_ => entry,
});
}
actions
}
fn collapsible_keys(groups: &[Group]) -> HashSet<String> {
fn owns_component(nodes: &[ChainNode]) -> bool {
nodes
.iter()
.any(|node| assembly_components::is_acomp_segment(&node.label))
}
fn walk(parent: &str, nodes: &[ChainNode], out: &mut HashSet<String>) {
for node in nodes
.iter()
.filter(|node| assembly_components::is_acomp_segment(&node.label))
{
let id = format!("{parent}:{}", node.label);
if owns_component(&node.children) {
out.insert(id.clone());
}
walk(&id, &node.children, out);
}
}
let mut out = HashSet::new();
for group in groups {
if owns_component(&group.children) {
out.insert(group.key.clone());
}
walk(&group.key, &group.children, &mut out);
}
out
}
fn badges(group: &Group) -> Vec<Value> {
let mut out = Vec::new();
if group.fixed {
out.push(serde_json::json!({
"glyph": assembly_components::FIXED_GLYPH,
"tooltip": "Grounded — unfix it before moving it",
}));
}
if group.outdated {
out.push(serde_json::json!({
"glyph": assembly_components::OUTDATED_GLYPH,
"color": color_hex(assembly_components::OUTDATED_AMBER),
"tooltip": "The source part has changed since this was inserted",
}));
}
if let Some(status) = &group.status {
out.push(serde_json::json!({
"glyph": "\u{25CF}",
"color": brep_render::assembly_status::status_color_hex(status),
"tooltip": format!("Constraint status: {status}"),
}));
}
out
}
fn color_hex(color: egui::Color32) -> String {
format!("#{:02x}{:02x}{:02x}", color.r(), color.g(), color.b())
}
fn chain_rows(parent: &str, nodes: &[ChainNode]) -> Vec<RowNode> {
nodes
.iter()
.filter(|node| assembly_components::is_acomp_segment(&node.label))
.map(|node| {
let id = format!("{parent}:{}", node.label);
let mut cells = HashMap::new();
cells.insert(ITEM_KEY.to_string(), Value::String(node.label.clone()));
RowNode {
children: chain_rows(&id, &node.children),
id,
cells,
editable: false,
selected: false,
expanded: false,
actions: Vec::new(),
}
})
.collect()
}
fn worse_status(current: Option<&str>, candidate: Option<&str>) -> bool {
let Some(candidate) = candidate else {
return false;
};
match current {
None => true,
Some(current) => {
brep_render::assembly_status::status_severity(candidate)
> brep_render::assembly_status::status_severity(current)
}
}
}
struct Group {
key: String,
part_name: String,
ids: Vec<String>,
attributes: Value,
selected: bool,
fixed: bool,
outdated: bool,
status: Option<String>,
visible: bool,
solids: Vec<String>,
children: Vec<ChainNode>,
}
fn occurrences_from(state: &mut EngineState, rows: &[ComponentRow]) -> Vec<Occurrence> {
rows.iter()
.map(|row| Occurrence {
attributes: state.occurrence_attributes(&row.id),
selected: row.selected,
fixed: row.fixed,
outdated: row.outdated,
status: row.rollup_status.clone(),
visible: row.visible,
solids: row.solids.clone(),
children: row.children.clone(),
part_name: row.part_name.clone(),
id: row.id.clone(),
})
.collect()
}
fn group(occurrences: &[Occurrence], packed: bool, fields: &[String]) -> Vec<Group> {
if !packed {
return occurrences
.iter()
.map(|occurrence| Group {
key: occurrence.id.clone(),
part_name: occurrence.part_name.clone(),
ids: vec![occurrence.id.clone()],
attributes: occurrence.attributes.clone(),
selected: occurrence.selected,
fixed: occurrence.fixed,
outdated: occurrence.outdated,
status: occurrence.status.clone(),
visible: occurrence.visible,
solids: occurrence.solids.clone(),
children: occurrence.children.clone(),
})
.collect();
}
let mut order: Vec<String> = Vec::new();
let mut groups: HashMap<String, Group> = HashMap::new();
for occurrence in occurrences {
let key = format!(
"{}\u{1}{}",
occurrence.part_name,
canonical_over(&occurrence.attributes, fields)
);
match groups.get_mut(&key) {
Some(group) => {
group.ids.push(occurrence.id.clone());
group.selected |= occurrence.selected;
group.fixed &= occurrence.fixed;
group.visible &= occurrence.visible;
group.outdated |= occurrence.outdated;
group.solids.extend(occurrence.solids.iter().cloned());
if worse_status(group.status.as_deref(), occurrence.status.as_deref()) {
group.status = occurrence.status.clone();
}
for child in &occurrence.children {
if !group.children.iter().any(|kept| kept == child) {
group.children.push(child.clone());
}
}
}
None => {
order.push(key.clone());
groups.insert(
key,
Group {
key: String::new(), part_name: occurrence.part_name.clone(),
ids: vec![occurrence.id.clone()],
attributes: occurrence.attributes.clone(),
selected: occurrence.selected,
fixed: occurrence.fixed,
outdated: occurrence.outdated,
status: occurrence.status.clone(),
visible: occurrence.visible,
solids: occurrence.solids.clone(),
children: occurrence.children.clone(),
},
);
}
}
}
order
.into_iter()
.filter_map(|key| groups.remove(&key))
.map(|mut group| {
group.key = format!(
"pack:{}",
group.ids.first().cloned().unwrap_or_default()
);
group
})
.collect()
}
fn canonical_over(attributes: &Value, fields: &[String]) -> String {
fields
.iter()
.map(|field| {
let value = attributes
.get(field)
.map(Value::to_string)
.unwrap_or_default();
format!("{field}={value}")
})
.collect::<Vec<_>>()
.join("\u{2}")
}
#[cfg(target_arch = "wasm32")]
fn publish(name: &str, json: &str) {
if let Some(win) = web_sys::window() {
let _ = js_sys::Reflect::set(
&win,
&wasm_bindgen::JsValue::from_str(name),
&wasm_bindgen::JsValue::from_str(json),
);
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
fn by(fields: &[&str]) -> Vec<String> {
fields.iter().map(|field| field.to_string()).collect()
}
fn snapshot(state: &mut EngineState) -> Vec<Occurrence> {
let rows = assembly_components::snapshot(state, &UpdateComponents::new());
occurrences_from(state, &rows)
}
use crate::panels::update_components::tests::part_document;
use crate::store::MemModelStore;
use brep_render::engine_state::ComponentInsert;
fn assembly() -> EngineState {
brep_render::brep_kernel::clear_history_cache();
let mut state = EngineState::new();
state
.insert_component(ComponentInsert::New {
name: "widget",
source_key: "",
source_signature: "sig-w",
document_json: &part_document(4.0),
})
.expect("widget inserts");
state
.insert_component(ComponentInsert::Existing { part_name: "widget" })
.expect("second widget");
state
.insert_component(ComponentInsert::New {
name: "gadget",
source_key: "",
source_signature: "sig-g",
document_json: &part_document(7.0),
})
.expect("gadget inserts");
state
}
fn panel_with(columns: &str) -> (BomPanel, EngineState) {
let mut state = assembly();
state
.apply_settings_json(&serde_json::json!({ "bomColumns": columns }).to_string())
.expect("columns apply");
let mut panel = BomPanel::new();
panel.sync_columns(&state);
(panel, state)
}
fn frame(
ctx: &egui::Context,
panel: &mut BomPanel,
state: &mut EngineState,
store: &dyn ModelStore,
events: Vec<egui::Event>,
) -> BomOutcome {
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(900.0, 600.0),
)),
events,
..Default::default()
};
let mut outcome = BomOutcome::default();
let _ = ctx.run_ui(raw, |ui| {
outcome = panel.show(ui, state, store, &UpdateComponents::new());
});
outcome
}
fn settle(
ctx: &egui::Context,
panel: &mut BomPanel,
state: &mut EngineState,
store: &dyn ModelStore,
) {
frame(ctx, panel, state, store, vec![]);
frame(ctx, panel, state, store, vec![]);
}
fn right_click_at(
ctx: &egui::Context,
panel: &mut BomPanel,
state: &mut EngineState,
store: &dyn ModelStore,
pos: egui::Pos2,
) -> BomOutcome {
press_release(ctx, panel, state, store, pos, egui::PointerButton::Secondary)
}
fn click_at(
ctx: &egui::Context,
panel: &mut BomPanel,
state: &mut EngineState,
store: &dyn ModelStore,
pos: egui::Pos2,
) -> BomOutcome {
press_release(ctx, panel, state, store, pos, egui::PointerButton::Primary)
}
fn press_release(
ctx: &egui::Context,
panel: &mut BomPanel,
state: &mut EngineState,
store: &dyn ModelStore,
pos: egui::Pos2,
button: egui::PointerButton,
) -> BomOutcome {
frame(
ctx,
panel,
state,
store,
vec![
egui::Event::PointerMoved(pos),
egui::Event::PointerButton {
pos,
button,
pressed: true,
modifiers: egui::Modifiers::default(),
},
],
);
frame(
ctx,
panel,
state,
store,
vec![egui::Event::PointerButton {
pos,
button,
pressed: false,
modifiers: egui::Modifiers::default(),
}],
)
}
#[test]
fn packed_rolls_up_identical_occurrences_and_unpacked_does_not() {
let mut state = assembly();
let occurrences = snapshot(&mut state);
assert_eq!(occurrences.len(), 3);
let packed = group(&occurrences, true, &by(&[]));
assert_eq!(packed.len(), 2, "widget x2 rolled up, gadget alone");
assert_eq!(packed[0].part_name, "widget");
assert_eq!(packed[0].ids, vec!["ACOMP1", "ACOMP2"]);
assert_eq!(packed[1].ids, vec!["ACOMP3"]);
let unpacked = group(&occurrences, false, &by(&[]));
assert_eq!(unpacked.len(), 3, "one row per placement");
assert!(unpacked.iter().all(|group| group.ids.len() == 1));
assert_eq!(unpacked[0].key, "ACOMP1", "the row IS the occurrence");
}
#[test]
fn a_differing_visible_field_splits_the_packed_row_and_a_hidden_one_does_not() {
let mut state = assembly();
state
.set_occurrence_attribute(
&["ACOMP2".to_string()],
"Reference_Designator",
Value::String("R2".into()),
)
.unwrap();
let shown = by(&["Reference_Designator"]);
let packed = group(&snapshot(&mut state), true, &shown);
assert_eq!(packed.len(), 3, "the two widgets no longer match");
assert_eq!(packed[0].ids, vec!["ACOMP1"]);
assert_eq!(packed[1].ids, vec!["ACOMP2"]);
let packed = group(&snapshot(&mut state), true, &by(&["Notes"]));
assert_eq!(packed.len(), 2, "a hidden difference does not split a row");
assert_eq!(packed[0].ids, vec!["ACOMP1", "ACOMP2"]);
state
.set_occurrence_attribute(
&["ACOMP1".to_string()],
"Reference_Designator",
Value::String("R2".into()),
)
.unwrap();
let packed = group(&snapshot(&mut state), true, &shown);
assert_eq!(packed.len(), 2, "identical again");
assert_eq!(packed[0].ids, vec!["ACOMP1", "ACOMP2"]);
}
#[test]
fn the_packing_key_ignores_attribute_write_order() {
let mut state = assembly();
let one = vec!["ACOMP1".to_string()];
let two = vec!["ACOMP2".to_string()];
state.set_occurrence_attribute(&one, "Notes", Value::String("a".into())).unwrap();
state.set_occurrence_attribute(&one, "Find_Number", Value::String("1".into())).unwrap();
state.set_occurrence_attribute(&two, "Find_Number", Value::String("1".into())).unwrap();
state.set_occurrence_attribute(&two, "Notes", Value::String("a".into())).unwrap();
let packed = group(&snapshot(&mut state), true, &by(&["Notes", "Find_Number"]));
assert_eq!(packed.len(), 2, "still widget x2 + gadget");
assert_eq!(
packed[0].ids,
vec!["ACOMP1", "ACOMP2"],
"same content, different write order, one row"
);
}
#[test]
fn a_packed_edit_fans_out_and_undoes_in_one_step() {
let ctx = egui::Context::default();
let store = MemModelStore::new();
let (mut panel, mut state) = panel_with("*occurrence.Notes\n");
frame(&ctx, &mut panel, &mut state, &store, vec![]);
let cell = *panel
.hits
.get("cell:pack:ACOMP1:occurrence.Notes")
.expect("the packed widget row's Notes cell");
click_at(&ctx, &mut panel, &mut state, &store, cell.center());
frame(&ctx, &mut panel, &mut state, &store, vec![egui::Event::Text("chk".into())]);
frame(
&ctx,
&mut panel,
&mut state,
&store,
vec![
egui::Event::Key {
key: egui::Key::Enter,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::default(),
},
egui::Event::Key {
key: egui::Key::Enter,
physical_key: None,
pressed: false,
repeat: false,
modifiers: egui::Modifiers::default(),
},
],
);
assert_eq!(state.occurrence_attributes("ACOMP1")["Notes"], "chk");
assert_eq!(
state.occurrence_attributes("ACOMP2")["Notes"], "chk",
"the edit fanned out to the whole packed row"
);
assert_eq!(
state.occurrence_attributes("ACOMP3"),
serde_json::json!({}),
"and only to that row"
);
state.undo();
assert_eq!(
state.occurrence_attributes("ACOMP1"),
serde_json::json!({}),
"ONE undo takes the whole fan-out back"
);
assert_eq!(state.occurrence_attributes("ACOMP2"), serde_json::json!({}));
}
#[test]
fn a_part_edit_writes_the_part_document_and_writes_through_to_its_file() {
let ctx = egui::Context::default();
let store = MemModelStore::new();
let document = part_document(4.0);
store.write("widget", &document).unwrap();
brep_render::brep_kernel::clear_history_cache();
let mut state = EngineState::new();
state
.insert_component(ComponentInsert::New {
name: "widget",
source_key: "widget",
source_signature: &assembly_edit::document_signature(&document),
document_json: &document,
})
.unwrap();
state
.insert_component(ComponentInsert::Existing { part_name: "widget" })
.unwrap();
state
.apply_settings_json(r##"{"bomColumns": "*part.Material\n"}"##)
.unwrap();
let mut panel = BomPanel::new();
frame(&ctx, &mut panel, &mut state, &store, vec![]);
let cell = *panel
.hits
.get("cell:pack:ACOMP1:part.Material")
.expect("the Material cell");
click_at(&ctx, &mut panel, &mut state, &store, cell.center());
frame(&ctx, &mut panel, &mut state, &store, vec![egui::Event::Text("6061".into())]);
frame(
&ctx,
&mut panel,
&mut state,
&store,
vec![
egui::Event::Key {
key: egui::Key::Enter,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::default(),
},
egui::Event::Key {
key: egui::Key::Enter,
physical_key: None,
pressed: false,
repeat: false,
modifiers: egui::Modifiers::default(),
},
],
);
assert_eq!(state.part_attributes("widget")["Material"], "6061");
let stored = store.read("widget").expect("the part file");
let stored_document: Value = serde_json::from_str(&stored).unwrap();
assert_eq!(stored_document["partAttributes"]["Material"], "6061");
let (_, signature) = state.part_source("widget").unwrap();
assert_eq!(
signature,
assembly_edit::document_signature(&stored),
"the entry's signature and the file describe the same content"
);
}
#[test]
fn quantity_is_derived_read_only_and_never_stored() {
let (panel, mut state) = panel_with("*occurrence.Quantity\n");
let groups = group(&snapshot(&mut state), true, &by(&[]));
let row = panel.row_for(&state, &groups[0]);
assert_eq!(row.cells[QUANTITY_KEY], serde_json::json!(2));
let mut unpacked = BomPanel::new();
unpacked.packed = false;
unpacked.parsed = panel.parsed.clone();
let groups = group(&snapshot(&mut state), false, &by(&[]));
let row = unpacked.row_for(&state, &groups[0]);
assert_eq!(row.cells[QUANTITY_KEY], serde_json::json!(1));
assert_eq!(state.occurrence_attributes("ACOMP1"), serde_json::json!({}));
assert_eq!(
panel.parsed.columns[0].kind(),
crate::column_tree::CellKind::ReadOnly
);
}
#[test]
fn the_settings_text_drives_the_columns() {
let ctx = egui::Context::default();
let store = MemModelStore::new();
let (mut panel, mut state) =
panel_with("*occurrence.Notes\n*part.Part_Number\npart.Mass\n*occurrence.Torque\n");
frame(&ctx, &mut panel, &mut state, &store, vec![]);
assert!(panel.hits.contains_key("col:occurrence.Notes"));
assert!(panel.hits.contains_key("col:part.Part_Number"));
assert!(panel.hits.contains_key("col:occurrence.Torque"), "custom field");
assert!(
!panel.hits.contains_key("col:part.Mass"),
"unstarred = hidden"
);
assert!(
panel.hits["col:occurrence.Notes"].left() < panel.hits["col:part.Part_Number"].left(),
"the text's order is the table's order"
);
panel.layout.widths.insert("occurrence.Notes".into(), 300.0);
frame(&ctx, &mut panel, &mut state, &store, vec![]);
assert_eq!(panel.layout.widths["occurrence.Notes"], 300.0);
}
#[test]
fn a_layout_change_persists_into_the_settings_text() {
let ctx = egui::Context::default();
let store = MemModelStore::new();
let (mut panel, mut state) = panel_with("*occurrence.Notes\n*part.Part_Number\n");
frame(&ctx, &mut panel, &mut state, &store, vec![]);
panel.layout.hidden.insert("part.Part_Number".into());
panel.persist_layout(&mut state, &store);
assert_eq!(
state.settings.bom_columns, "*occurrence.Notes\npart.Part_Number\n",
"the star came off the hidden column"
);
frame(&ctx, &mut panel, &mut state, &store, vec![]);
assert!(!panel.hits.contains_key("col:part.Part_Number"));
}
#[test]
fn the_actions_cell_opens_the_menu_and_edit_feature_focuses_the_row() {
let ctx = egui::Context::default();
let store = MemModelStore::new();
let (mut panel, mut state) = panel_with("*occurrence.Notes\n");
frame(&ctx, &mut panel, &mut state, &store, vec![]);
let trigger = *panel
.hits
.get("menu:pack:ACOMP3")
.expect("the gadget row's menu trigger");
click_at(&ctx, &mut panel, &mut state, &store, trigger.center());
settle(&ctx, &mut panel, &mut state, &store);
let entry = *panel
.hits
.get("menuitem:pack:ACOMP3:edit-feature")
.expect("Edit feature is on the menu");
let outcome = click_at(&ctx, &mut panel, &mut state, &store, entry.center());
assert_eq!(outcome.focus.as_deref(), Some("ACOMP3"));
}
#[test]
fn a_right_click_on_a_row_runs_a_shared_component_action() {
let ctx = egui::Context::default();
let store = MemModelStore::new();
let (mut panel, mut state) = panel_with("*occurrence.Notes\n");
frame(&ctx, &mut panel, &mut state, &store, vec![]);
let cell = *panel
.hits
.get("cell:pack:ACOMP3:occurrence.Notes")
.expect("the gadget row's Notes cell");
right_click_at(&ctx, &mut panel, &mut state, &store, cell.center());
settle(&ctx, &mut panel, &mut state, &store);
let entry = *panel
.hits
.get("menuitem:pack:ACOMP3:move")
.expect("Move is on the menu opened by right-click");
click_at(&ctx, &mut panel, &mut state, &store, entry.center());
assert!(state.component_move_armed(), "the gizmo armed");
assert_eq!(state.component_move_armed_feature(), "ACOMP3");
assert_eq!(
state.occurrence_attributes("ACOMP3"),
serde_json::json!({}),
"and the right-click wrote nothing into the cell it landed on"
);
}
#[test]
fn the_menu_is_the_shared_action_set_refused_per_row() {
let (_, mut state) = panel_with("*occurrence.Notes\n");
let groups = group(&snapshot(&mut state), true, &by(&[]));
let ids = |actions: &[RowAction]| -> Vec<String> {
actions.iter().map(|action| action.id.clone()).collect()
};
let refused = |actions: &[RowAction]| -> Vec<String> {
actions
.iter()
.filter(|action| !action.enabled)
.map(|action| action.id.clone())
.collect()
};
let packed = groups.iter().find(|g| g.ids.len() == 2).expect("two widgets");
let actions = actions_for(&state, packed);
assert_eq!(
ids(&actions),
vec![
EDIT_FEATURE,
"move",
"edit-in-place",
"open-part",
"toggle-fixed",
"delete"
],
"Edit feature, then ComponentAction::ALL in bar order"
);
assert_eq!(
refused(&actions),
vec!["move", "open-part", "toggle-fixed", "delete"],
"per-instance actions on a rolled-up row, and the embedded part"
);
assert!(
actions.iter().all(|action| !action.tooltip.is_empty()),
"every entry says what it does — a refused one says why not"
);
assert!(
actions.last().is_some_and(|action| action.destructive
&& action.separator_above
&& action.id == "delete"),
"Delete is the destructive tail, fenced off"
);
let single = groups.iter().find(|g| g.ids == ["ACOMP3"]).expect("the gadget");
assert_eq!(
refused(&actions_for(&state, single)),
vec!["open-part"],
"only the embedded-part refusal survives on an unpacked row"
);
let panel = BomPanel::new();
let row = panel.row_for(&state, single);
assert!(!row.actions.is_empty(), "the component row offers its menu");
}
#[test]
fn nested_sub_assembly_rows_are_children_and_read_only() {
let (panel, mut state) = panel_with("*occurrence.Notes\n");
let mut groups = group(&snapshot(&mut state), true, &by(&[]));
groups[0].children = vec![ChainNode {
label: "ACOMP9".into(),
children: vec![ChainNode { label: "ACOMP3".into(), children: vec![] }],
}];
let row = panel.row_for(&state, &groups[0]);
assert_eq!(row.children.len(), 1);
assert!(
!row.children[0].editable,
"a nested row belongs to another document"
);
assert!(row.editable, "the top-level row is still editable");
assert_eq!(row.children[0].children.len(), 1, "depth 2 renders");
assert_eq!(
row.children[0].children[0].cells.get(ITEM_KEY),
Some(&Value::String("ACOMP3".into()))
);
}
#[test]
fn visibility_toggle_hides_every_member_solid_of_the_row() {
let ctx = egui::Context::default();
let (mut panel, mut state) = panel_with("*occurrence.Notes\n");
let store = MemModelStore::new();
panel.packed = false;
settle(&ctx, &mut panel, &mut state, &store);
let cell = *panel
.hits
.get(&format!("cell:ACOMP1:{VISIBLE_KEY}"))
.expect("a visibility cell for the first row");
click_at(&ctx, &mut panel, &mut state, &store, cell.center());
assert!(
!state.scene.solid("ACOMP1:Part").unwrap().visible,
"the row's member is hidden"
);
assert!(
state.scene.solid("ACOMP2:Part").unwrap().visible,
"the other instance is untouched"
);
}
#[test]
fn badges_report_the_grounded_instance() {
let (_panel, mut state) = panel_with("*occurrence.Notes\n");
let groups = group(&snapshot(&mut state), false, &by(&[]));
let grounded = groups.iter().find(|g| g.fixed).expect("one is grounded");
let glyphs: Vec<String> = badges(grounded)
.iter()
.filter_map(|badge| badge.get("glyph").and_then(Value::as_str))
.map(str::to_string)
.collect();
assert!(
glyphs.contains(&assembly_components::FIXED_GLYPH.to_string()),
"the grounded row shows ⏚, got {glyphs:?}"
);
let free = groups.iter().find(|g| !g.fixed).expect("one is free");
assert!(badges(free).is_empty(), "a plain instance carries no badge");
}
#[test]
fn a_packed_row_is_grounded_only_when_every_placement_is() {
let (_panel, mut state) = panel_with("*occurrence.Notes\n");
let unpacked = group(&snapshot(&mut state), false, &by(&[]));
assert_eq!(unpacked.len(), 3);
assert_eq!(
unpacked.iter().filter(|g| g.fixed).count(),
1,
"exactly one instance is grounded"
);
let packed = group(&snapshot(&mut state), true, &by(&[]));
let widget = packed
.iter()
.find(|g| g.part_name == "widget")
.expect("the two widgets roll up");
assert_eq!(widget.ids.len(), 2, "same part, same fields — one row");
assert!(!widget.fixed, "not grounded, because not ALL of it is");
assert!(widget.visible, "all are visible, so the row is");
assert_eq!(
widget.solids.len(),
2,
"the toggle writes to every member of every placement"
);
}
#[test]
fn a_viewport_pick_marks_the_component_row_selected() {
let (panel, mut state) = panel_with("*occurrence.Notes\n");
assert!(group(&snapshot(&mut state), false, &by(&[]))
.iter()
.all(|group| !group.selected));
state.select_components(&["ACOMP2".to_string()]);
let unpacked = group(&snapshot(&mut state), false, &by(&[]));
let picked: Vec<&str> = unpacked
.iter()
.filter(|group| group.selected)
.map(|group| group.key.as_str())
.collect();
assert_eq!(picked, vec!["ACOMP2"], "that row, and only that row");
assert!(
panel.row_for(&state, unpacked.iter().find(|g| g.selected).unwrap()).selected,
"and the widget row carries it, so the band is drawn"
);
let packed = group(&snapshot(&mut state), true, &by(&[]));
let widget = packed.iter().find(|g| g.part_name == "widget").unwrap();
assert_eq!(widget.ids, vec!["ACOMP1", "ACOMP2"]);
assert!(widget.selected, "any placement selected selects the row");
assert!(
!packed.iter().find(|g| g.part_name == "gadget").unwrap().selected,
"and an unrelated part's row is left alone"
);
}
#[test]
fn packing_fields_follow_the_visible_occurrence_columns() {
let (mut panel, _state) =
panel_with("*occurrence.Reference_Designator\n*part.Mass\n*occurrence.Notes\n");
assert_eq!(
panel.packing_fields(),
vec!["Reference_Designator".to_string(), "Notes".to_string()],
"occurrence columns only, in the arrangement's order"
);
panel
.layout
.hidden
.insert("occurrence.Reference_Designator".into());
assert_eq!(
panel.packing_fields(),
vec!["Notes".to_string()],
"hiding a column drops it from the key"
);
panel.layout.hidden.insert("occurrence.Notes".into());
assert!(panel.packing_fields().is_empty());
}
#[test]
fn body_leaves_are_not_rows_and_a_plain_part_has_no_children() {
let (panel, mut state) = panel_with("*occurrence.Notes\n");
let mut groups = group(&snapshot(&mut state), false, &by(&[]));
groups[0].children = vec![
ChainNode { label: "Body".into(), children: vec![] },
ChainNode { label: "Rim".into(), children: vec![] },
ChainNode {
label: "ACOMP9".into(),
children: vec![
ChainNode { label: "Cap".into(), children: vec![] },
ChainNode { label: "ACOMP3".into(), children: vec![] },
],
},
];
let row = panel.row_for(&state, &groups[0]);
let labels: Vec<&Value> = row
.children
.iter()
.filter_map(|child| child.cells.get(ITEM_KEY))
.collect();
assert_eq!(
labels,
vec![&Value::String("ACOMP9".into())],
"the bodies are not rows — only the nested component is"
);
assert_eq!(
row.children[0]
.children
.iter()
.filter_map(|c| c.cells.get(ITEM_KEY))
.collect::<Vec<_>>(),
vec![&Value::String("ACOMP3".into())],
"and the same rule applies at depth"
);
groups[0].children = vec![ChainNode { label: "Body".into(), children: vec![] }];
assert!(panel.row_for(&state, &groups[0]).children.is_empty());
assert!(
collapsible_keys(&groups).is_empty(),
"and it claims no collapse key"
);
}
#[test]
fn collapse_all_collects_keys_at_every_depth() {
let groups = vec![Group {
key: "G".into(),
part_name: "sub".into(),
ids: vec!["ACOMP1".into()],
attributes: Value::Null,
selected: false,
fixed: false,
outdated: false,
status: None,
visible: true,
solids: vec![],
children: vec![ChainNode {
label: "ACOMP9".into(),
children: vec![ChainNode {
label: "ACOMP3".into(),
children: vec![
ChainNode { label: "ACOMP7".into(), children: vec![] },
ChainNode { label: "Body".into(), children: vec![] },
],
}],
}],
}];
let keys = collapsible_keys(&groups);
assert!(keys.contains("G"), "the group row");
assert!(keys.contains("G:ACOMP9"), "the nested component");
assert!(keys.contains("G:ACOMP9:ACOMP3"), "and the one inside THAT");
assert!(
!keys.contains("G:ACOMP9:ACOMP3:ACOMP7"),
"a component holding no further COMPONENT has nothing to collapse"
);
assert!(
!keys.contains("G:ACOMP9:ACOMP3:Body"),
"and a body is never a row at all"
);
}
}