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::column_tree::{self, CellEdit, ColumnLayout, ColumnTreeSpec, RowAction, RowNode};
61use crate::panels::parts_library;
62use crate::panels::component_actions::{
63    run_component_action, ComponentAction, ComponentActionRequest,
64};
65use crate::panels::assembly_components::{self, ChainNode, ComponentRow};
66use crate::panels::update_components::UpdateComponents;
67use crate::panels::bom_columns::{
68    self, ParsedColumns, Scope, FLAGS_KEY, ITEM_KEY, QUANTITY_KEY, VISIBLE_KEY,
69};
70use crate::store::ModelStore;
71use brep_render::engine_state::EngineState;
72use eframe::egui;
73use serde_json::Value;
74use std::collections::{BTreeMap, HashMap, HashSet};
75
76/// The row menu's own entry: roll to the component's feature and open it in
77/// the history tree. Not a [`ComponentAction`] — the shared set is the
78/// COMPONENT vocabulary (spec §8.5) and the context bar draws a button per
79/// member of it, so a document-navigation entry does not belong in there. The
80/// assembly structure tree keeps this action locally for the same reason.
81const EDIT_FEATURE: &str = "edit-feature";
82
83/// What a BOM frame hands back to the shell.
84#[derive(Default)]
85pub struct BomOutcome {
86    /// A feature id to roll to + expand in the history tree (the row menu's
87    /// "Edit feature") — the structure panel's `focus` contract, verbatim, so
88    /// the shell routes both the same way.
89    pub focus: Option<String>,
90    /// A document-level flow the SHELL owns (Edit Part → open the part's own
91    /// document tab), handed
92    /// back by the shared component-action dispatcher exactly as the selection
93    /// context bar hands it back.
94    pub component: Option<ComponentActionRequest>,
95}
96
97/// One occurrence, flattened out of the engine's projection.
98#[derive(Clone)]
99struct Occurrence {
100    /// The owning ACOMP feature id.
101    id: String,
102    part_name: String,
103    /// This occurrence's own attribute record.
104    attributes: Value,
105    selected: bool,
106    /// Grounded (the ⏚ badge, and what refuses Move).
107    fixed: bool,
108    /// The library entry no longer matches its store source (the ↻ badge).
109    outdated: bool,
110    /// Worst constraint status referencing this component, if any.
111    status: Option<String>,
112    /// Every member solid currently visible.
113    visible: bool,
114    /// Member scene names, for the visibility toggle.
115    solids: Vec<String>,
116    /// Read-only nested component rows, from the member name chains. FULL
117    /// depth: a sub-assembly inside a sub-assembly renders as such.
118    children: Vec<ChainNode>,
119}
120
121/// The BOM panel's transient UI state. The data lives in the document; the
122/// column arrangement lives in the settings text; this holds only what is true
123/// for this session.
124pub struct BomPanel {
125    hits: HashMap<String, egui::Rect>,
126    /// The widget's live column arrangement. Rebuilt from the settings text
127    /// whenever that text changes, keeping session-only widths + sort.
128    layout: ColumnLayout,
129    /// The settings text `layout` was built from — the change detector.
130    layout_source: String,
131    /// The parsed configuration for `layout_source`.
132    parsed: ParsedColumns,
133    /// Packed (one row per distinct part + occurrence data) or unpacked (one
134    /// row per placement).
135    packed: bool,
136    /// Rows explicitly collapsed, by row id (absent = open).
137    collapsed: HashSet<String>,
138}
139
140impl Default for BomPanel {
141    fn default() -> Self {
142        Self::new()
143    }
144}
145
146impl BomPanel {
147    pub fn new() -> Self {
148        Self {
149            hits: HashMap::new(),
150            layout: ColumnLayout::default(),
151            layout_source: String::new(),
152            parsed: ParsedColumns::default(),
153            // Packed is the BOM a person asks for: a parts list, not a
154            // placement list.
155            packed: true,
156            collapsed: HashSet::new(),
157        }
158    }
159
160    /// Draw the BOM. Snapshots the projection, draws the column tree, then
161    /// applies at most one deferred engine mutation — the shared panel
162    /// pattern, and the reason the draw can borrow `state` immutably.
163    pub fn show(
164        &mut self,
165        ui: &mut egui::Ui,
166        state: &mut EngineState,
167        store: &dyn ModelStore,
168        updates: &UpdateComponents,
169    ) -> BomOutcome {
170        self.hits.clear();
171        // What is actually VISIBLE of this pane. Every other rect below is a
172        // raw LAYOUT rect, so a widget scrolled past the pane's edge is still
173        // published while being unclickable — a headed verifier has to scroll
174        // it into this rect first. (The constraints panel publishes
175        // `acon:panel:clip` for exactly the same reason.)
176        self.hits.insert("bom:panel:clip".into(), ui.clip_rect());
177        let mut outcome = BomOutcome::default();
178        state.ensure_assembly_synced();
179
180        self.sync_columns(state);
181        let component_rows = assembly_components::snapshot(state, updates);
182        let occurrences = occurrences_from(state, &component_rows);
183        let groups = group(&occurrences, self.packed, &self.packing_fields());
184
185        // --- header: the packed/unpacked switch ------------------------------
186        ui.horizontal(|ui| {
187            let packed = ui
188                .selectable_label(self.packed, "Packed")
189                .on_hover_text("One row per part, rolled up where every occurrence field matches");
190            self.hits.insert("bom:packed".into(), packed.rect);
191            if packed.clicked() {
192                self.packed = true;
193            }
194            let unpacked = ui
195                .selectable_label(!self.packed, "Unpacked")
196                .on_hover_text("One row per individual instance");
197            self.hits.insert("bom:unpacked".into(), unpacked.rect);
198            if unpacked.clicked() {
199                self.packed = false;
200            }
201            let expand = ui
202                .button("Expand all")
203                .on_hover_text("Expand every row with nested components");
204            self.hits.insert("bom:expand-all".into(), expand.rect);
205            if expand.clicked() {
206                self.collapsed.clear();
207            }
208            let collapse = ui
209                .button("Collapse all")
210                .on_hover_text("Collapse every row with nested components");
211            self.hits.insert("bom:collapse-all".into(), collapse.rect);
212            if collapse.clicked() {
213                // Every key the tree can hold: the group rows and, beneath
214                // them, every nested chain node — collapse-all has to fold the
215                // WHOLE tree, at every depth, with no key drift.
216                self.collapsed = collapsible_keys(&groups);
217            }
218            ui.label(
219                egui::RichText::new(format!("{} rows / {} occurrences", groups.len(), occurrences.len()))
220                    .weak(),
221            );
222        });
223        ui.add_space(2.0);
224
225        // --- the tree ---------------------------------------------------------
226        let rows: Vec<RowNode> = groups
227            .iter()
228            .map(|group| self.row_for(state, group))
229            .collect();
230        let specs = bom_columns::column_specs(&self.parsed);
231        let mut root_cells: HashMap<String, Value> = HashMap::new();
232        root_cells.insert(
233            QUANTITY_KEY.to_string(),
234            Value::from(occurrences.len() as u64),
235        );
236        let spec = ColumnTreeSpec {
237            id: "bom",
238            columns: &specs,
239            root_label: Some("Assembly"),
240            root_cells: Some(&root_cells),
241            empty_hint: Some("(no components — insert one via Add new feature)"),
242            hits_prefix: "",
243        };
244        let out = column_tree::column_tree(
245            ui,
246            &spec,
247            &mut self.layout,
248            &rows,
249            Some(&mut self.hits),
250        );
251
252        // --- act on what the widget reported ---------------------------------
253        if out.layout_changed {
254            self.persist_layout(state, store);
255        }
256        if let Some(id) = &out.toggled {
257            if !self.collapsed.remove(id) {
258                self.collapsed.insert(id.clone());
259            }
260        }
261        if let Some(id) = &out.clicked {
262            if let Some(group) = groups.iter().find(|group| group.key == *id) {
263                state.select_components(&group.ids);
264            }
265        }
266        // The row menu. Engine-mutating actions run in the SHARED dispatcher
267        // (one truth, one undo lane, the same one the structure tree's buttons
268        // and the context bar use); the two document-level flows come back as
269        // a request for the shell.
270        let mut acted = false;
271        for click in &out.actions {
272            let Some(group) = groups.iter().find(|group| group.key == click.row_id) else {
273                continue;
274            };
275            let Some(first) = group.ids.first() else {
276                continue;
277            };
278            acted = true;
279            if click.action == EDIT_FEATURE {
280                if let Some(index) = state.history.index_of(first) {
281                    state.roll_to(index);
282                }
283                outcome.focus = Some(first.clone());
284            } else if let Some(action) = ComponentAction::from_id(&click.action) {
285                outcome.component = run_component_action(state, action, first);
286            }
287        }
288        // At most ONE edit lands per frame (egui gives one widget the focus),
289        // and applying it re-runs the history, so take the first and let the
290        // next frame carry any other. An action that just deleted the feature
291        // this edit names would make the write fail loudly, so the action wins
292        // the frame and the edit comes back on the next one.
293        if !acted {
294            if let Some(edit) = out.edits.first() {
295                if edit.column == VISIBLE_KEY {
296                    // Scene state, not a stored attribute: write it straight
297                    // through to every member solid the row stands for.
298                    let visible = edit.value.as_bool().unwrap_or(true);
299                    if let Some(group) = groups.iter().find(|g| g.key == edit.row_id) {
300                        for solid in &group.solids {
301                            state.set_visible(solid, visible);
302                        }
303                    }
304                } else {
305                    self.apply_edit(state, store, &groups, edit);
306                }
307            }
308        }
309
310        // The component oracle the headed verifiers read. Published from the
311        // shared projection rather than from these rows, so it stays engine
312        // truth: `verify_bom_menu` uses it to prove a menu action reached the
313        // engine, and proving that against the BOM's own rendering would be
314        // checking the panel against itself.
315        assembly_components::publish_tree(&component_rows);
316
317        #[cfg(target_arch = "wasm32")]
318        {
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            publish("__brepBom", &Value::Array(listing).to_string());
331            publish("__brepBomHit", &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    #[cfg(target_arch = "wasm32")]
506    pub fn hits_json(&self) -> String {
507        let map: serde_json::Map<String, Value> = self
508            .hits
509            .iter()
510            .map(|(key, rect)| {
511                (
512                    key.clone(),
513                    serde_json::json!([rect.min.x, rect.min.y, rect.width(), rect.height()]),
514                )
515            })
516            .collect();
517        Value::Object(map).to_string()
518    }
519}
520
521/// The row menu for one group: "Edit feature" (this panel's own) then the
522/// SHARED component actions in bar order, each refused-with-a-reason where this
523/// row cannot honour it.
524///
525/// The per-INSTANCE actions (Move, Fix/Unfix, Delete) are refused on a PACKED
526/// row that rolls up more than one placement: acting on "the first" of four is
527/// a trap, and fanning Delete or Fix out across the group would be N undo steps
528/// where every other BOM edit is one. The part-level flows (Edit in place, Open
529/// Part) mean the same thing for every placement, so they stay live; and
530/// "Edit feature" only rolls the history, which is what the ✎ button it
531/// replaced always did.
532fn actions_for(state: &EngineState, group: &Group) -> Vec<RowAction> {
533    let Some(first) = group.ids.first() else {
534        return Vec::new();
535    };
536    let fixed = state
537        .component_info(first)
538        .map(|info| info.fixed)
539        .unwrap_or(false);
540    let rolled_up = group.ids.len() > 1;
541    let unpack = |verb: &str| {
542        format!(
543            "{} placements on this row — switch to Unpacked to {verb} one",
544            group.ids.len()
545        )
546    };
547    // An embedded-only part (no `sourceKey`) has no document to open.
548    let embedded = !state
549        .part_source(&group.part_name)
550        .is_some_and(|(key, _)| !key.is_empty());
551
552    let mut actions = vec![RowAction::new(EDIT_FEATURE, "\u{270E} Edit feature")
553        .tooltip("Roll to this component's feature and open it in the history")];
554    for action in ComponentAction::ALL {
555        let entry = RowAction::new(action.id(), action.label(fixed)).tooltip(action.tooltip());
556        let entry = match action {
557            ComponentAction::Move if fixed => {
558                entry.disabled("This component is fixed — unfix it before moving it")
559            }
560            ComponentAction::Move if rolled_up => entry.disabled(unpack("move")),
561            ComponentAction::ToggleFixed if rolled_up => entry.disabled(unpack("fix or unfix")),
562            ComponentAction::Delete if rolled_up => entry.disabled(unpack("delete")),
563            ComponentAction::OpenPart if embedded => {
564                entry.disabled("This part is embedded in the assembly — it has no source document")
565            }
566            _ => entry,
567        };
568        actions.push(match action {
569            // The destructive tail, fenced off from the rest.
570            ComponentAction::Delete => entry.separator_above().destructive(),
571            _ => entry,
572        });
573    }
574    actions
575}
576
577/// Every key the tree can place in `collapsed`: each group row that has nested
578/// components, and every nested chain node beneath it that has children of its
579/// own. Collapse-all writes exactly this set.
580fn collapsible_keys(groups: &[Group]) -> HashSet<String> {
581    /// Does this node own a nested COMPONENT anywhere below it? Bodies do not
582    /// count — they are not drawn, so a node holding only bodies has nothing to
583    /// collapse and must not claim a key.
584    fn owns_component(nodes: &[ChainNode]) -> bool {
585        nodes
586            .iter()
587            .any(|node| assembly_components::is_acomp_segment(&node.label))
588    }
589    fn walk(parent: &str, nodes: &[ChainNode], out: &mut HashSet<String>) {
590        for node in nodes
591            .iter()
592            .filter(|node| assembly_components::is_acomp_segment(&node.label))
593        {
594            let id = format!("{parent}:{}", node.label);
595            if owns_component(&node.children) {
596                out.insert(id.clone());
597            }
598            walk(&id, &node.children, out);
599        }
600    }
601    let mut out = HashSet::new();
602    for group in groups {
603        if owns_component(&group.children) {
604            out.insert(group.key.clone());
605        }
606        walk(&group.key, &group.children, &mut out);
607    }
608    out
609}
610
611/// The row's status glyphs: grounded, outdated, and the worst constraint
612/// status referencing it. Colour carries the meaning for the last two, which is
613/// why these are badges rather than text.
614fn badges(group: &Group) -> Vec<Value> {
615    let mut out = Vec::new();
616    if group.fixed {
617        out.push(serde_json::json!({
618            "glyph": assembly_components::FIXED_GLYPH,
619            "tooltip": "Grounded — unfix it before moving it",
620        }));
621    }
622    if group.outdated {
623        out.push(serde_json::json!({
624            "glyph": assembly_components::OUTDATED_GLYPH,
625            "color": color_hex(assembly_components::OUTDATED_AMBER),
626            "tooltip": "The source part has changed since this was inserted",
627        }));
628    }
629    if let Some(status) = &group.status {
630        out.push(serde_json::json!({
631            "glyph": "\u{25CF}",
632            "color": brep_render::assembly_status::status_color_hex(status),
633            "tooltip": format!("Constraint status: {status}"),
634        }));
635    }
636    out
637}
638
639/// `Color32` → the `#rrggbb` the widget's badge cell parses. (Constraint
640/// statuses have their own [`brep_render::assembly_status::status_color_hex`];
641/// this is for the badge colours the app owns.)
642fn color_hex(color: egui::Color32) -> String {
643    format!("#{:02x}{:02x}{:02x}", color.r(), color.g(), color.b())
644}
645
646/// Nested COMPONENT rows for one group, to full depth. Read-only throughout:
647/// these belong to the sub-assembly's own document, so they carry no cells the
648/// BOM may edit and offer no actions in THIS document.
649///
650/// Only `ACOMP<n>` nodes appear. A BOM lists PARTS and the sub-assemblies a
651/// part contains — the bodies inside a part are that part's internals and live
652/// on the Scene tree, not here. Filtering recursively also means a part whose
653/// chain holds nothing but bodies ends up with no children at all, so the
654/// widget draws no collapse box on a row with nothing behind it.
655fn chain_rows(parent: &str, nodes: &[ChainNode]) -> Vec<RowNode> {
656    nodes
657        .iter()
658        .filter(|node| assembly_components::is_acomp_segment(&node.label))
659        .map(|node| {
660            let id = format!("{parent}:{}", node.label);
661            let mut cells = HashMap::new();
662            cells.insert(ITEM_KEY.to_string(), Value::String(node.label.clone()));
663            RowNode {
664                children: chain_rows(&id, &node.children),
665                id,
666                cells,
667                editable: false,
668                selected: false,
669                expanded: false,
670                actions: Vec::new(),
671            }
672        })
673        .collect()
674}
675
676/// Is `candidate` a worse constraint status than `current`? Uses the ONE status
677/// map's severity ordering, so a rolled-up row shows the worst of what it
678/// stands for rather than whichever placement happened to be first.
679fn worse_status(current: Option<&str>, candidate: Option<&str>) -> bool {
680    let Some(candidate) = candidate else {
681        return false;
682    };
683    match current {
684        None => true,
685        Some(current) => {
686            brep_render::assembly_status::status_severity(candidate)
687                > brep_render::assembly_status::status_severity(current)
688        }
689    }
690}
691
692/// One BOM row's occurrences: the whole group in the packed view, exactly one
693/// in the unpacked view.
694struct Group {
695    /// The row id. In the packed view this is a synthetic group key; in the
696    /// unpacked view it is the ACOMP id itself.
697    key: String,
698    part_name: String,
699    /// Every ACOMP this row stands for — what a packed edit fans out across.
700    ids: Vec<String>,
701    /// The occurrence attributes shared by the whole group (identical by
702    /// construction — that is what made them one group).
703    attributes: Value,
704    selected: bool,
705    /// Rolled up across the group: grounded only when EVERY placement is.
706    fixed: bool,
707    outdated: bool,
708    /// Worst status across the group's placements.
709    status: Option<String>,
710    /// Visible only when EVERY member solid of every placement is.
711    visible: bool,
712    /// Every member solid the row stands for — what the toggle writes to.
713    solids: Vec<String>,
714    children: Vec<ChainNode>,
715}
716
717/// Flatten the engine's component projection into occurrences.
718///
719/// The per-component truth (fixed, outdated, constraint-status rollup,
720/// visibility, the nested chain) comes from the SHARED projection in
721/// [`assembly_components`] — the same rows the headed verifiers read as
722/// `__brepAssemblyTree`. The BOM adds only what is its own: the attribute
723/// records it edits.
724fn occurrences_from(state: &mut EngineState, rows: &[ComponentRow]) -> Vec<Occurrence> {
725    rows.iter()
726        .map(|row| Occurrence {
727            attributes: state.occurrence_attributes(&row.id),
728            selected: row.selected,
729            fixed: row.fixed,
730            outdated: row.outdated,
731            status: row.rollup_status.clone(),
732            visible: row.visible,
733            solids: row.solids.clone(),
734            children: row.children.clone(),
735            part_name: row.part_name.clone(),
736            id: row.id.clone(),
737        })
738        .collect()
739}
740
741/// Group occurrences into BOM rows.
742///
743/// PACKED rolls up by `(part name, EVERY occurrence field)` — the owner's rule:
744/// occurrences that differ in ANY occurrence field stay separate rows, because
745/// a rolled-up row would have to show one of two different values and an edit
746/// to it would silently overwrite the other. UNPACKED is one row each.
747///
748/// Group order follows first appearance, which is the engine's deterministic
749/// id order, so the table is stable frame to frame before any sort.
750fn group(occurrences: &[Occurrence], packed: bool, fields: &[String]) -> Vec<Group> {
751    if !packed {
752        return occurrences
753            .iter()
754            .map(|occurrence| Group {
755                key: occurrence.id.clone(),
756                part_name: occurrence.part_name.clone(),
757                ids: vec![occurrence.id.clone()],
758                attributes: occurrence.attributes.clone(),
759                selected: occurrence.selected,
760                fixed: occurrence.fixed,
761                outdated: occurrence.outdated,
762                status: occurrence.status.clone(),
763                visible: occurrence.visible,
764                solids: occurrence.solids.clone(),
765                children: occurrence.children.clone(),
766            })
767            .collect();
768    }
769    let mut order: Vec<String> = Vec::new();
770    let mut groups: HashMap<String, Group> = HashMap::new();
771    for occurrence in occurrences {
772        let key = format!(
773            "{}\u{1}{}",
774            occurrence.part_name,
775            canonical_over(&occurrence.attributes, fields)
776        );
777        match groups.get_mut(&key) {
778            Some(group) => {
779                group.ids.push(occurrence.id.clone());
780                group.selected |= occurrence.selected;
781                // A rolled-up row states what is true of EVERY placement it
782                // stands for: grounded only if all are, visible only if all
783                // are. Anything else would let one row claim a state a
784                // placement behind it does not have.
785                group.fixed &= occurrence.fixed;
786                group.visible &= occurrence.visible;
787                group.outdated |= occurrence.outdated;
788                group.solids.extend(occurrence.solids.iter().cloned());
789                if worse_status(group.status.as_deref(), occurrence.status.as_deref()) {
790                    group.status = occurrence.status.clone();
791                }
792                for child in &occurrence.children {
793                    if !group.children.iter().any(|kept| kept == child) {
794                        group.children.push(child.clone());
795                    }
796                }
797            }
798            None => {
799                order.push(key.clone());
800                groups.insert(
801                    key,
802                    Group {
803                        key: String::new(), // filled below, from the group order
804                        part_name: occurrence.part_name.clone(),
805                        ids: vec![occurrence.id.clone()],
806                        attributes: occurrence.attributes.clone(),
807                        selected: occurrence.selected,
808                        fixed: occurrence.fixed,
809                        outdated: occurrence.outdated,
810                        status: occurrence.status.clone(),
811                        visible: occurrence.visible,
812                        solids: occurrence.solids.clone(),
813                        children: occurrence.children.clone(),
814                    },
815                );
816            }
817        }
818    }
819    order
820        .into_iter()
821        .filter_map(|key| groups.remove(&key))
822        .map(|mut group| {
823            // The row id must be STABLE across frames (it keys collapse state
824            // and every out-value) but must not be a raw attribute dump. The
825            // first ACOMP of the group is both — deterministic, because the
826            // projection is in id order.
827            group.key = format!(
828                "pack:{}",
829                group.ids.first().cloned().unwrap_or_default()
830            );
831            group
832        })
833        .collect()
834}
835
836/// The packing key's value half: the named fields, in the given order, with a
837/// missing field spelled explicitly. Order comes from the column arrangement
838/// rather than the record, so two placements whose attributes were WRITTEN in a
839/// different order still key the same. (serde_json runs with `preserve_order`
840/// in this workspace, so a naive `to_string` of the record would not.)
841fn canonical_over(attributes: &Value, fields: &[String]) -> String {
842    fields
843        .iter()
844        .map(|field| {
845            let value = attributes
846                .get(field)
847                .map(Value::to_string)
848                .unwrap_or_default();
849            format!("{field}={value}")
850        })
851        .collect::<Vec<_>>()
852        .join("\u{2}")
853}
854
855/// Mirror a JSON string to `window.<name>` (wasm/verification only).
856#[cfg(target_arch = "wasm32")]
857fn publish(name: &str, json: &str) {
858    if let Some(win) = web_sys::window() {
859        let _ = js_sys::Reflect::set(
860            &win,
861            &wasm_bindgen::JsValue::from_str(name),
862            &wasm_bindgen::JsValue::from_str(json),
863        );
864    }
865}
866
867// Native-only (as the update-components + assembly-edit suites are): the
868// fixtures ride the test-only in-memory `ModelStore`, which wasm does not
869// compile.
870#[cfg(all(test, not(target_arch = "wasm32")))]
871mod tests {
872    use super::*;
873
874    /// The packing fields a test groups by — normally the columns it actually
875    /// sets, since packing keys on the VISIBLE occurrence columns.
876    fn by(fields: &[&str]) -> Vec<String> {
877        fields.iter().map(|field| field.to_string()).collect()
878    }
879
880    /// The old one-argument snapshot the tests were written against: the
881    /// shared projection with nothing outdated. Keeps every existing test
882    /// honest about what it is actually asserting.
883    fn snapshot(state: &mut EngineState) -> Vec<Occurrence> {
884        let rows = assembly_components::snapshot(state, &UpdateComponents::new());
885        occurrences_from(state, &rows)
886    }
887    use crate::panels::update_components::tests::part_document;
888    use crate::store::MemModelStore;
889    use brep_render::engine_state::ComponentInsert;
890
891    /// Two instances of `widget` + one `gadget`, all embedded-only unless the
892    /// test says otherwise.
893    fn assembly() -> EngineState {
894        brep_render::brep_kernel::clear_history_cache();
895        let mut state = EngineState::new();
896        state
897            .insert_component(ComponentInsert::New {
898                name: "widget",
899                source_key: "",
900                source_signature: "sig-w",
901                document_json: &part_document(4.0),
902            })
903            .expect("widget inserts");
904        state
905            .insert_component(ComponentInsert::Existing { part_name: "widget" })
906            .expect("second widget");
907        state
908            .insert_component(ComponentInsert::New {
909                name: "gadget",
910                source_key: "",
911                source_signature: "sig-g",
912                document_json: &part_document(7.0),
913            })
914            .expect("gadget inserts");
915        state
916    }
917
918    fn panel_with(columns: &str) -> (BomPanel, EngineState) {
919        let mut state = assembly();
920        state
921            .apply_settings_json(&serde_json::json!({ "bomColumns": columns }).to_string())
922            .expect("columns apply");
923        // Adopt the configuration up front, so a test that calls `row_for`
924        // directly (without a draw) still has its columns.
925        let mut panel = BomPanel::new();
926        panel.sync_columns(&state);
927        (panel, state)
928    }
929
930    /// Draw one frame.
931    fn frame(
932        ctx: &egui::Context,
933        panel: &mut BomPanel,
934        state: &mut EngineState,
935        store: &dyn ModelStore,
936        events: Vec<egui::Event>,
937    ) -> BomOutcome {
938        let raw = egui::RawInput {
939            screen_rect: Some(egui::Rect::from_min_size(
940                egui::pos2(0.0, 0.0),
941                egui::vec2(900.0, 600.0),
942            )),
943            events,
944            ..Default::default()
945        };
946        let mut outcome = BomOutcome::default();
947        let _ = ctx.run_ui(raw, |ui| {
948            outcome = panel.show(ui, state, store, &UpdateComponents::new());
949        });
950        outcome
951    }
952
953    /// Draw two idle frames: an egui popup's FIRST frame is a sizing pass whose
954    /// widgets are not yet interactable (it asks for a repaint, which a real app
955    /// serves immediately and a test has to draw by hand).
956    fn settle(
957        ctx: &egui::Context,
958        panel: &mut BomPanel,
959        state: &mut EngineState,
960        store: &dyn ModelStore,
961    ) {
962        frame(ctx, panel, state, store, vec![]);
963        frame(ctx, panel, state, store, vec![]);
964    }
965
966    fn right_click_at(
967        ctx: &egui::Context,
968        panel: &mut BomPanel,
969        state: &mut EngineState,
970        store: &dyn ModelStore,
971        pos: egui::Pos2,
972    ) -> BomOutcome {
973        press_release(ctx, panel, state, store, pos, egui::PointerButton::Secondary)
974    }
975
976    fn click_at(
977        ctx: &egui::Context,
978        panel: &mut BomPanel,
979        state: &mut EngineState,
980        store: &dyn ModelStore,
981        pos: egui::Pos2,
982    ) -> BomOutcome {
983        press_release(ctx, panel, state, store, pos, egui::PointerButton::Primary)
984    }
985
986    fn press_release(
987        ctx: &egui::Context,
988        panel: &mut BomPanel,
989        state: &mut EngineState,
990        store: &dyn ModelStore,
991        pos: egui::Pos2,
992        button: egui::PointerButton,
993    ) -> BomOutcome {
994        frame(
995            ctx,
996            panel,
997            state,
998            store,
999            vec![
1000                egui::Event::PointerMoved(pos),
1001                egui::Event::PointerButton {
1002                    pos,
1003                    button,
1004                    pressed: true,
1005                    modifiers: egui::Modifiers::default(),
1006                },
1007            ],
1008        );
1009        frame(
1010            ctx,
1011            panel,
1012            state,
1013            store,
1014            vec![egui::Event::PointerButton {
1015                pos,
1016                button,
1017                pressed: false,
1018                modifiers: egui::Modifiers::default(),
1019            }],
1020        )
1021    }
1022
1023    /// PACKED rolls the two identical `widget` occurrences into one row with
1024    /// QTY 2; UNPACKED shows all three placements at QTY 1 each.
1025    #[test]
1026    fn packed_rolls_up_identical_occurrences_and_unpacked_does_not() {
1027        let mut state = assembly();
1028        let occurrences = snapshot(&mut state);
1029        assert_eq!(occurrences.len(), 3);
1030
1031        let packed = group(&occurrences, true, &by(&[]));
1032        assert_eq!(packed.len(), 2, "widget x2 rolled up, gadget alone");
1033        assert_eq!(packed[0].part_name, "widget");
1034        assert_eq!(packed[0].ids, vec!["ACOMP1", "ACOMP2"]);
1035        assert_eq!(packed[1].ids, vec!["ACOMP3"]);
1036
1037        let unpacked = group(&occurrences, false, &by(&[]));
1038        assert_eq!(unpacked.len(), 3, "one row per placement");
1039        assert!(unpacked.iter().all(|group| group.ids.len() == 1));
1040        assert_eq!(unpacked[0].key, "ACOMP1", "the row IS the occurrence");
1041    }
1042
1043    /// The roll-up rule: occurrences that differ in a VISIBLE occurrence
1044    /// column stay SEPARATE rows — that row would have to show one of two
1045    /// different values, and an edit to it would silently overwrite the other.
1046    /// A field NOT on screen cannot split anything: the row stands for what
1047    /// the table shows.
1048    #[test]
1049    fn a_differing_visible_field_splits_the_packed_row_and_a_hidden_one_does_not() {
1050        let mut state = assembly();
1051        state
1052            .set_occurrence_attribute(
1053                &["ACOMP2".to_string()],
1054                "Reference_Designator",
1055                Value::String("R2".into()),
1056            )
1057            .unwrap();
1058        // Reference_Designator ON SCREEN: the widgets no longer match.
1059        let shown = by(&["Reference_Designator"]);
1060        let packed = group(&snapshot(&mut state), true, &shown);
1061        assert_eq!(packed.len(), 3, "the two widgets no longer match");
1062        assert_eq!(packed[0].ids, vec!["ACOMP1"]);
1063        assert_eq!(packed[1].ids, vec!["ACOMP2"]);
1064
1065        // The SAME documents, with that column hidden: one row again. Nothing
1066        // about the data changed — only what the table is showing.
1067        let packed = group(&snapshot(&mut state), true, &by(&["Notes"]));
1068        assert_eq!(packed.len(), 2, "a hidden difference does not split a row");
1069        assert_eq!(packed[0].ids, vec!["ACOMP1", "ACOMP2"]);
1070
1071        // Give ACOMP1 the SAME value and they roll back up — the rule is
1072        // about content, not about having ever been edited.
1073        state
1074            .set_occurrence_attribute(
1075                &["ACOMP1".to_string()],
1076                "Reference_Designator",
1077                Value::String("R2".into()),
1078            )
1079            .unwrap();
1080        let packed = group(&snapshot(&mut state), true, &shown);
1081        assert_eq!(packed.len(), 2, "identical again");
1082        assert_eq!(packed[0].ids, vec!["ACOMP1", "ACOMP2"]);
1083    }
1084
1085    /// The packing key compares CONTENT, not serialization: two records with
1086    /// the same fields written in a different ORDER still roll up. (serde_json
1087    /// runs with `preserve_order` in this workspace, so a naive `to_string`
1088    /// key would not.)
1089    #[test]
1090    fn the_packing_key_ignores_attribute_write_order() {
1091        let mut state = assembly();
1092        let one = vec!["ACOMP1".to_string()];
1093        let two = vec!["ACOMP2".to_string()];
1094        state.set_occurrence_attribute(&one, "Notes", Value::String("a".into())).unwrap();
1095        state.set_occurrence_attribute(&one, "Find_Number", Value::String("1".into())).unwrap();
1096        // ...the other one written in the OPPOSITE order.
1097        state.set_occurrence_attribute(&two, "Find_Number", Value::String("1".into())).unwrap();
1098        state.set_occurrence_attribute(&two, "Notes", Value::String("a".into())).unwrap();
1099
1100        let packed = group(&snapshot(&mut state), true, &by(&["Notes", "Find_Number"]));
1101        assert_eq!(packed.len(), 2, "still widget x2 + gadget");
1102        assert_eq!(
1103            packed[0].ids,
1104            vec!["ACOMP1", "ACOMP2"],
1105            "same content, different write order, one row"
1106        );
1107    }
1108
1109    /// Editing a PACKED row's occurrence cell applies to every occurrence it
1110    /// rolls up — and takes ONE undo to reverse, not two.
1111    #[test]
1112    fn a_packed_edit_fans_out_and_undoes_in_one_step() {
1113        let ctx = egui::Context::default();
1114        let store = MemModelStore::new();
1115        let (mut panel, mut state) = panel_with("*occurrence.Notes\n");
1116        frame(&ctx, &mut panel, &mut state, &store, vec![]);
1117
1118        let cell = *panel
1119            .hits
1120            .get("cell:pack:ACOMP1:occurrence.Notes")
1121            .expect("the packed widget row's Notes cell");
1122        click_at(&ctx, &mut panel, &mut state, &store, cell.center());
1123        frame(&ctx, &mut panel, &mut state, &store, vec![egui::Event::Text("chk".into())]);
1124        frame(
1125            &ctx,
1126            &mut panel,
1127            &mut state,
1128            &store,
1129            vec![
1130                egui::Event::Key {
1131                    key: egui::Key::Enter,
1132                    physical_key: None,
1133                    pressed: true,
1134                    repeat: false,
1135                    modifiers: egui::Modifiers::default(),
1136                },
1137                egui::Event::Key {
1138                    key: egui::Key::Enter,
1139                    physical_key: None,
1140                    pressed: false,
1141                    repeat: false,
1142                    modifiers: egui::Modifiers::default(),
1143                },
1144            ],
1145        );
1146
1147        assert_eq!(state.occurrence_attributes("ACOMP1")["Notes"], "chk");
1148        assert_eq!(
1149            state.occurrence_attributes("ACOMP2")["Notes"], "chk",
1150            "the edit fanned out to the whole packed row"
1151        );
1152        assert_eq!(
1153            state.occurrence_attributes("ACOMP3"),
1154            serde_json::json!({}),
1155            "and only to that row"
1156        );
1157
1158        state.undo();
1159        assert_eq!(
1160            state.occurrence_attributes("ACOMP1"),
1161            serde_json::json!({}),
1162            "ONE undo takes the whole fan-out back"
1163        );
1164        assert_eq!(state.occurrence_attributes("ACOMP2"), serde_json::json!({}));
1165    }
1166
1167    /// A PART cell edit writes the part document (so every occurrence of the
1168    /// part shows it, in either view) and rides the shared write-through lane,
1169    /// so the part's file is updated too.
1170    #[test]
1171    fn a_part_edit_writes_the_part_document_and_writes_through_to_its_file() {
1172        let ctx = egui::Context::default();
1173        let store = MemModelStore::new();
1174        let document = part_document(4.0);
1175        store.write("widget", &document).unwrap();
1176
1177        brep_render::brep_kernel::clear_history_cache();
1178        let mut state = EngineState::new();
1179        state
1180            .insert_component(ComponentInsert::New {
1181                name: "widget",
1182                source_key: "widget",
1183                source_signature: &parts_library::document_signature(&document),
1184                document_json: &document,
1185            })
1186            .unwrap();
1187        state
1188            .insert_component(ComponentInsert::Existing { part_name: "widget" })
1189            .unwrap();
1190        state
1191            .apply_settings_json(r##"{"bomColumns": "*part.Material\n"}"##)
1192            .unwrap();
1193        let mut panel = BomPanel::new();
1194        frame(&ctx, &mut panel, &mut state, &store, vec![]);
1195
1196        let cell = *panel
1197            .hits
1198            .get("cell:pack:ACOMP1:part.Material")
1199            .expect("the Material cell");
1200        click_at(&ctx, &mut panel, &mut state, &store, cell.center());
1201        frame(&ctx, &mut panel, &mut state, &store, vec![egui::Event::Text("6061".into())]);
1202        frame(
1203            &ctx,
1204            &mut panel,
1205            &mut state,
1206            &store,
1207            vec![
1208                egui::Event::Key {
1209                    key: egui::Key::Enter,
1210                    physical_key: None,
1211                    pressed: true,
1212                    repeat: false,
1213                    modifiers: egui::Modifiers::default(),
1214                },
1215                egui::Event::Key {
1216                    key: egui::Key::Enter,
1217                    physical_key: None,
1218                    pressed: false,
1219                    repeat: false,
1220                    modifiers: egui::Modifiers::default(),
1221                },
1222            ],
1223        );
1224
1225        assert_eq!(state.part_attributes("widget")["Material"], "6061");
1226        // Write-through: the file the part came from now carries it too, and
1227        // its signature matches the entry's — so Update Components does not
1228        // badge the component outdated against a file it is newer than.
1229        let stored = store.read("widget").expect("the part file");
1230        let stored_document: Value = serde_json::from_str(&stored).unwrap();
1231        assert_eq!(stored_document["partAttributes"]["Material"], "6061");
1232        let (_, signature) = state.part_source("widget").unwrap();
1233        assert_eq!(
1234            signature,
1235            parts_library::document_signature(&stored),
1236            "the entry's signature and the file describe the same content"
1237        );
1238    }
1239
1240    /// The `occurrence.Quantity` column is DERIVED and read-only: packed shows
1241    /// the roll-up size, unpacked shows 1, and neither is ever stored.
1242    #[test]
1243    fn quantity_is_derived_read_only_and_never_stored() {
1244        let (panel, mut state) = panel_with("*occurrence.Quantity\n");
1245        let groups = group(&snapshot(&mut state), true, &by(&[]));
1246        let row = panel.row_for(&state, &groups[0]);
1247        assert_eq!(row.cells[QUANTITY_KEY], serde_json::json!(2));
1248
1249        let mut unpacked = BomPanel::new();
1250        unpacked.packed = false;
1251        unpacked.parsed = panel.parsed.clone();
1252        let groups = group(&snapshot(&mut state), false, &by(&[]));
1253        let row = unpacked.row_for(&state, &groups[0]);
1254        assert_eq!(row.cells[QUANTITY_KEY], serde_json::json!(1));
1255
1256        // Never stored: the occurrence record stays empty.
1257        assert_eq!(state.occurrence_attributes("ACOMP1"), serde_json::json!({}));
1258        assert_eq!(
1259            panel.parsed.columns[0].kind(),
1260            crate::column_tree::CellKind::ReadOnly
1261        );
1262    }
1263
1264    /// The settings text drives which columns the table shows, and in what
1265    /// order — including a user-added custom field.
1266    #[test]
1267    fn the_settings_text_drives_the_columns() {
1268        let ctx = egui::Context::default();
1269        let store = MemModelStore::new();
1270        let (mut panel, mut state) =
1271            panel_with("*occurrence.Notes\n*part.Part_Number\npart.Mass\n*occurrence.Torque\n");
1272        frame(&ctx, &mut panel, &mut state, &store, vec![]);
1273
1274        assert!(panel.hits.contains_key("col:occurrence.Notes"));
1275        assert!(panel.hits.contains_key("col:part.Part_Number"));
1276        assert!(panel.hits.contains_key("col:occurrence.Torque"), "custom field");
1277        assert!(
1278            !panel.hits.contains_key("col:part.Mass"),
1279            "unstarred = hidden"
1280        );
1281        assert!(
1282            panel.hits["col:occurrence.Notes"].left() < panel.hits["col:part.Part_Number"].left(),
1283            "the text's order is the table's order"
1284        );
1285        // Re-applying the SAME text does not rebuild the layout under the user.
1286        panel.layout.widths.insert("occurrence.Notes".into(), 300.0);
1287        frame(&ctx, &mut panel, &mut state, &store, vec![]);
1288        assert_eq!(panel.layout.widths["occurrence.Notes"], 300.0);
1289    }
1290
1291    /// Hiding a column by dragging writes BACK to the settings text, so the
1292    /// table and the configuration can never disagree.
1293    #[test]
1294    fn a_layout_change_persists_into_the_settings_text() {
1295        let ctx = egui::Context::default();
1296        let store = MemModelStore::new();
1297        let (mut panel, mut state) = panel_with("*occurrence.Notes\n*part.Part_Number\n");
1298        frame(&ctx, &mut panel, &mut state, &store, vec![]);
1299
1300        panel.layout.hidden.insert("part.Part_Number".into());
1301        panel.persist_layout(&mut state, &store);
1302        assert_eq!(
1303            state.settings.bom_columns, "*occurrence.Notes\npart.Part_Number\n",
1304            "the star came off the hidden column"
1305        );
1306        // ...and a redraw keeps it hidden rather than re-reading the old text.
1307        frame(&ctx, &mut panel, &mut state, &store, vec![]);
1308        assert!(!panel.hits.contains_key("col:part.Part_Number"));
1309    }
1310
1311    /// The actions cell opens the MENU, and choosing "Edit feature" reports the
1312    /// row's feature for the shell to focus — the ✎ button's old job, now one
1313    /// entry of a list.
1314    #[test]
1315    fn the_actions_cell_opens_the_menu_and_edit_feature_focuses_the_row() {
1316        let ctx = egui::Context::default();
1317        let store = MemModelStore::new();
1318        let (mut panel, mut state) = panel_with("*occurrence.Notes\n");
1319        frame(&ctx, &mut panel, &mut state, &store, vec![]);
1320        let trigger = *panel
1321            .hits
1322            .get("menu:pack:ACOMP3")
1323            .expect("the gadget row's menu trigger");
1324
1325        click_at(&ctx, &mut panel, &mut state, &store, trigger.center());
1326        settle(&ctx, &mut panel, &mut state, &store);
1327        let entry = *panel
1328            .hits
1329            .get("menuitem:pack:ACOMP3:edit-feature")
1330            .expect("Edit feature is on the menu");
1331        let outcome = click_at(&ctx, &mut panel, &mut state, &store, entry.center());
1332        assert_eq!(outcome.focus.as_deref(), Some("ACOMP3"));
1333    }
1334
1335    /// A RIGHT-CLICK anywhere on the row opens the same menu — here over the
1336    /// Notes text cell, the case a `context_menu` on the row band would lose to
1337    /// the text editor — and one of the SHARED component actions run from it
1338    /// reaches the engine through the shared dispatcher.
1339    #[test]
1340    fn a_right_click_on_a_row_runs_a_shared_component_action() {
1341        let ctx = egui::Context::default();
1342        let store = MemModelStore::new();
1343        let (mut panel, mut state) = panel_with("*occurrence.Notes\n");
1344        frame(&ctx, &mut panel, &mut state, &store, vec![]);
1345        let cell = *panel
1346            .hits
1347            .get("cell:pack:ACOMP3:occurrence.Notes")
1348            .expect("the gadget row's Notes cell");
1349
1350        right_click_at(&ctx, &mut panel, &mut state, &store, cell.center());
1351        settle(&ctx, &mut panel, &mut state, &store);
1352        let entry = *panel
1353            .hits
1354            .get("menuitem:pack:ACOMP3:move")
1355            .expect("Move is on the menu opened by right-click");
1356        click_at(&ctx, &mut panel, &mut state, &store, entry.center());
1357
1358        assert!(state.component_move_armed(), "the gizmo armed");
1359        assert_eq!(state.component_move_armed_feature(), "ACOMP3");
1360        assert_eq!(
1361            state.occurrence_attributes("ACOMP3"),
1362            serde_json::json!({}),
1363            "and the right-click wrote nothing into the cell it landed on"
1364        );
1365    }
1366
1367    /// The menu is the SHARED component action set plus this panel's own
1368    /// "Edit feature", and availability is decided per row: a fixed component
1369    /// refuses Move, an embedded-only part refuses Open Part, and a PACKED row
1370    /// standing for several placements refuses the per-instance actions rather
1371    /// than guessing which placement was meant.
1372    #[test]
1373    fn the_menu_is_the_shared_action_set_refused_per_row() {
1374        let (_, mut state) = panel_with("*occurrence.Notes\n");
1375        let groups = group(&snapshot(&mut state), true, &by(&[]));
1376
1377        let ids = |actions: &[RowAction]| -> Vec<String> {
1378            actions.iter().map(|action| action.id.clone()).collect()
1379        };
1380        let refused = |actions: &[RowAction]| -> Vec<String> {
1381            actions
1382                .iter()
1383                .filter(|action| !action.enabled)
1384                .map(|action| action.id.clone())
1385                .collect()
1386        };
1387
1388        // The packed widget row: two placements, and ACOMP1 is the grounded
1389        // first component.
1390        let packed = groups.iter().find(|g| g.ids.len() == 2).expect("two widgets");
1391        let actions = actions_for(&state, packed);
1392        assert_eq!(
1393            ids(&actions),
1394            vec![
1395                EDIT_FEATURE,
1396                "move",
1397                "open-part",
1398                "toggle-fixed",
1399                "delete"
1400            ],
1401            "Edit feature, then ComponentAction::ALL in bar order"
1402        );
1403        assert_eq!(
1404            refused(&actions),
1405            vec!["move", "open-part", "toggle-fixed", "delete"],
1406            "per-instance actions on a rolled-up row, and the embedded part"
1407        );
1408        assert!(
1409            actions.iter().all(|action| !action.tooltip.is_empty()),
1410            "every entry says what it does — a refused one says why not"
1411        );
1412        assert!(
1413            actions.last().is_some_and(|action| action.destructive
1414                && action.separator_above
1415                && action.id == "delete"),
1416            "Delete is the destructive tail, fenced off"
1417        );
1418
1419        // The lone gadget: one placement, free, still embedded-only.
1420        let single = groups.iter().find(|g| g.ids == ["ACOMP3"]).expect("the gadget");
1421        assert_eq!(
1422            refused(&actions_for(&state, single)),
1423            vec!["open-part"],
1424            "only the embedded-part refusal survives on an unpacked row"
1425        );
1426
1427        // A nested sub-assembly row owns no feature HERE, so it offers nothing.
1428        let panel = BomPanel::new();
1429        let row = panel.row_for(&state, single);
1430        assert!(!row.actions.is_empty(), "the component row offers its menu");
1431    }
1432
1433    /// The nested components of a rigid sub-assembly are child ROWS (so the
1434    /// BOM reads as the tree it is) but take no edit — their data lives in the
1435    /// sub-assembly's own document.
1436    #[test]
1437    fn nested_sub_assembly_rows_are_children_and_read_only() {
1438        let (panel, mut state) = panel_with("*occurrence.Notes\n");
1439        let mut groups = group(&snapshot(&mut state), true, &by(&[]));
1440        groups[0].children = vec![ChainNode {
1441            label: "ACOMP9".into(),
1442            children: vec![ChainNode { label: "ACOMP3".into(), children: vec![] }],
1443        }];
1444        let row = panel.row_for(&state, &groups[0]);
1445        assert_eq!(row.children.len(), 1);
1446        assert!(
1447            !row.children[0].editable,
1448            "a nested row belongs to another document"
1449        );
1450        assert!(row.editable, "the top-level row is still editable");
1451        // FULL DEPTH: the nested row's own child renders too. The Structure
1452        // panel this replaced showed every level, and a BOM that stopped at one
1453        // would have quietly dropped sub-sub-assemblies from the model's only
1454        // component list.
1455        assert_eq!(row.children[0].children.len(), 1, "depth 2 renders");
1456        assert_eq!(
1457            row.children[0].children[0].cells.get(ITEM_KEY),
1458            Some(&Value::String("ACOMP3".into()))
1459        );
1460    }
1461
1462    /// The VISIBILITY toggle (ported from the Structure panel it replaced):
1463    /// unchecking a row hides every member solid it stands for, and leaves
1464    /// every other instance alone.
1465    #[test]
1466    fn visibility_toggle_hides_every_member_solid_of_the_row() {
1467        let ctx = egui::Context::default();
1468        let (mut panel, mut state) = panel_with("*occurrence.Notes\n");
1469        let store = MemModelStore::new();
1470        panel.packed = false;
1471        settle(&ctx, &mut panel, &mut state, &store);
1472        let cell = *panel
1473            .hits
1474            .get(&format!("cell:ACOMP1:{VISIBLE_KEY}"))
1475            .expect("a visibility cell for the first row");
1476        click_at(&ctx, &mut panel, &mut state, &store, cell.center());
1477        assert!(
1478            !state.scene.solid("ACOMP1:Part").unwrap().visible,
1479            "the row's member is hidden"
1480        );
1481        assert!(
1482            state.scene.solid("ACOMP2:Part").unwrap().visible,
1483            "the other instance is untouched"
1484        );
1485    }
1486
1487    /// The BADGES cell carries grounded / outdated / constraint status. The
1488    /// first instance of a batch is grounded by the insert rule, so ⏚ is the
1489    /// one badge a bare two-instance document shows.
1490    #[test]
1491    fn badges_report_the_grounded_instance() {
1492        let (_panel, mut state) = panel_with("*occurrence.Notes\n");
1493        let groups = group(&snapshot(&mut state), false, &by(&[]));
1494        let grounded = groups.iter().find(|g| g.fixed).expect("one is grounded");
1495        let glyphs: Vec<String> = badges(grounded)
1496            .iter()
1497            .filter_map(|badge| badge.get("glyph").and_then(Value::as_str))
1498            .map(str::to_string)
1499            .collect();
1500        assert!(
1501            glyphs.contains(&assembly_components::FIXED_GLYPH.to_string()),
1502            "the grounded row shows ⏚, got {glyphs:?}"
1503        );
1504        let free = groups.iter().find(|g| !g.fixed).expect("one is free");
1505        assert!(badges(free).is_empty(), "a plain instance carries no badge");
1506    }
1507
1508    /// PACKED ROLL-UP of the new state: a rolled-up row may only claim what is
1509    /// true of EVERY placement behind it. Two instances, one grounded, roll up
1510    /// to NOT grounded — the opposite would let the row show a ⏚ that only
1511    /// half its placements have.
1512    #[test]
1513    fn a_packed_row_is_grounded_only_when_every_placement_is() {
1514        let (_panel, mut state) = panel_with("*occurrence.Notes\n");
1515        // The fixture is two `widget` placements plus one `gadget`; the
1516        // insert rule grounds the FIRST component only.
1517        let unpacked = group(&snapshot(&mut state), false, &by(&[]));
1518        assert_eq!(unpacked.len(), 3);
1519        assert_eq!(
1520            unpacked.iter().filter(|g| g.fixed).count(),
1521            1,
1522            "exactly one instance is grounded"
1523        );
1524        let packed = group(&snapshot(&mut state), true, &by(&[]));
1525        let widget = packed
1526            .iter()
1527            .find(|g| g.part_name == "widget")
1528            .expect("the two widgets roll up");
1529        assert_eq!(widget.ids.len(), 2, "same part, same fields — one row");
1530        assert!(!widget.fixed, "not grounded, because not ALL of it is");
1531        assert!(widget.visible, "all are visible, so the row is");
1532        assert_eq!(
1533            widget.solids.len(),
1534            2,
1535            "the toggle writes to every member of every placement"
1536        );
1537    }
1538
1539    /// VIEWPORT -> BOM sync: picking a component's solid in the 3D view marks
1540    /// its BOM row selected, in both views. The row is what the user has to
1541    /// find, so a packed row stands selected when ANY placement it rolls up is.
1542    #[test]
1543    fn a_viewport_pick_marks_the_component_row_selected() {
1544        let (panel, mut state) = panel_with("*occurrence.Notes\n");
1545        // Nothing picked: no row claims selection.
1546        assert!(group(&snapshot(&mut state), false, &by(&[]))
1547            .iter()
1548            .all(|group| !group.selected));
1549
1550        // Pick the SECOND widget's solid, exactly as a viewport click does.
1551        state.select_components(&["ACOMP2".to_string()]);
1552        let unpacked = group(&snapshot(&mut state), false, &by(&[]));
1553        let picked: Vec<&str> = unpacked
1554            .iter()
1555            .filter(|group| group.selected)
1556            .map(|group| group.key.as_str())
1557            .collect();
1558        assert_eq!(picked, vec!["ACOMP2"], "that row, and only that row");
1559        assert!(
1560            panel.row_for(&state, unpacked.iter().find(|g| g.selected).unwrap()).selected,
1561            "and the widget row carries it, so the band is drawn"
1562        );
1563
1564        // PACKED: the row standing for both widgets is selected, because one of
1565        // the placements behind it is the one the user picked.
1566        let packed = group(&snapshot(&mut state), true, &by(&[]));
1567        let widget = packed.iter().find(|g| g.part_name == "widget").unwrap();
1568        assert_eq!(widget.ids, vec!["ACOMP1", "ACOMP2"]);
1569        assert!(widget.selected, "any placement selected selects the row");
1570        assert!(
1571            !packed.iter().find(|g| g.part_name == "gadget").unwrap().selected,
1572            "and an unrelated part's row is left alone"
1573        );
1574    }
1575
1576    /// `packing_fields` reads the LIVE arrangement, not the configuration
1577    /// text: hiding a column through the header checklist re-packs the table on
1578    /// the next frame, and part-scoped columns never enter the key (they are
1579    /// identical across every placement of a part, so they cannot split a row).
1580    #[test]
1581    fn packing_fields_follow_the_visible_occurrence_columns() {
1582        let (mut panel, _state) =
1583            panel_with("*occurrence.Reference_Designator\n*part.Mass\n*occurrence.Notes\n");
1584        assert_eq!(
1585            panel.packing_fields(),
1586            vec!["Reference_Designator".to_string(), "Notes".to_string()],
1587            "occurrence columns only, in the arrangement's order"
1588        );
1589
1590        panel
1591            .layout
1592            .hidden
1593            .insert("occurrence.Reference_Designator".into());
1594        assert_eq!(
1595            panel.packing_fields(),
1596            vec!["Notes".to_string()],
1597            "hiding a column drops it from the key"
1598        );
1599
1600        // Every occurrence column hidden: placements of one part are one row.
1601        panel.layout.hidden.insert("occurrence.Notes".into());
1602        assert!(panel.packing_fields().is_empty());
1603    }
1604
1605    /// A BOM lists PARTS, not the bodies inside them. A plain part's chain is
1606    /// all body leaves, so its row has no children and no collapse box — the
1607    /// bodies belong to the Scene tree.
1608    #[test]
1609    fn body_leaves_are_not_rows_and_a_plain_part_has_no_children() {
1610        let (panel, mut state) = panel_with("*occurrence.Notes\n");
1611        let mut groups = group(&snapshot(&mut state), false, &by(&[]));
1612        // A part holding two bodies and ONE nested sub-assembly, which itself
1613        // holds a body and a deeper component.
1614        groups[0].children = vec![
1615            ChainNode { label: "Body".into(), children: vec![] },
1616            ChainNode { label: "Rim".into(), children: vec![] },
1617            ChainNode {
1618                label: "ACOMP9".into(),
1619                children: vec![
1620                    ChainNode { label: "Cap".into(), children: vec![] },
1621                    ChainNode { label: "ACOMP3".into(), children: vec![] },
1622                ],
1623            },
1624        ];
1625        let row = panel.row_for(&state, &groups[0]);
1626        let labels: Vec<&Value> = row
1627            .children
1628            .iter()
1629            .filter_map(|child| child.cells.get(ITEM_KEY))
1630            .collect();
1631        assert_eq!(
1632            labels,
1633            vec![&Value::String("ACOMP9".into())],
1634            "the bodies are not rows — only the nested component is"
1635        );
1636        assert_eq!(
1637            row.children[0]
1638                .children
1639                .iter()
1640                .filter_map(|c| c.cells.get(ITEM_KEY))
1641                .collect::<Vec<_>>(),
1642            vec![&Value::String("ACOMP3".into())],
1643            "and the same rule applies at depth"
1644        );
1645
1646        // A part with nothing but bodies has no children at all, so the widget
1647        // draws no collapse box on it.
1648        groups[0].children = vec![ChainNode { label: "Body".into(), children: vec![] }];
1649        assert!(panel.row_for(&state, &groups[0]).children.is_empty());
1650        assert!(
1651            collapsible_keys(&groups).is_empty(),
1652            "and it claims no collapse key"
1653        );
1654    }
1655
1656    /// Collapse-all folds EVERY depth, not just the top level.
1657    #[test]
1658    fn collapse_all_collects_keys_at_every_depth() {
1659        let groups = vec![Group {
1660            key: "G".into(),
1661            part_name: "sub".into(),
1662            ids: vec!["ACOMP1".into()],
1663            attributes: Value::Null,
1664            selected: false,
1665            fixed: false,
1666            outdated: false,
1667            status: None,
1668            visible: true,
1669            solids: vec![],
1670            children: vec![ChainNode {
1671                label: "ACOMP9".into(),
1672                children: vec![ChainNode {
1673                    label: "ACOMP3".into(),
1674                    children: vec![
1675                        ChainNode { label: "ACOMP7".into(), children: vec![] },
1676                        ChainNode { label: "Body".into(), children: vec![] },
1677                    ],
1678                }],
1679            }],
1680        }];
1681        let keys = collapsible_keys(&groups);
1682        assert!(keys.contains("G"), "the group row");
1683        assert!(keys.contains("G:ACOMP9"), "the nested component");
1684        assert!(keys.contains("G:ACOMP9:ACOMP3"), "and the one inside THAT");
1685        assert!(
1686            !keys.contains("G:ACOMP9:ACOMP3:ACOMP7"),
1687            "a component holding no further COMPONENT has nothing to collapse"
1688        );
1689        assert!(
1690            !keys.contains("G:ACOMP9:ACOMP3:Body"),
1691            "and a body is never a row at all"
1692        );
1693    }
1694}