Skip to main content

brep_app/panels/
bom.rs

1//! The assembly BOM panel — the parts list on the shared
2//! [`crate::column_tree`] widget.
3//!
4//! This module is the BOM-shaped half: it turns the engine's component
5//! projection into the widget's generic rows, and turns the widget's generic
6//! edits back into the two attribute stores. The widget itself knows none of
7//! this, which is what lets a second consumer (the wire-harness connection
8//! list) reuse it untouched.
9//!
10//! # Packed and unpacked
11//!
12//! Two views of the same occurrences.
13//!
14//! * **Unpacked** — one row per placement. `Quantity` reads 1.
15//! * **Packed** — one row per DISTINCT part **whose occurrence data matches**.
16//!   Two occurrences roll up only when they agree on EVERY occurrence field;
17//!   one differing Reference Designator and they stay two rows. `Quantity` is
18//!   the size of the group, read-only in both views because it is derived and
19//!   so can never disagree with the model.
20//!
21//! Editing a packed row applies to every occurrence it rolls up, and that
22//! fan-out is ONE undo step, not N — `EngineState::set_occurrence_attribute`
23//! takes the whole group and checkpoints once. A part-level edit is inherently
24//! the same shape (the value lives on the part, so every occurrence of it sees
25//! the change) and is one undo step for the same reason.
26//!
27//! # Nested sub-assembly rows are READ-ONLY
28//!
29//! A rigid sub-assembly's internal components appear as child rows, so the BOM
30//! reads as the tree it is — but their part and occurrence data lives in the
31//! SUB-ASSEMBLY's own document, not this one. That is the rigid-nesting model
32//! (the same reason `EngineState::export_bom_csv` reports a sub-assembly as one
33//! row at this level), not a limit of the widget: editing them means opening
34//! that document. They are drawn weak and take no edit.
35//!
36//! # The row ACTION MENU
37//!
38//! The rightmost column's `⋯` opens a menu — and so does a right-click
39//! anywhere on the row; both are the widget's ONE menu, declared here as
40//! [`RowAction`]s. Its entries are the SHARED component actions
41//! ([`crate::panels::component_actions`], the same dispatcher the assembly
42//! structure tree's row buttons route through) plus this panel's own
43//! "Edit feature" — the `✎` button the menu replaced.
44//!
45//! Availability is decided PER ROW here, because only this panel knows what
46//! refuses what: a fixed component will not Move, an embedded-only part has no
47//! source document to Open, and a PACKED row standing for several placements
48//! refuses the per-instance actions rather than guessing which placement was
49//! meant (and Delete across a group would be N undo steps, not the one this
50//! panel promises). Refused entries are greyed with the reason, never hidden.
51//!
52//! # The write lanes
53//!
54//! * occurrence field → `set_occurrence_attribute(ids, key, value)`.
55//! * part field → `set_part_attribute(part, key, value)`, then the shared
56//!   write-through lane ([`crate::panels::parts_library::write_through`]) so
57//!   the part's file and the entry's signature keep agreeing. There is no
58//!   second write path.
59
60use crate::automation::hit_keys::HitKeyDoc;
61use crate::column_tree::{self, CellEdit, ColumnLayout, ColumnTreeSpec, RowAction, RowNode};
62use crate::panels::parts_library;
63use crate::panels::component_actions::{
64    run_component_action, ComponentAction, ComponentActionRequest,
65};
66use crate::panels::assembly_components::{self, ChainNode, ComponentRow};
67use crate::panels::update_components::UpdateComponents;
68use crate::panels::bom_columns::{
69    self, ParsedColumns, Scope, FLAGS_KEY, ITEM_KEY, QUANTITY_KEY, VISIBLE_KEY,
70};
71use crate::store::ModelStore;
72use brep_render::engine_state::EngineState;
73use eframe::egui;
74use serde_json::Value;
75use std::collections::{BTreeMap, HashMap, HashSet};
76
77/// The row menu's own entry: roll to the component's feature and open it in
78/// the history tree. Not a [`ComponentAction`] — the shared set is the
79/// COMPONENT vocabulary (spec §8.5) and the context bar draws a button per
80/// member of it, so a document-navigation entry does not belong in there. The
81/// assembly structure tree keeps this action locally for the same reason.
82const EDIT_FEATURE: &str = "edit-feature";
83
84/// What a BOM frame hands back to the shell.
85#[derive(Default)]
86pub struct BomOutcome {
87    /// A feature id to roll to + expand in the history tree (the row menu's
88    /// "Edit feature") — the structure panel's `focus` contract, verbatim, so
89    /// the shell routes both the same way.
90    pub focus: Option<String>,
91    /// A document-level flow the SHELL owns (Edit Part → open the part's own
92    /// document tab), handed
93    /// back by the shared component-action dispatcher exactly as the selection
94    /// context bar hands it back.
95    pub component: Option<ComponentActionRequest>,
96}
97
98/// One occurrence, flattened out of the engine's projection.
99#[derive(Clone)]
100struct Occurrence {
101    /// The owning ACOMP feature id.
102    id: String,
103    part_name: String,
104    /// This occurrence's own attribute record.
105    attributes: Value,
106    selected: bool,
107    /// Grounded (the ⏚ badge, and what refuses Move).
108    fixed: bool,
109    /// The library entry no longer matches its store source (the ↻ badge).
110    outdated: bool,
111    /// Worst constraint status referencing this component, if any.
112    status: Option<String>,
113    /// Every member solid currently visible.
114    visible: bool,
115    /// Member scene names, for the visibility toggle.
116    solids: Vec<String>,
117    /// Read-only nested component rows, from the member name chains. FULL
118    /// depth: a sub-assembly inside a sub-assembly renders as such.
119    children: Vec<ChainNode>,
120}
121
122/// The BOM panel's transient UI state. The data lives in the document; the
123/// column arrangement lives in the settings text; this holds only what is true
124/// for this session.
125pub struct BomPanel {
126    hits: HashMap<String, egui::Rect>,
127    /// The widget's live column arrangement. Rebuilt from the settings text
128    /// whenever that text changes, keeping session-only widths + sort.
129    layout: ColumnLayout,
130    /// The settings text `layout` was built from — the change detector.
131    layout_source: String,
132    /// The parsed configuration for `layout_source`.
133    parsed: ParsedColumns,
134    /// Packed (one row per distinct part + occurrence data) or unpacked (one
135    /// row per placement).
136    packed: bool,
137    /// Rows explicitly collapsed, by row id (absent = open).
138    collapsed: HashSet<String>,
139}
140
141impl Default for BomPanel {
142    fn default() -> Self {
143        Self::new()
144    }
145}
146
147impl BomPanel {
148    pub fn new() -> Self {
149        Self {
150            hits: HashMap::new(),
151            layout: ColumnLayout::default(),
152            layout_source: String::new(),
153            parsed: ParsedColumns::default(),
154            // Packed is the BOM a person asks for: a parts list, not a
155            // placement list.
156            packed: true,
157            collapsed: HashSet::new(),
158        }
159    }
160
161    /// Draw the BOM. Snapshots the projection, draws the column tree, then
162    /// applies at most one deferred engine mutation — the shared panel
163    /// pattern, and the reason the draw can borrow `state` immutably.
164    pub fn show(
165        &mut self,
166        ui: &mut egui::Ui,
167        state: &mut EngineState,
168        store: &dyn ModelStore,
169        updates: &UpdateComponents,
170    ) -> BomOutcome {
171        self.hits.clear();
172        // What is actually VISIBLE of this pane. Every other rect below is a
173        // raw LAYOUT rect, so a widget scrolled past the pane's edge is still
174        // published while being unclickable — a headed verifier has to scroll
175        // it into this rect first. (The constraints panel publishes
176        // `acon:panel:clip` for exactly the same reason.)
177        self.hits.insert("bom:panel:clip".into(), ui.clip_rect());
178        let mut outcome = BomOutcome::default();
179        state.ensure_assembly_synced();
180
181        self.sync_columns(state);
182        let component_rows = assembly_components::snapshot(state, updates);
183        let occurrences = occurrences_from(state, &component_rows);
184        let groups = group(&occurrences, self.packed, &self.packing_fields());
185
186        // --- header: the packed/unpacked switch ------------------------------
187        ui.horizontal(|ui| {
188            let packed = ui
189                .selectable_label(self.packed, "Packed")
190                .on_hover_text("One row per part, rolled up where every occurrence field matches");
191            self.hits.insert("bom:packed".into(), packed.rect);
192            if packed.clicked() {
193                self.packed = true;
194            }
195            let unpacked = ui
196                .selectable_label(!self.packed, "Unpacked")
197                .on_hover_text("One row per individual instance");
198            self.hits.insert("bom:unpacked".into(), unpacked.rect);
199            if unpacked.clicked() {
200                self.packed = false;
201            }
202            let expand = ui
203                .button("Expand all")
204                .on_hover_text("Expand every row with nested components");
205            self.hits.insert("bom:expand-all".into(), expand.rect);
206            if expand.clicked() {
207                self.collapsed.clear();
208            }
209            let collapse = ui
210                .button("Collapse all")
211                .on_hover_text("Collapse every row with nested components");
212            self.hits.insert("bom:collapse-all".into(), collapse.rect);
213            if collapse.clicked() {
214                // Every key the tree can hold: the group rows and, beneath
215                // them, every nested chain node — collapse-all has to fold the
216                // WHOLE tree, at every depth, with no key drift.
217                self.collapsed = collapsible_keys(&groups);
218            }
219            ui.label(
220                egui::RichText::new(format!("{} rows / {} occurrences", groups.len(), occurrences.len()))
221                    .weak(),
222            );
223        });
224        ui.add_space(2.0);
225
226        // --- the tree ---------------------------------------------------------
227        let rows: Vec<RowNode> = groups
228            .iter()
229            .map(|group| self.row_for(state, group))
230            .collect();
231        let specs = bom_columns::column_specs(&self.parsed);
232        let mut root_cells: HashMap<String, Value> = HashMap::new();
233        root_cells.insert(
234            QUANTITY_KEY.to_string(),
235            Value::from(occurrences.len() as u64),
236        );
237        let spec = ColumnTreeSpec {
238            id: "bom",
239            columns: &specs,
240            root_label: Some("Assembly"),
241            root_cells: Some(&root_cells),
242            empty_hint: Some("(no components — insert one via Add new feature)"),
243            hits_prefix: "",
244        };
245        let out = column_tree::column_tree(
246            ui,
247            &spec,
248            &mut self.layout,
249            &rows,
250            Some(&mut self.hits),
251        );
252
253        // --- act on what the widget reported ---------------------------------
254        if out.layout_changed {
255            self.persist_layout(state, store);
256        }
257        if let Some(id) = &out.toggled {
258            if !self.collapsed.remove(id) {
259                self.collapsed.insert(id.clone());
260            }
261        }
262        if let Some(id) = &out.clicked {
263            if let Some(group) = groups.iter().find(|group| group.key == *id) {
264                state.select_components(&group.ids);
265            }
266        }
267        // The row menu. Engine-mutating actions run in the SHARED dispatcher
268        // (one truth, one undo lane, the same one the structure tree's buttons
269        // and the context bar use); the two document-level flows come back as
270        // a request for the shell.
271        let mut acted = false;
272        for click in &out.actions {
273            let Some(group) = groups.iter().find(|group| group.key == click.row_id) else {
274                continue;
275            };
276            let Some(first) = group.ids.first() else {
277                continue;
278            };
279            acted = true;
280            if click.action == EDIT_FEATURE {
281                if let Some(index) = state.history.index_of(first) {
282                    state.roll_to(index);
283                }
284                outcome.focus = Some(first.clone());
285            } else if let Some(action) = ComponentAction::from_id(&click.action) {
286                outcome.component = run_component_action(state, action, first);
287            }
288        }
289        // At most ONE edit lands per frame (egui gives one widget the focus),
290        // and applying it re-runs the history, so take the first and let the
291        // next frame carry any other. An action that just deleted the feature
292        // this edit names would make the write fail loudly, so the action wins
293        // the frame and the edit comes back on the next one.
294        if !acted {
295            if let Some(edit) = out.edits.first() {
296                if edit.column == VISIBLE_KEY {
297                    // Scene state, not a stored attribute: write it straight
298                    // through to every member solid the row stands for.
299                    let visible = edit.value.as_bool().unwrap_or(true);
300                    if let Some(group) = groups.iter().find(|g| g.key == edit.row_id) {
301                        for solid in &group.solids {
302                            state.set_visible(solid, visible);
303                        }
304                    }
305                } else {
306                    self.apply_edit(state, store, &groups, edit);
307                }
308            }
309        }
310
311        // The component oracle the headed verifiers read. Published from the
312        // shared projection rather than from these rows, so it stays engine
313        // truth: `verify_bom_menu` uses it to prove a menu action reached the
314        // engine, and proving that against the BOM's own rendering would be
315        // checking the panel against itself.
316        assembly_components::publish_tree(&component_rows);
317
318        if crate::automation::registry::enabled() {
319            let listing: Vec<Value> = groups
320                .iter()
321                .map(|group| {
322                    serde_json::json!({
323                        "key": group.key,
324                        "partName": group.part_name,
325                        "ids": group.ids,
326                        "quantity": group.ids.len(),
327                    })
328                })
329                .collect();
330            crate::automation::registry::publish("__brepBom", "BOM groups {key, partName, ids, quantity}", &Value::Array(listing).to_string());
331            crate::automation::registry::publish("__brepBomHit", "BOM widget rects (BOM:, cell:)", &self.hits_json());
332        }
333
334        outcome
335    }
336
337    /// Rebuild the column layout when the settings text has changed. Widths
338    /// and sort are session state and survive the rebuild — a re-parse must
339    /// not resize the table under the user's hands.
340    fn sync_columns(&mut self, state: &EngineState) {
341        let text = bom_columns::effective_text(&state.settings.bom_columns);
342        if text == self.layout_source {
343            return;
344        }
345        self.parsed = bom_columns::parse(&text);
346        self.layout = bom_columns::layout_from(&self.parsed, &self.layout);
347        self.layout_source = text;
348    }
349
350    /// Fold a layout the user changed BY DRAGGING back into the settings text,
351    /// so the table and the configuration can never disagree.
352    fn persist_layout(&mut self, state: &mut EngineState, store: &dyn ModelStore) {
353        let columns = bom_columns::columns_from_layout(&self.parsed, &self.layout);
354        let text = bom_columns::serialize(
355            &columns,
356            &self.parsed.preserved,
357            // Dragging a column across the freeze boundary moves the marker,
358            // exactly as dragging one across another moves its line.
359            bom_columns::frozen_from_layout(&self.layout),
360        );
361        let mut settings: Value =
362            serde_json::from_str(&state.settings_json()).unwrap_or(Value::Null);
363        let Some(object) = settings.as_object_mut() else {
364            return;
365        };
366        object.insert("bomColumns".into(), Value::String(text.clone()));
367        let json = settings.to_string();
368        let _ = state.apply_settings_json(&json);
369        let _ = store.write(crate::store::SETTINGS_KEY, &json);
370        // Adopt it as our own source so `sync_columns` does not now rebuild
371        // (and discard) the very layout the user just dragged.
372        self.parsed = bom_columns::parse(&text);
373        self.layout_source = text;
374    }
375
376    /// The occurrence fields a packed row is keyed by: the VISIBLE
377    /// occurrence-scoped columns, in the arrangement's order.
378    ///
379    /// Visible, not every field: a BOM row stands for what the table SHOWS, so
380    /// two placements that differ only in a column nobody is looking at are one
381    /// line. Part-scoped columns are identical across every placement of a part
382    /// by definition, so they cannot split a row and are not consulted; the
383    /// derived quantity is not a field at all.
384    fn packing_fields(&self) -> Vec<String> {
385        self.parsed
386            .columns
387            .iter()
388            .filter(|column| column.scope == Scope::Occurrence)
389            .filter(|column| column.key() != QUANTITY_KEY)
390            .filter(|column| !self.layout.hidden.contains(&column.key()))
391            .map(|column| column.field.clone())
392            .collect()
393    }
394
395    /// Build one widget row for a group: the tree cell, every configured
396    /// column's value, and the read-only nested component rows beneath.
397    fn row_for(&self, state: &EngineState, group: &Group) -> RowNode {
398        let mut cells: HashMap<String, Value> = HashMap::new();
399        let label = if self.packed {
400            group.part_name.clone()
401        } else {
402            format!("{} ({})", group.part_name, group.key)
403        };
404        cells.insert(ITEM_KEY.to_string(), Value::String(label));
405        cells.insert(VISIBLE_KEY.to_string(), Value::Bool(group.visible));
406        cells.insert(FLAGS_KEY.to_string(), Value::Array(badges(group)));
407        // Quantity is DERIVED — the size of the roll-up — and never stored.
408        cells.insert(
409            QUANTITY_KEY.to_string(),
410            Value::from(group.ids.len() as u64),
411        );
412
413        let part_attributes = state.part_attributes(&group.part_name);
414        for column in &self.parsed.columns {
415            let key = column.key();
416            if key == QUANTITY_KEY {
417                continue;
418            }
419            let source = match column.scope {
420                Scope::Part => &part_attributes,
421                Scope::Occurrence => &group.attributes,
422            };
423            if let Some(value) = source.get(&column.field) {
424                cells.insert(key, value.clone());
425            }
426        }
427
428        RowNode {
429            id: group.key.clone(),
430            cells,
431            editable: true,
432            selected: group.selected,
433            expanded: !self.collapsed.contains(&group.key),
434            actions: actions_for(state, group),
435            // Nested components belong to the sub-assembly's own document, so
436            // they show but never take an edit (rigid nesting). Rendered to
437            // FULL depth: a sub-assembly inside a sub-assembly is a real thing
438            // in the model and the list has to be able to show it.
439            children: chain_rows(&group.key, &group.children),
440        }
441    }
442
443    /// Route ONE cell edit to its store. The column's scope decides which:
444    /// occurrence fields fan out across the group's ACOMPs in one undo step,
445    /// part fields go to the part document and then through the shared
446    /// write-through.
447    fn apply_edit(
448        &self,
449        state: &mut EngineState,
450        store: &dyn ModelStore,
451        groups: &[Group],
452        edit: &CellEdit,
453    ) {
454        let Some(group) = groups.iter().find(|group| group.key == edit.row_id) else {
455            return; // a nested sub-assembly row — read-only, nothing to write
456        };
457        let Some(column) = self
458            .parsed
459            .columns
460            .iter()
461            .find(|column| column.key() == edit.column)
462        else {
463            return;
464        };
465        match column.scope {
466            Scope::Occurrence => {
467                // The fan-out: EVERY occurrence the packed row rolls up, as
468                // ONE undo step.
469                if let Err(error) =
470                    state.set_occurrence_attribute(&group.ids, &column.field, edit.value.clone())
471                {
472                    state.push_notice(format!("BOM: {error}"));
473                }
474            }
475            Scope::Part => {
476                // The part's `(sourceKey, signature-as-inserted)` must be read
477                // BEFORE the edit re-stamps the signature — that pair is what
478                // the write-through compares the file against.
479                let target = state.part_source(&group.part_name).and_then(|(key, sig)| {
480                    (!key.is_empty()).then_some((key, sig))
481                });
482                if let Err(error) =
483                    state.set_part_attribute(&group.part_name, &column.field, edit.value.clone())
484                {
485                    state.push_notice(format!("BOM: {error}"));
486                    return;
487                }
488                // The part document that just changed is saved back to the file
489                // it came from, through the shared write-through lane, so the
490                // entry's signature and the file agree.
491                if let Some(document) = state.part_document_json(&group.part_name) {
492                    parts_library::write_through(
493                        state,
494                        store,
495                        &group.part_name,
496                        target.as_ref(),
497                        &document,
498                    );
499                }
500            }
501        }
502    }
503
504    /// The published widget hit-rects for the headed verifier.
505    pub fn hits_json(&self) -> String {
506        crate::automation::hit_rects::hits_json(&self.hits)
507    }
508}
509
510/// The row menu for one group: "Edit feature" (this panel's own) then the
511/// SHARED component actions in bar order, each refused-with-a-reason where this
512/// row cannot honour it.
513///
514/// The per-INSTANCE actions (Move, Fix/Unfix, Delete) are refused on a PACKED
515/// row that rolls up more than one placement: acting on "the first" of four is
516/// a trap, and fanning Delete or Fix out across the group would be N undo steps
517/// where every other BOM edit is one. The part-level flows (Edit in place, Open
518/// Part) mean the same thing for every placement, so they stay live; and
519/// "Edit feature" only rolls the history, which is what the ✎ button it
520/// replaced always did.
521fn actions_for(state: &EngineState, group: &Group) -> Vec<RowAction> {
522    let Some(first) = group.ids.first() else {
523        return Vec::new();
524    };
525    let fixed = state
526        .component_info(first)
527        .map(|info| info.fixed)
528        .unwrap_or(false);
529    let rolled_up = group.ids.len() > 1;
530    let unpack = |verb: &str| {
531        format!(
532            "{} placements on this row — switch to Unpacked to {verb} one",
533            group.ids.len()
534        )
535    };
536    // An embedded-only part (no `sourceKey`) has no document to open.
537    let embedded = !state
538        .part_source(&group.part_name)
539        .is_some_and(|(key, _)| !key.is_empty());
540
541    let mut actions = vec![RowAction::new(EDIT_FEATURE, "\u{270E} Edit feature")
542        .tooltip("Roll to this component's feature and open it in the history")];
543    for action in ComponentAction::ALL {
544        let entry = RowAction::new(action.id(), action.label(fixed)).tooltip(action.tooltip());
545        let entry = match action {
546            ComponentAction::Move if fixed => {
547                entry.disabled("This component is fixed — unfix it before moving it")
548            }
549            ComponentAction::Move if rolled_up => entry.disabled(unpack("move")),
550            ComponentAction::ToggleFixed if rolled_up => entry.disabled(unpack("fix or unfix")),
551            ComponentAction::Delete if rolled_up => entry.disabled(unpack("delete")),
552            ComponentAction::OpenPart if embedded => {
553                entry.disabled("This part is embedded in the assembly — it has no source document")
554            }
555            _ => entry,
556        };
557        actions.push(match action {
558            // The destructive tail, fenced off from the rest.
559            ComponentAction::Delete => entry.separator_above().destructive(),
560            _ => entry,
561        });
562    }
563    actions
564}
565
566/// Every key the tree can place in `collapsed`: each group row that has nested
567/// components, and every nested chain node beneath it that has children of its
568/// own. Collapse-all writes exactly this set.
569fn collapsible_keys(groups: &[Group]) -> HashSet<String> {
570    /// Does this node own a nested COMPONENT anywhere below it? Bodies do not
571    /// count — they are not drawn, so a node holding only bodies has nothing to
572    /// collapse and must not claim a key.
573    fn owns_component(nodes: &[ChainNode]) -> bool {
574        nodes
575            .iter()
576            .any(|node| assembly_components::is_acomp_segment(&node.label))
577    }
578    fn walk(parent: &str, nodes: &[ChainNode], out: &mut HashSet<String>) {
579        for node in nodes
580            .iter()
581            .filter(|node| assembly_components::is_acomp_segment(&node.label))
582        {
583            let id = format!("{parent}:{}", node.label);
584            if owns_component(&node.children) {
585                out.insert(id.clone());
586            }
587            walk(&id, &node.children, out);
588        }
589    }
590    let mut out = HashSet::new();
591    for group in groups {
592        if owns_component(&group.children) {
593            out.insert(group.key.clone());
594        }
595        walk(&group.key, &group.children, &mut out);
596    }
597    out
598}
599
600/// The row's status glyphs: grounded, outdated, and the worst constraint
601/// status referencing it. Colour carries the meaning for the last two, which is
602/// why these are badges rather than text.
603fn badges(group: &Group) -> Vec<Value> {
604    let mut out = Vec::new();
605    if group.fixed {
606        out.push(serde_json::json!({
607            "glyph": assembly_components::FIXED_GLYPH,
608            "tooltip": "Grounded — unfix it before moving it",
609        }));
610    }
611    if group.outdated {
612        out.push(serde_json::json!({
613            "glyph": assembly_components::OUTDATED_GLYPH,
614            "color": color_hex(assembly_components::OUTDATED_AMBER),
615            "tooltip": "The source part has changed since this was inserted",
616        }));
617    }
618    if let Some(status) = &group.status {
619        out.push(serde_json::json!({
620            "glyph": "\u{25CF}",
621            "color": brep_render::assembly_status::status_color_hex(status),
622            "tooltip": format!("Constraint status: {status}"),
623        }));
624    }
625    out
626}
627
628/// `Color32` → the `#rrggbb` the widget's badge cell parses. (Constraint
629/// statuses have their own [`brep_render::assembly_status::status_color_hex`];
630/// this is for the badge colours the app owns.)
631fn color_hex(color: egui::Color32) -> String {
632    crate::color::rgb_to_hex([color.r(), color.g(), color.b()])
633}
634
635/// Nested COMPONENT rows for one group, to full depth. Read-only throughout:
636/// these belong to the sub-assembly's own document, so they carry no cells the
637/// BOM may edit and offer no actions in THIS document.
638///
639/// Only `ACOMP<n>` nodes appear. A BOM lists PARTS and the sub-assemblies a
640/// part contains — the bodies inside a part are that part's internals and live
641/// on the Scene tree, not here. Filtering recursively also means a part whose
642/// chain holds nothing but bodies ends up with no children at all, so the
643/// widget draws no collapse box on a row with nothing behind it.
644fn chain_rows(parent: &str, nodes: &[ChainNode]) -> Vec<RowNode> {
645    nodes
646        .iter()
647        .filter(|node| assembly_components::is_acomp_segment(&node.label))
648        .map(|node| {
649            let id = format!("{parent}:{}", node.label);
650            let mut cells = HashMap::new();
651            cells.insert(ITEM_KEY.to_string(), Value::String(node.label.clone()));
652            RowNode {
653                children: chain_rows(&id, &node.children),
654                id,
655                cells,
656                editable: false,
657                selected: false,
658                expanded: false,
659                actions: Vec::new(),
660            }
661        })
662        .collect()
663}
664
665/// Is `candidate` a worse constraint status than `current`? Uses the ONE status
666/// map's severity ordering, so a rolled-up row shows the worst of what it
667/// stands for rather than whichever placement happened to be first.
668fn worse_status(current: Option<&str>, candidate: Option<&str>) -> bool {
669    let Some(candidate) = candidate else {
670        return false;
671    };
672    match current {
673        None => true,
674        Some(current) => {
675            brep_render::assembly_status::status_severity(candidate)
676                > brep_render::assembly_status::status_severity(current)
677        }
678    }
679}
680
681/// One BOM row's occurrences: the whole group in the packed view, exactly one
682/// in the unpacked view.
683struct Group {
684    /// The row id. In the packed view this is a synthetic group key; in the
685    /// unpacked view it is the ACOMP id itself.
686    key: String,
687    part_name: String,
688    /// Every ACOMP this row stands for — what a packed edit fans out across.
689    ids: Vec<String>,
690    /// The occurrence attributes shared by the whole group (identical by
691    /// construction — that is what made them one group).
692    attributes: Value,
693    selected: bool,
694    /// Rolled up across the group: grounded only when EVERY placement is.
695    fixed: bool,
696    outdated: bool,
697    /// Worst status across the group's placements.
698    status: Option<String>,
699    /// Visible only when EVERY member solid of every placement is.
700    visible: bool,
701    /// Every member solid the row stands for — what the toggle writes to.
702    solids: Vec<String>,
703    children: Vec<ChainNode>,
704}
705
706/// Flatten the engine's component projection into occurrences.
707///
708/// The per-component truth (fixed, outdated, constraint-status rollup,
709/// visibility, the nested chain) comes from the SHARED projection in
710/// [`assembly_components`] — the same rows the headed verifiers read as
711/// `__brepAssemblyTree`. The BOM adds only what is its own: the attribute
712/// records it edits.
713fn occurrences_from(state: &mut EngineState, rows: &[ComponentRow]) -> Vec<Occurrence> {
714    rows.iter()
715        .map(|row| Occurrence {
716            attributes: state.occurrence_attributes(&row.id),
717            selected: row.selected,
718            fixed: row.fixed,
719            outdated: row.outdated,
720            status: row.rollup_status.clone(),
721            visible: row.visible,
722            solids: row.solids.clone(),
723            children: row.children.clone(),
724            part_name: row.part_name.clone(),
725            id: row.id.clone(),
726        })
727        .collect()
728}
729
730/// Group occurrences into BOM rows.
731///
732/// PACKED rolls up by `(part name, EVERY occurrence field)` — the owner's rule:
733/// occurrences that differ in ANY occurrence field stay separate rows, because
734/// a rolled-up row would have to show one of two different values and an edit
735/// to it would silently overwrite the other. UNPACKED is one row each.
736///
737/// Group order follows first appearance, which is the engine's deterministic
738/// id order, so the table is stable frame to frame before any sort.
739fn group(occurrences: &[Occurrence], packed: bool, fields: &[String]) -> Vec<Group> {
740    if !packed {
741        return occurrences
742            .iter()
743            .map(|occurrence| Group {
744                key: occurrence.id.clone(),
745                part_name: occurrence.part_name.clone(),
746                ids: vec![occurrence.id.clone()],
747                attributes: occurrence.attributes.clone(),
748                selected: occurrence.selected,
749                fixed: occurrence.fixed,
750                outdated: occurrence.outdated,
751                status: occurrence.status.clone(),
752                visible: occurrence.visible,
753                solids: occurrence.solids.clone(),
754                children: occurrence.children.clone(),
755            })
756            .collect();
757    }
758    let mut order: Vec<String> = Vec::new();
759    let mut groups: HashMap<String, Group> = HashMap::new();
760    for occurrence in occurrences {
761        let key = format!(
762            "{}\u{1}{}",
763            occurrence.part_name,
764            canonical_over(&occurrence.attributes, fields)
765        );
766        match groups.get_mut(&key) {
767            Some(group) => {
768                group.ids.push(occurrence.id.clone());
769                group.selected |= occurrence.selected;
770                // A rolled-up row states what is true of EVERY placement it
771                // stands for: grounded only if all are, visible only if all
772                // are. Anything else would let one row claim a state a
773                // placement behind it does not have.
774                group.fixed &= occurrence.fixed;
775                group.visible &= occurrence.visible;
776                group.outdated |= occurrence.outdated;
777                group.solids.extend(occurrence.solids.iter().cloned());
778                if worse_status(group.status.as_deref(), occurrence.status.as_deref()) {
779                    group.status = occurrence.status.clone();
780                }
781                for child in &occurrence.children {
782                    if !group.children.iter().any(|kept| kept == child) {
783                        group.children.push(child.clone());
784                    }
785                }
786            }
787            None => {
788                order.push(key.clone());
789                groups.insert(
790                    key,
791                    Group {
792                        key: String::new(), // filled below, from the group order
793                        part_name: occurrence.part_name.clone(),
794                        ids: vec![occurrence.id.clone()],
795                        attributes: occurrence.attributes.clone(),
796                        selected: occurrence.selected,
797                        fixed: occurrence.fixed,
798                        outdated: occurrence.outdated,
799                        status: occurrence.status.clone(),
800                        visible: occurrence.visible,
801                        solids: occurrence.solids.clone(),
802                        children: occurrence.children.clone(),
803                    },
804                );
805            }
806        }
807    }
808    order
809        .into_iter()
810        .filter_map(|key| groups.remove(&key))
811        .map(|mut group| {
812            // The row id must be STABLE across frames (it keys collapse state
813            // and every out-value) but must not be a raw attribute dump. The
814            // first ACOMP of the group is both — deterministic, because the
815            // projection is in id order.
816            group.key = format!(
817                "pack:{}",
818                group.ids.first().cloned().unwrap_or_default()
819            );
820            group
821        })
822        .collect()
823}
824
825/// The packing key's value half: the named fields, in the given order, with a
826/// missing field spelled explicitly. Order comes from the column arrangement
827/// rather than the record, so two placements whose attributes were WRITTEN in a
828/// different order still key the same. (serde_json runs with `preserve_order`
829/// in this workspace, so a naive `to_string` of the record would not.)
830fn canonical_over(attributes: &Value, fields: &[String]) -> String {
831    fields
832        .iter()
833        .map(|field| {
834            let value = attributes
835                .get(field)
836                .map(Value::to_string)
837                .unwrap_or_default();
838            format!("{field}={value}")
839        })
840        .collect::<Vec<_>>()
841        .join("\u{2}")
842}
843
844// BREP private tests: 3c26b46ba10327e2
845
846/// The hit keys this panel publishes (see `automation::hit_keys`).
847pub static HIT_KEYS: &[HitKeyDoc] = &[
848    HitKeyDoc { panel: "bom", prefix: "bom:expand-all", meaning: "expand every group", command: None },
849    HitKeyDoc { panel: "bom", prefix: "bom:collapse-all", meaning: "collapse every group", command: None },
850    HitKeyDoc { panel: "bom", prefix: "bom:packed", meaning: "packed view", command: None },
851    HitKeyDoc { panel: "bom", prefix: "bom:unpacked", meaning: "unpacked view", command: None },
852    HitKeyDoc { panel: "bom", prefix: "bom:panel:clip", meaning: "the visible region of the pane", command: None },
853    HitKeyDoc { panel: "bom", prefix: "BOM:", meaning: "a row action", command: None },
854    HitKeyDoc { panel: "bom", prefix: "cell:", meaning: "a table cell (cell:row:column)", command: Some("bom_set_occurrence_attribute") },
855    HitKeyDoc { panel: "bom", prefix: "row:", meaning: "a structure-tree row (row:node key)", command: Some("component_select") },
856    HitKeyDoc { panel: "bom", prefix: "box:", meaning: "a structure-tree row's expander (box:node key)", command: None },
857    HitKeyDoc { panel: "bom", prefix: "col:", meaning: "a table column header (col:field) — click to sort", command: None },
858    HitKeyDoc { panel: "bom", prefix: "grip:", meaning: "a column's resize grip (grip:field)", command: None },
859    HitKeyDoc { panel: "bom", prefix: "freeze:divider", meaning: "the frozen-column divider", command: None },
860];