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, MF_QTY_KEY, PMI_KEY, QUANTITY_KEY, VISIBLE_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};
const EDIT_FEATURE: &str = "edit-feature";
#[derive(Default)]
pub struct BomOutcome {
pub update_components: bool,
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>,
margin_edit: Option<f64>,
plm: crate::panels::bom_plm::PlmBomView,
configuration: crate::panels::bom_configuration::BomConfiguration,
owner_key: Option<String>,
plm_mode: bool,
}
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(),
margin_edit: None,
plm: Default::default(),
configuration: Default::default(),
owner_key: None,
plm_mode: false,
}
}
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 history: Value = serde_json::from_str(&state.history_request_json()).unwrap_or_default();
let mut keys: Vec<String> = history["partsLibrary"].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);
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(|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);
}
if !wires.is_empty() || margin != 0.0 {
ui.label("Cut margin");
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.label(
egui::RichText::new(format!(
"{} rows / {} occurrences{wire_count}",
groups.len() + wires.len(),
occurrences.len()
))
.weak(),
);
});
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);
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();
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 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: "",
};
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);
}
}
if let Some(id) = &out.double_clicked {
if let Some(first) = groups
.iter()
.find(|group| group.key == *id)
.and_then(|group| group.ids.first())
{
if let Some(index) = state.history.index_of(first) {
state.roll_to(index);
}
outcome.focus = Some(first.clone());
}
}
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 == "update-components" {
outcome.update_components = true;
} else 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 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 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(),
"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}", &Value::Array(listing).to_string());
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();
}
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 = state.part_attributes(&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 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(
&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 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,
);
}
}
}
}
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 fixed = group.fixed;
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::MoveCopy if rolled_up => entry.disabled(unpack("copy")),
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 wire_row_key(line: &WireBomLine) -> String {
format!("wire:{}", line.id)
}
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(),
}
}
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(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> {
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(),
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(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));
}
}
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 opens the component's feature", 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 },
];