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];