use crate::automation::hit_keys::HitKeyDoc;
use crate::column_tree::{self, CellEdit, ColumnLayout, ColumnTreeSpec, RowAction, RowNode};
use crate::panels::component_actions::{run_component_actions, 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, MASS_KEY, MF_QTY_KEY, PMI_KEY, QUANTITY_KEY,
VISIBLE_KEY, VOLUME_KEY,
};
use crate::store::ModelStore;
use brep_render::engine_state::{EngineState, WireBomLine, WireLengthState};
use eframe::egui;
use serde_json::Value;
use std::collections::{BTreeMap, HashMap, HashSet};
pub(crate) mod nested;
#[derive(Default)]
pub struct BomOutcome {
pub update_components: bool,
pub focus: Option<String>,
pub component: Option<ComponentActionRequest>,
pub work_part_attribute: Option<(String, Value)>,
}
#[derive(Clone)]
struct Occurrence {
id: String,
part_name: String,
attributes: Value,
selected: bool,
fixed: bool,
outdated: bool,
status: Option<String>,
freedom: Option<(usize, usize)>,
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>,
opened: HashSet<String>,
margin_edit: Option<f64>,
plm: crate::panels::bom_plm::PlmBomView,
configuration: crate::panels::bom_configuration::BomConfiguration,
owner_key: Option<String>,
plm_mode: bool,
nested: HashMap<String, nested::NestedLine>,
work_path: Option<String>,
nested_cache: NestedCache,
mass_records: HashMap<String, (Value, brep_render::engine_state::part_mass::MassReport)>,
}
#[derive(Default)]
struct NestedCache {
revision: Option<u64>,
groups: Vec<(String, Vec<String>, String, Vec<ChainNode>)>,
columns: Vec<bom_columns::BomColumn>,
selected: HashSet<String>,
solids: Vec<(String, bool)>,
opened: HashSet<String>,
children: Vec<Vec<RowNode>>,
builds: u64,
tree: Option<(u64, Vec<RowNode>, Option<String>, String)>,
}
fn same_row(a: &RowNode, b: &RowNode) -> bool {
a.id == b.id
&& a.cells == b.cells
&& a.editable == b.editable
&& a.selected == b.selected
&& a.actions == b.actions
&& a.locked == b.locked
}
impl NestedCache {
fn holds(
&self,
revision: u64,
groups: &[Group],
columns: &[bom_columns::BomColumn],
selected: &HashSet<String>,
solids: &[(String, bool)],
opened: &HashSet<String>,
) -> bool {
self.revision == Some(revision)
&& self.groups.len() == groups.len()
&& self.groups.iter().zip(groups).all(|((key, ids, part, children), group)| {
*key == group.key && *ids == group.ids && *part == group.part_name && *children == group.children
})
&& self.columns == columns
&& self.selected == *selected
&& self.solids == solids
&& self.opened == *opened
}
}
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(),
opened: HashSet::new(),
margin_edit: None,
plm: Default::default(),
configuration: Default::default(),
owner_key: None,
plm_mode: false,
nested: HashMap::new(),
work_path: None,
nested_cache: NestedCache::default(),
mass_records: HashMap::new(),
}
}
pub fn set_work_part(&mut self, path: Option<String>) {
self.work_path = path;
}
pub fn show(
&mut self,
ui: &mut egui::Ui,
state: &mut EngineState,
store: &dyn ModelStore,
updates: &UpdateComponents,
document: Option<&str>,
) -> BomOutcome {
self.hits.clear();
self.plm.settle(state);
let plm_target = plm_target(store, document);
self.owner_key = plm_target.as_ref().map(|(_,part,rev)|crate::plm::identity::document_key(part,rev));
self.plm_mode = store.plm_client().is_some();
if let Some(client) = store.plm_client() {
let mut keys: Vec<String> = state.history.parts_library().as_object().into_iter().flat_map(|l|l.values()).filter_map(|entry|entry["sourceKey"].as_str().filter(|key|crate::plm::bom::revision_of_document(key).is_some()).map(str::to_string)).collect();
if let Some(key)=&self.owner_key { keys.push(key.clone()); }
keys.sort(); keys.dedup();
self.configuration.ensure(&client,keys,store.mutation_generation());
self.configuration.ui(ui,&client,&mut self.hits);
}
self.hits.insert("bom:panel:clip".into(), ui.clip_rect());
let mut outcome = BomOutcome::default();
self.sync_columns(state);
let component_rows = assembly_components::snapshot(state, updates);
let mut occurrences = occurrences_from(state, &component_rows);
if let Some(owner)=&self.owner_key {
for occurrence in &mut occurrences {if let Some(attributes)=self.configuration.occurrence(owner,&occurrence.id){occurrence.attributes=attributes;}}
}
let packing_fields = if self.plm_mode {occurrences.iter().flat_map(|o|o.attributes.as_object().into_iter().flat_map(|a|a.keys().cloned())).collect::<std::collections::BTreeSet<_>>().into_iter().collect()}else{self.packing_fields()};
let groups = group(&occurrences, self.packed, &packing_fields);
let wires = state.wire_bom_lines();
let margin = state.wire_harness_state().cut_margin;
let mut margin_commit: Option<f64> = None;
if plm_target.is_some() {
ui.horizontal(|ui| {
let document = ui.selectable_label(!self.plm.active, "Document").on_hover_text("This document's own BOM");
self.hits.insert("bom:source:document".into(), document.rect);
if document.clicked() {
self.plm.active = false;
}
let plm = ui
.selectable_label(self.plm.active, "PLM")
.on_hover_text("The PLM's BOM of this revision: every level, part numbers, catalog values, sourcing and costs");
self.hits.insert("bom:source:plm".into(), plm.rect);
if plm.clicked() {
self.plm.active = true;
}
});
} else {
self.plm.active = false;
}
if let (true, Some((client, part, revision))) = (self.plm.active, &plm_target) {
self.plm.ui(ui, state, client, part, revision, &self.configuration, &mut self.hits);
if crate::automation::registry::enabled() {
self.publish_plm(true);
self.publish_hits();
}
return outcome;
}
ui.horizontal_wrapped(|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();
self.opened = collapsible_keys(&groups);
}
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);
self.opened.clear();
}
if !wires.is_empty() || margin != 0.0 {
ui.add(egui::Label::new("Cut margin").wrap_mode(egui::TextWrapMode::Extend));
let mut value = self.margin_edit.unwrap_or(margin);
let field = ui
.add(egui::DragValue::new(&mut value).range(0.0..=f64::MAX).speed(0.5))
.on_hover_text("Added to EVERY wire's routed length to give its cut length (MF QTY) — once per wire");
self.hits.insert("bom:cut-margin".into(), field.rect);
if field.changed() {
self.margin_edit = Some(value);
}
let editing = field.dragged() || field.has_focus();
if !editing {
if let Some(edited) = self.margin_edit.take() {
if edited != margin {
margin_commit = Some(edited);
}
}
}
}
let wire_count = if wires.is_empty() {
String::new()
} else {
format!(" / {} wire{}", wires.len(), if wires.len() == 1 { "" } else { "s" })
};
ui.add(
egui::Label::new(
egui::RichText::new(format!(
"{} rows / {} occurrences{wire_count}",
groups.len() + wires.len(),
occurrences.len()
))
.weak(),
)
.wrap_mode(egui::TextWrapMode::Extend),
);
});
if updates.outdated_count() > 0 {
let refresh = ui.button(format!("Update components ({})", updates.outdated_count()))
.on_hover_text("Load the latest saved source models and rebuild every affected instance. One undo step.");
self.hits.insert("bom:update-components".into(), refresh.rect);
outcome.update_components |= refresh.clicked();
}
ui.add_space(2.0);
state.measure_parts(groups.iter().map(|group| group.part_name.as_str()));
self.mass_records.clear();
for group in &groups {
if !self.mass_records.contains_key(&group.part_name) {
let record = state.part_bom_record_cached(&group.part_name);
self.mass_records.insert(group.part_name.clone(), record);
}
}
let mut rows: Vec<RowNode> = groups
.iter()
.map(|group| {
let mut row = self.row_for(state, group);
plm_cells(&mut row, state, store, &group.part_name);
row
})
.collect();
{
let emphasis = &state.emphasis;
let selected: HashSet<String> = emphasis
.selected_solids
.iter()
.chain(emphasis.selected_faces.iter())
.chain(emphasis.selected_edges.iter())
.cloned()
.collect();
let solids: Vec<(String, bool)> =
state.scene.solids().iter().map(|s| (s.name.clone(), s.visible)).collect();
let revision = state.history.revision();
let cache = &mut self.nested_cache;
if !cache.holds(revision, &groups, &self.parsed.columns, &selected, &solids, &self.opened) {
self.nested.clear();
let root_parts: Vec<String> = occurrences.iter().map(|o| o.part_name.clone()).collect();
let library = state.history.parts_library();
let visible_under = nested::visibility_by_prefix(&solids);
let top_params: HashMap<String, Value> = groups
.iter()
.flat_map(|group| group.ids.iter())
.filter_map(|id| Some((id.clone(), state.history.feature_params(state.history.index_of(id)?)?)))
.collect();
let mut ctx = nested::NestedCtx {
columns: &self.parsed.columns,
selected: &selected,
solids: &solids,
visible_under: &visible_under,
opened: &self.opened,
root_parts: &root_parts,
library,
lines: &mut self.nested,
uses: nested::UseCounts::new(&root_parts, library),
top_params: &top_params,
};
cache.children = groups
.iter()
.map(|group| {
let entry = &library[&group.part_name];
let source = entry["sourceKey"].as_str().filter(|s| !s.is_empty());
nested::rows(
&mut ctx,
&group.key,
&group.ids,
std::slice::from_ref(&group.part_name),
source,
&group.children,
&entry["document"],
)
})
.collect();
cache.revision = Some(revision);
cache.groups = groups
.iter()
.map(|g| (g.key.clone(), g.ids.clone(), g.part_name.clone(), g.children.clone()))
.collect();
cache.columns = self.parsed.columns.clone();
cache.selected = selected;
cache.solids = solids;
cache.opened = self.opened.clone();
cache.builds += 1;
}
for (row, children) in rows.iter_mut().zip(cache.children.iter_mut()) {
row.children = std::mem::take(children);
}
}
if self.parsed.columns.iter().any(|c| c.key() == MASS_KEY || c.key() == VOLUME_KEY) {
let chains: Vec<String> =
self.nested.values().filter_map(|line| line.paths.first().cloned()).collect();
for chain in &chains {
let _ = state.chain_mass(chain);
}
nested_mass_cells(&mut rows, &self.nested, state);
}
rows.extend(wires.iter().map(wire_row));
let mut specs = bom_columns::column_specs(&self.parsed);
if self.plm_mode {for spec in &mut specs {if let Some(field)=self.configuration.field(&spec.key){spec.label=if field.unit.is_empty(){field.name.clone()}else{format!("{} ({})",field.name,field.unit)};spec.kind=field.cell_kind();}}}
let mut root_cells: HashMap<String, Value> = HashMap::new();
root_cells.insert(
QUANTITY_KEY.to_string(),
Value::from(occurrences.len() as u64),
);
let mut total = Some(0.0);
for group in &groups {
let mass = self.mass_records.get(&group.part_name).and_then(|(_, report)| report.mass_kg);
total = total.zip(mass).map(|(sum, kg)| sum + kg * group.ids.len() as f64);
}
if let Some(total) = total.filter(|_| !groups.is_empty()) {
root_cells.insert(
MASS_KEY.to_string(),
Value::String(brep_render::engine_state::part_mass::format_mass_kg(total)),
);
}
let spec = ColumnTreeSpec {
id: "bom",
columns: &specs,
root_label: Some("Assembly"),
root_cells: Some(&root_cells),
empty_hint: Some("(no components or harness wires — insert a component via Add new feature)"),
hits_prefix: "",
drag_rows: true,
};
let out = column_tree::column_tree(
ui,
&spec,
&mut self.layout,
&rows,
Some(&mut self.hits),
);
if let Some(row) = &out.dragging {
let drag = if let Some(line) = self.nested.get(row) {
Some(crate::panels::instructions::PartsDrag { occurrences: line.paths.clone(), part: line.part_name.clone() })
} else {
groups.iter().find(|g| &g.key == row).map(|g| crate::panels::instructions::PartsDrag { occurrences: g.ids.clone(), part: g.part_name.clone() })
};
if let Some(drag) = drag {
if let Some(pos) = ui.ctx().pointer_interact_pos() {
egui::Area::new(egui::Id::new("bom-drag-tip")).order(egui::Order::Tooltip).fixed_pos(pos + egui::vec2(12.0, 12.0)).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.label(format!("{} → a step", drag.part));
});
});
}
egui::DragAndDrop::set_payload(ui.ctx(), drag);
}
}
if out.layout_changed {
self.persist_layout(state, store);
}
if let Some(id) = &out.toggled {
if self.nested.contains_key(id) {
if !self.opened.remove(id) {
self.opened.insert(id.clone());
}
} else if !self.collapsed.remove(id) {
self.collapsed.insert(id.clone());
}
}
if let Some(id) = &out.clicked {
let additive = ui.input(|i| i.modifiers.command || i.modifiers.shift);
if let Some(line) = self.nested.get(id) {
nested::select_paths(state, &line.paths, additive);
} else if let Some(group) = groups.iter().find(|group| group.key == *id) {
if additive {
nested::select_paths(state, &group.ids, true);
} else {
state.select_components(&group.ids);
}
}
}
if let Some(id) = &out.double_clicked {
if let Some(group) = groups.iter().find(|group| {
*id == group.key || id.starts_with(&format!("{}:", group.key))
}) {
if let Some(first) = group.ids.first() {
outcome.component = Some(ComponentActionRequest::WorkPart {
component_path: format!("{first}{}", &id[group.key.len()..]),
in_place: true,
});
}
}
}
let mut acted = false;
for click in &out.actions {
if click.action == "update-components" {
outcome.update_components = true;
acted = true;
continue;
}
let ids: Vec<String> = match self.nested.get(&click.row_id) {
Some(line) => line.paths.clone(),
None => match groups.iter().find(|group| group.key == click.row_id) {
Some(group) => group.ids.clone(),
None => continue,
},
};
let Some(action) = ComponentAction::from_id(&click.action) else { continue };
acted = true;
match run_component_actions(state, action, &ids) {
Some(ComponentActionRequest::EditFeature { feature_id }) => outcome.focus = Some(feature_id),
request => outcome.component = request,
}
}
if let Some(edited) = margin_commit {
if let Err(error) = state.wire_harness_set_cut_margin(edited) {
state.push_notice(format!("BOM: {error}"));
}
acted = true;
}
if !acted {
if let Some(edit) = out.edits.first() {
if let Some(line) = self.nested.get(&edit.row_id).cloned() {
self.apply_nested_edit(state, store, &line, edit, &mut outcome);
} else 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) {
state.set_visible_many(&group.solids, visible);
}
} else if self.work_path.is_some()
&& edit.column.starts_with("part.")
&& groups.iter().find(|g| g.key == edit.row_id).and_then(|g| g.ids.first())
== self.work_path.as_ref()
{
let field = edit.column["part.".len()..].to_string();
outcome.work_part_attribute = Some((field, edit.value.clone()));
} 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(),
"mass": self.mass_records.get(&group.part_name).map(|(_, report)| report.to_json()),
"plm": plm_entry(state, store, &group.part_name).map(|e| serde_json::json!({
"partNumber": e.part_number, "revision": e.revision_label, "lifecycle": e.lifecycle,
})),
})
})
.collect();
crate::automation::registry::publish("__brepBom", "BOM groups {key, partName, ids, quantity, mass}", &Value::Array(listing).to_string());
let cache = &mut self.nested_cache;
let current = cache.tree.as_ref().is_some_and(|(builds, top, work, _)| {
*builds == cache.builds
&& *work == self.work_path
&& top.len() == rows.len()
&& top.iter().zip(&rows).all(|(a, b)| same_row(a, b))
});
if !current {
let mut tree = serde_json::Map::new();
fn walk(rows: &[RowNode], lines: &HashMap<String, nested::NestedLine>, work: Option<&str>, tree: &mut serde_json::Map<String, Value>) {
for row in rows {
let mut entry = match lines.get(&row.id) {
Some(line) => nested::line_state(row, line),
None => serde_json::json!({
"selected": row.selected, "editable": row.editable,
"cells": row.cells, "locked": row.locked,
}),
};
let path = lines.get(&row.id).and_then(|l| l.paths.first().cloned()).unwrap_or_else(|| row.id.clone());
entry["workPart"] = Value::Bool(work == Some(path.as_str()));
entry["actions"] = row.actions.iter().map(|a| Value::String(a.id.clone())).collect();
entry["menu"] = row
.actions
.iter()
.map(|a| (a.id.clone(), serde_json::json!({ "label": a.label, "enabled": a.enabled, "tooltip": a.tooltip })))
.collect::<serde_json::Map<_, _>>()
.into();
tree.insert(row.id.clone(), entry);
walk(&row.children, lines, work, tree);
}
}
walk(&rows, &self.nested, self.work_path.as_deref(), &mut tree);
let top = rows.iter().map(|row| RowNode { children: Vec::new(), ..row.clone() }).collect();
cache.tree = Some((cache.builds, top, self.work_path.clone(), Value::Object(tree).to_string()));
}
let text = cache.tree.as_ref().map(|(_, _, _, text)| text.as_str()).unwrap_or("{}");
crate::automation::registry::publish("__brepBomTree", "every BOM line by row id, nested lines included: selected, editable, cell values, read-only cells with their reason, menu action ids and each entry's {label, enabled, tooltip}, workPart; nested lines also paths/partName/ownerUses", text);
let wire_listing: Vec<Value> = wires
.iter()
.map(|line| {
serde_json::json!({
"key": wire_row_key(line),
"connectionId": line.connection_id,
"stockPartNumber": line.stock_part_number,
"length": line.length,
"margin": line.margin,
"mfQty": line.mf_qty,
"status": line.state.as_str(),
})
})
.collect();
crate::automation::registry::publish("__brepBomWires", "BOM wire lines {key, connectionId, stockPartNumber, length, margin, mfQty, status}", &Value::Array(wire_listing).to_string());
self.publish_plm(plm_target.is_some());
self.publish_hits();
}
for (children, row) in self.nested_cache.children.iter_mut().zip(rows.iter_mut()) {
*children = std::mem::take(&mut row.children);
}
outcome
}
fn sync_columns(&mut self, state: &EngineState) {
let text = if self.plm_mode {self.configuration.text().unwrap_or_else(||bom_columns::effective_text(&state.settings.bom_columns))}else{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);
if self.plm_mode {self.configuration.adopt_layout(&columns);return;}
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 && column.key() != MF_QTY_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 pmi = state.part_pmi_count(&group.part_name);
if pmi > 0 {
cells.insert(PMI_KEY.to_string(), Value::from(pmi as u64));
}
let (part_attributes, mass) = self
.mass_records
.get(&group.part_name)
.cloned()
.unwrap_or_else(|| state.part_bom_record_cached(&group.part_name));
for column in &self.parsed.columns {
let key = column.key();
if key == QUANTITY_KEY || key == PMI_KEY || key == MF_QTY_KEY {
continue;
}
if self.plm_mode {
if let Some(field)=self.configuration.field(&key) {
if let Some(target)=field.edit.as_ref().filter(|target|target.resource=="part") {
if let Some((source_key,_))=state.part_source(&group.part_name){
if let Some(snapshot)=self.configuration.snapshot(&source_key){if let Some(value)=snapshot["record"].get(&target.key){cells.insert(key.clone(),value.clone());}}
}
continue;
}
if field.scope=="part" {
if let Some((source_key,_))=state.part_source(&group.part_name){
if let Some(snapshot)=self.configuration.snapshot(&source_key){
if snapshot["part_type"].as_str()==field.part_type.as_deref(){if let Some(value)=snapshot["attributes"].get(&field.key){cells.insert(key.clone(),value.clone());}}
}
}
continue;
}
if field.id=="builtin.total"{cells.insert(key.clone(),Value::from(group.ids.len() as u64));continue;}
}
}
let source = match column.scope {
Scope::Part => &part_attributes,
Scope::Occurrence => &group.attributes,
};
if key == MASS_KEY || key == VOLUME_KEY {
let text = source
.get(&column.field)
.and_then(|value| {
brep_render::engine_state::part_mass::format_derived(&column.field, value)
})
.or_else(|| mass.status.note().filter(|_| key == MASS_KEY));
if let Some(text) = text {
cells.insert(key, Value::String(text));
}
continue;
}
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: Vec::new(),
locked: HashMap::new(),
}
}
fn apply_nested_edit(
&mut self,
state: &mut EngineState,
store: &dyn ModelStore,
line: &nested::NestedLine,
edit: &CellEdit,
outcome: &mut BomOutcome,
) {
if edit.column == VISIBLE_KEY {
let visible = edit.value.as_bool().unwrap_or(true);
let members: Vec<String> = state
.scene
.solids()
.iter()
.filter(|s| line.paths.iter().any(|p| s.name.starts_with(&format!("{p}:"))))
.map(|s| s.name.clone())
.collect();
state.set_visible_many(&members, visible);
return;
}
let Some(column) = self.parsed.columns.iter().find(|c| c.key() == edit.column).cloned() else {
return;
};
if column.scope == Scope::Part && line.paths.first() == self.work_path.as_ref() {
outcome.work_part_attribute = Some((column.field.clone(), edit.value.clone()));
return;
}
if column.scope == Scope::Part && self.queue_nested_plm_edit(state, store, line, &column, edit) {
return;
}
if let Err(error) = nested::apply_edit(state, line, &column, edit.value.clone()) {
state.push_notice(format!("BOM: {error}"));
}
}
fn queue_nested_plm_edit(
&mut self,
state: &mut EngineState,
store: &dyn ModelStore,
line: &nested::NestedLine,
column: &bom_columns::BomColumn,
edit: &CellEdit,
) -> bool {
let Some(key) = line.part_source.as_deref() else { return false };
let (Some((part, rev)), Some(client)) = (crate::plm::bom::revision_of_document(key), store.plm_client()) else {
return false;
};
let source = Some(key);
if self.plm_mode {
if let Some(field) = self.configuration.field(&column.key()).cloned() {
if !field.editable {
return true;
}
if let Some(target) = field.edit.as_ref().filter(|target| target.resource == "part") {
self.plm.queue_part_record_patch(&client, &part, &line.part_name, &target.key, &field.cad_field, edit.value.clone());
self.plm.nest_last(&line.part_names, source);
return true;
}
if field.scope == "part" {
if self.configuration.snapshot(key).is_some_and(|s| s["part_type"].as_str() == field.part_type.as_deref()) {
self.plm.queue_revision_patch(&client, &part, &rev, &line.part_name, &field.key, edit.value.clone());
self.plm.nest_last(&line.part_names, source);
}
return true;
}
}
}
if PLM_PART_FIELDS.iter().any(|f| column.field.eq_ignore_ascii_case(f)) {
state.push_notice(format!(
"BOM: {}'s {} is the PLM's; it is not edited here",
line.part_name,
column.field.replace('_', " ").to_lowercase()
));
return true;
}
self.plm.queue_patch(&client, &part, &line.part_name, &column.field, edit.value.clone());
self.plm.nest_last(&line.part_names, source);
true
}
fn apply_edit(
&mut 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;
};
if self.plm_mode {
if let Some(field)=self.configuration.field(&column.key()).cloned(){
if !field.editable {return;}
if let Some(client)=store.plm_client(){
if let Some(target)=field.edit.as_ref().filter(|target|target.resource=="part") {
if let Some((key,_))=state.part_source(&group.part_name){if let Some((part,_))=crate::plm::bom::revision_of_document(&key){self.plm.queue_part_record_patch(&client,&part,&group.part_name,&target.key,&field.cad_field,edit.value.clone());return;}}
}
if field.scope=="part"{
if let Some((key,_))=state.part_source(&group.part_name){
if let Some((part,rev))=crate::plm::bom::revision_of_document(&key){
if self.configuration.snapshot(&key).is_some_and(|s|s["part_type"].as_str()==field.part_type.as_deref()){
self.plm.queue_revision_patch(&client,&part,&rev,&group.part_name,&field.key,edit.value.clone());
}
return;
}
}
}else if field.scope=="occurrence"{
if let Some(owner)=&self.owner_key{
if group.ids.iter().all(|id|self.configuration.occurrence(owner,id).is_some()){
if let Some((part,rev))=crate::plm::bom::revision_of_document(owner){self.plm.queue_occurrence_patch(&client,&part,&rev,&group.ids,&field.cad_field,edit.value.clone(),true);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))
});
let plm_part = target.as_ref().and_then(|(key, _)| crate::plm::bom::revision_of_document(key));
if let (Some((part_id, _)), Some(client)) = (plm_part, store.plm_client()) {
if PLM_PART_FIELDS.iter().any(|f| column.field.eq_ignore_ascii_case(f)) {
state.push_notice(format!(
"BOM: {}'s {} is the PLM's; it is not edited here",
group.part_name,
column.field.replace('_', " ").to_lowercase()
));
return;
}
self.plm.queue_patch(&client, &part_id, &group.part_name, &column.field, edit.value.clone());
return;
}
if let Some((key, _)) = target.as_ref() {
let result = state
.part_document_json(&group.part_name)
.and_then(|text| serde_json::from_str::<Value>(&text).ok())
.ok_or_else(|| format!("part '{}': malformed document", group.part_name))
.and_then(|mut document| {
nested::write_record(&mut document, brep_render::engine_state::PART_ATTRIBUTES, &column.field, edit.value.clone());
nested::apply_definition(state, std::slice::from_ref(&group.part_name), Some(key.as_str()), &document)
});
if let Err(error) = result {
state.push_notice(format!("BOM: {error}"));
}
return;
}
if let Err(error) =
state.set_part_attribute(&group.part_name, &column.field, edit.value.clone())
{
state.push_notice(format!("BOM: {error}"));
}
}
}
}
fn publish_plm(&self, available: bool) {
crate::automation::registry::publish(
"__brepPlmBom",
"the BOM panel's PLM view: {available, active, flat, loading, error, number, revision, lines:[server lines], totals, unpriced, warnings, document:[the document's lines in the same terms], documentRefused, differences, pendingEdits, lastEdit}",
&self.plm.state_json(available).to_string(),
);
}
pub fn hits_json(&self) -> String {
crate::automation::hit_rects::hits_json(&self.hits)
}
pub fn clear_hits(&mut self) {
self.hits.clear();
if crate::automation::registry::enabled() {
self.publish_hits();
}
}
fn publish_hits(&self) {
crate::automation::registry::publish("__brepBomHit", "BOM widget rects (BOM:, cell:)", &self.hits_json());
}
}
fn actions_for(state: &EngineState, group: &Group) -> Vec<RowAction> {
if group.ids.is_empty() {
return Vec::new();
}
let components = state.assembly_components();
let fixed = group
.ids
.iter()
.map(|id| {
components
.iter()
.find(|component| &component.id == id)
.map(|component| component.fixed)
.ok_or_else(|| format!("'{id}' is not an assembly component"))
})
.collect();
let embedded = !state.part_source(&group.part_name).is_some_and(|(key, _)| !key.is_empty());
crate::panels::component_actions::component_menu(&crate::panels::component_actions::MenuSubject {
ids: group.ids.clone(),
embedded,
fixed,
visible: group.visible,
})
}
fn wire_row_key(line: &WireBomLine) -> String {
format!("wire:{}", line.id)
}
fn nested_mass_cells(
rows: &mut [RowNode],
lines: &HashMap<String, nested::NestedLine>,
state: &EngineState,
) {
for row in rows {
if let Some(chain) = lines.get(&row.id).and_then(|line| line.paths.first()) {
let report = state.chain_mass_cached(chain);
row.cells.remove(MASS_KEY);
row.cells.remove(VOLUME_KEY);
let mass = report
.mass_kg
.map(brep_render::engine_state::part_mass::format_mass_kg)
.or_else(|| report.status.note());
if let Some(text) = mass {
row.cells.insert(MASS_KEY.to_string(), Value::String(text));
}
if let Some(volume) = report.volume_mm3 {
row.cells.insert(
VOLUME_KEY.to_string(),
Value::String(brep_render::engine_state::part_mass::format_volume_mm3(volume)),
);
}
}
nested_mass_cells(&mut row.children, lines, state);
}
}
fn wire_row(line: &WireBomLine) -> RowNode {
let mut cells: HashMap<String, Value> = HashMap::new();
cells.insert(ITEM_KEY.to_string(), Value::String(line.connection_id.clone()));
cells.insert(QUANTITY_KEY.to_string(), Value::from(1_u64));
if !line.stock_part_number.is_empty() {
cells.insert("part.Part_Number".to_string(), Value::String(line.stock_part_number.clone()));
}
let mut flags = Vec::new();
match line.mf_qty {
Some(qty) => {
cells.insert(MF_QTY_KEY.to_string(), Value::from((qty * 1000.0).round() / 1000.0));
}
None => {
cells.insert(MF_QTY_KEY.to_string(), Value::String(line.state.describe()));
flags.push(serde_json::json!({
"glyph": "\u{25CF}",
"color": color_hex(assembly_components::OUTDATED_AMBER),
"tooltip": format!("No cut length: {}", line.state.explain()),
}));
}
}
cells.insert(FLAGS_KEY.to_string(), Value::Array(flags));
RowNode {
id: wire_row_key(line),
cells,
editable: false,
selected: false,
expanded: false,
actions: Vec::new(),
children: Vec::new(),
locked: HashMap::new(),
}
}
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": "Source changed — click to update all outdated components from their saved models",
"action": "update-components",
}));
}
if let Some((turns, slides)) = group.freedom.filter(|&free| freedom_total(Some(free)) > 0) {
out.push(serde_json::json!({
"glyph": assembly_components::UNDER_DEFINED_GLYPH,
"tooltip": freedom_words(turns, slides),
}));
}
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 freedom_total(freedom: Option<(usize, usize)>) -> usize {
freedom.map_or(0, |(turns, slides)| turns + slides)
}
pub(crate) fn freedom_words(turns: usize, slides: usize) -> String {
let plural = |count: usize, one: &str, many: &str| match count {
0 => None,
1 => Some(format!("1 {one}")),
n => Some(format!("{n} {many}")),
};
let parts: Vec<String> = [plural(turns, "turn", "turns"), plural(slides, "slide", "slides")].into_iter().flatten().collect();
format!("Under-defined: the mates leave {} free", parts.join(" and "))
}
fn color_hex(color: egui::Color32) -> String {
crate::color::rgb_to_hex([color.r(), color.g(), color.b()])
}
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>,
freedom: Option<(usize, usize)>,
visible: bool,
solids: Vec<String>,
children: Vec<ChainNode>,
}
fn occurrences_from(state: &mut EngineState, rows: &[ComponentRow]) -> Vec<Occurrence> {
let mut attributes = state.occurrence_attributes_all();
rows.iter()
.map(|row| Occurrence {
attributes: attributes
.remove(&row.id)
.unwrap_or_else(|| Value::Object(serde_json::Map::new())),
selected: row.selected,
fixed: row.fixed,
outdated: row.outdated,
status: row.rollup_status.clone(),
freedom: row.freedom,
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(),
freedom: occurrence.freedom,
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 freedom_total(occurrence.freedom) > freedom_total(group.freedom) {
group.freedom = occurrence.freedom;
}
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(),
freedom: occurrence.freedom,
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(crate) const PLM_PART_FIELDS: [&str; 3] = ["Part_Number", "Revision", "Lifecycle_State"];
fn plm_cells(row: &mut RowNode, state: &EngineState, store: &dyn ModelStore, part_name: &str) {
let Some(entry) = plm_entry(state, store, part_name) else { return };
for (field, value) in [
("Part_Number", entry.part_number.clone()),
("Revision", entry.revision_label.clone()),
("Lifecycle_State", entry.lifecycle.clone()),
] {
row.cells.insert(format!("part.{field}"), Value::String(value));
}
if crate::plm::superseded::is_superseded(&entry.lifecycle) {
let badge = serde_json::json!({
"glyph": assembly_components::OUTDATED_GLYPH,
"color": color_hex(assembly_components::OUTDATED_AMBER),
"tooltip": format!("PLM: rev {} is {} — see the PLM pane for the current revision", entry.revision_label, entry.lifecycle),
"kind": "superseded",
});
match row.cells.get_mut(FLAGS_KEY).and_then(Value::as_array_mut) {
Some(flags) => flags.push(badge),
None => {
row.cells.insert(FLAGS_KEY.to_string(), Value::Array(vec![badge]));
}
}
}
}
fn plm_entry(state: &EngineState, store: &dyn ModelStore, part_name: &str) -> Option<crate::plm::client::IndexEntry> {
let (key, _) = state.part_source(part_name)?;
let (part, rev) = crate::plm::bom::revision_of_document(&key)?;
store.plm_revision(&crate::plm::identity::document_key(&part, &rev))
}
fn plm_target(store: &dyn ModelStore, document: Option<&str>) -> Option<(std::rc::Rc<crate::plm::client::PlmClient>, String, String)> {
let connected = matches!(
store.plm_session().map(|s| s.status),
Some(crate::plm::connection::Status::Connected { .. })
);
if !connected {
return None;
}
let (part, revision) = crate::plm::bom::revision_of_document(document?)?;
Some((store.plm_client()?, part, revision))
}
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:update-components", meaning: "update all outdated components from saved sources", command: Some("component_update") },
HitKeyDoc { panel: "bom", prefix: "badge:", meaning: "an actionable row badge (badge:<row>:update-components)", command: Some("component_update") },
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:source:", meaning: "the BOM's source, document or plm (only for a PLM revision in a PLM session)", command: None },
HitKeyDoc { panel: "bom", prefix: "bom:plm:", meaning: "the PLM view: indented, flat, refresh, lines", command: None },
HitKeyDoc { panel: "bom", prefix: "bom:cut-margin", meaning: "the cut margin added to every harness wire's routed length (MF QTY), shown once the document has a wire; written when the edit ends", command: Some("wire_harness_set_cut_margin") },
HitKeyDoc { panel: "bom", prefix: "menu:", meaning: "a row's action-menu trigger cell (menu:<row key>) \u{2014} the rightmost column, so it may need the pane scrolled horizontally; a RIGHT-CLICK anywhere on the row opens the same menu", command: None },
HitKeyDoc { panel: "bom", prefix: "menuitem:", meaning: "one entry of the OPEN row action menu (menuitem:<row key>:<action>, e.g. toggle-fixed, move, delete, edit-feature, open-part) \u{2014} published only while the menu is up", command: Some("component_set_fixed") },
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) \u{2014} a click selects its components, a double click activates the Work part", 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: "label:", meaning: "a structure-tree row's name cell (label:node key), the drag handle: dragged onto an Instructions step row, the row's occurrences join that step", command: Some("instructions_add_parts") },
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 },
];