use crate::automation::hit_keys::HitKeyDoc;
use crate::column_tree::{self, CellEdit, ColumnLayout, ColumnTreeSpec, RowAction, RowNode};
use crate::panels::parts_library;
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);
if crate::automation::registry::enabled() {
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();
crate::automation::registry::publish("__brepBom", "BOM groups {key, partName, ids, quantity}", &Value::Array(listing).to_string());
crate::automation::registry::publish("__brepBomHit", "BOM widget rects (BOM:, cell:)", &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) {
parts_library::write_through(
state,
store,
&group.part_name,
target.as_ref(),
&document,
);
}
}
}
}
pub fn hits_json(&self) -> String {
crate::automation::hit_rects::hits_json(&self.hits)
}
}
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 {
crate::color::rgb_to_hex([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}")
}
pub static HIT_KEYS: &[HitKeyDoc] = &[
HitKeyDoc { panel: "bom", prefix: "bom:expand-all", meaning: "expand every group", command: None },
HitKeyDoc { panel: "bom", prefix: "bom:collapse-all", meaning: "collapse every group", command: None },
HitKeyDoc { panel: "bom", prefix: "bom:packed", meaning: "packed view", command: None },
HitKeyDoc { panel: "bom", prefix: "bom:unpacked", meaning: "unpacked view", command: None },
HitKeyDoc { panel: "bom", prefix: "bom:panel:clip", meaning: "the visible region of the pane", command: None },
HitKeyDoc { panel: "bom", prefix: "BOM:", meaning: "a row action", command: None },
HitKeyDoc { panel: "bom", prefix: "cell:", meaning: "a table cell (cell:row:column)", command: Some("bom_set_occurrence_attribute") },
HitKeyDoc { panel: "bom", prefix: "row:", meaning: "a structure-tree row (row:node key)", command: Some("component_select") },
HitKeyDoc { panel: "bom", prefix: "box:", meaning: "a structure-tree row's expander (box:node key)", command: None },
HitKeyDoc { panel: "bom", prefix: "col:", meaning: "a table column header (col:field) — click to sort", command: None },
HitKeyDoc { panel: "bom", prefix: "grip:", meaning: "a column's resize grip (grip:field)", command: None },
HitKeyDoc { panel: "bom", prefix: "freeze:divider", meaning: "the frozen-column divider", command: None },
];