Skip to main content

brep_app/panels/
bom_columns.rs

1//! The BOM's COLUMN CONFIGURATION — the catalogue of known fields, the
2//! settings-textarea format, and the translation between that text and the
3//! generic [`crate::column_tree`] widget's `ColumnSpec` / `ColumnLayout`.
4//!
5//! This is the BOM-shaped half deliberately kept OUT of the widget. The widget
6//! knows "a column of kind K"; this module knows that a BOM column is either a
7//! PART field or an OCCURRENCE field, which of them are built in, and how the
8//! user writes that down.
9//!
10//! # The settings text
11//!
12//! One column per line, in display order. A leading `*` means SHOWN:
13//!
14//! ```text
15//! part.Mass
16//! *part.Part_Number
17//! *occurrence.Notes
18//! *occurrence.Item_Number
19//! ```
20//!
21//! * `part.` — stored on the PART, shared by every occurrence of it.
22//! * `occurrence.` — stored on ONE placement.
23//! * A name the catalogue does not know is a user-added CUSTOM field, not an
24//!   error: it becomes a text column and is stored like any other.
25//! * `#` starts a comment. Blank lines are ignored.
26//! * A MALFORMED line (no `.`, an unknown prefix, an empty field name) costs
27//!   that ONE column and nothing else: it is skipped, the text is left exactly
28//!   as typed, and the panel lists `line N: …` under the textarea. A typo must
29//!   never cost the user the rest of their configuration.
30//! * A duplicate `prefix.Name` keeps the FIRST and reports the rest — two rules
31//!   for one column would otherwise be decided by whichever the loop saw last.
32//!
33//! # The freeze marker
34//!
35//! A line that is exactly `-` FREEZES everything above it: those columns stay
36//! put while the rest scroll horizontally, as in a spreadsheet.
37//!
38//! ```text
39//! *part.Part_Number
40//! -
41//! *part.Description
42//! ```
43//!
44//! * The STRUCTURAL columns — Item, the visibility toggle, the status badges —
45//!   have no lines here and NEVER scroll. They are the row's identity and its
46//!   state; scrolling them away leaves a table of values with nothing saying
47//!   which part each line is about. So the frozen band is those three plus
48//!   whatever the marker names, and a configuration with no marker at all still
49//!   holds them still.
50//! * A marker below every column would freeze the lot, leaving nothing to
51//!   scroll it against; the widget reads that as "frozen: none" so no column
52//!   can be stranded off the right edge.
53//! * The FIRST marker decides. A second one is reported like any other
54//!   unusable line — and, unlike a comment, it is DROPPED rather than
55//!   preserved: preserved lines are re-emitted ABOVE the column block, where a
56//!   stray `-` would come back as "freeze nothing" on the next write-back.
57//!
58//! Reordering or hiding a column by dragging its heading WRITES BACK here, so
59//! the text and the table can never disagree. Comment and malformed lines
60//! survive that round trip (they are re-emitted above the column block), so an
61//! automated rewrite can never eat something the user typed.
62
63use crate::column_tree::{CellKind, ColumnLayout, ColumnSpec};
64use std::collections::HashSet;
65
66/// The user-facing prefix for a field stored on the PART.
67pub const PART_PREFIX: &str = "part";
68
69/// The user-facing prefix for a field stored on ONE occurrence.
70pub const OCCURRENCE_PREFIX: &str = "occurrence";
71
72/// The always-present structural column: the tree itself (part name +
73/// occurrence id). Not a stored attribute, so it is not written in the
74/// settings text — but it IS an ordinary column to the widget, so the user can
75/// still resize, reorder, and even hide it (the tree then draws in whatever
76/// column comes first).
77pub const ITEM_KEY: &str = "item";
78
79/// The other structural column: the per-row ACTION MENU trigger. Also not a
80/// stored attribute, so also not written in the settings text.
81pub const ACTIONS_KEY: &str = "actions";
82
83/// The per-row visibility toggle — structural, like [`ITEM_KEY`]: it shows the
84/// scene's truth for the row's member solids and is never configured away,
85/// because a component list you cannot blank out of the viewport is a
86/// regression against the Structure panel this replaced.
87pub const VISIBLE_KEY: &str = "visible";
88
89/// The per-row status badges (fixed / outdated / worst constraint status).
90/// Structural for the same reason, and one column rather than three because
91/// they are all "what is true of this instance right now", read at a glance.
92pub const FLAGS_KEY: &str = "flags";
93
94/// The freeze marker: a settings line that is exactly this holds every column
95/// above it fixed while the rest scroll.
96pub const FREEZE_MARKER: &str = "-";
97
98/// `occurrence.Quantity` is DERIVED, never stored: in the packed view it is
99/// how many occurrences the row rolls up, in the unpacked view it is 1. It is
100/// the one column the brief exempts from "all columns are editable".
101pub const QUANTITY_KEY: &str = "occurrence.Quantity";
102
103/// Which store a BOM column's value lives in.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum Scope {
106    /// On the part's own document (`partAttributes`) — shared by every
107    /// occurrence of the part.
108    Part,
109    /// On the placing ACOMP's `inputParams.bom` — one placement's own.
110    Occurrence,
111}
112
113impl Scope {
114    pub fn prefix(self) -> &'static str {
115        match self {
116            Scope::Part => PART_PREFIX,
117            Scope::Occurrence => OCCURRENCE_PREFIX,
118        }
119    }
120}
121
122/// One configured BOM column.
123#[derive(Debug, Clone, PartialEq)]
124pub struct BomColumn {
125    pub scope: Scope,
126    /// The field name as written in the settings text (`Part_Number`) — also
127    /// the attribute key in the stored document.
128    pub field: String,
129    /// Shown in the table (a leading `*` in the settings text).
130    pub shown: bool,
131}
132
133impl BomColumn {
134    /// The widget column key — the settings text's own `prefix.Field`, so a
135    /// hit key, a layout entry and a config line all read the same.
136    pub fn key(&self) -> String {
137        format!("{}.{}", self.scope.prefix(), self.field)
138    }
139
140    /// The heading: the field name with underscores opened out.
141    pub fn label(&self) -> String {
142        self.field.replace('_', " ")
143    }
144
145    /// The editor kind — from the catalogue when the field is built in, plain
146    /// text for a user-added one.
147    pub fn kind(&self) -> CellKind {
148        if self.key() == QUANTITY_KEY {
149            return CellKind::ReadOnly;
150        }
151        catalogue()
152            .iter()
153            .find(|entry| entry.scope == self.scope && entry.field == self.field)
154            .map(|entry| entry.kind.clone())
155            .unwrap_or(CellKind::Text)
156    }
157}
158
159/// A built-in field: its scope, name and editor kind.
160pub struct CatalogueEntry {
161    pub scope: Scope,
162    pub field: &'static str,
163    pub kind: CellKind,
164}
165
166/// The built-in BOM fields. Part fields describe the PART, occurrence fields
167/// describe ONE PLACEMENT — the owner's split, and the reason they persist in
168/// two different documents.
169pub fn catalogue() -> Vec<CatalogueEntry> {
170    let choice = |options: &[&str]| CellKind::Choice {
171        options: options.iter().map(|option| option.to_string()).collect(),
172    };
173    let part = |field, kind| CatalogueEntry {
174        scope: Scope::Part,
175        field,
176        kind,
177    };
178    let occurrence = |field, kind| CatalogueEntry {
179        scope: Scope::Occurrence,
180        field,
181        kind,
182    };
183    vec![
184        // --- Part ---------------------------------------------------------
185        part("Part_Number", CellKind::Text),
186        part("Revision", CellKind::Text),
187        part("Description", CellKind::Text),
188        part(
189            "Part_Type",
190            choice(&["Manufactured", "Purchased", "Assembly", "Phantom", "Reference"]),
191        ),
192        part("Make_Buy", choice(&["Make", "Buy"])),
193        part(
194            "Unit_of_Measure",
195            choice(&["EA", "MM", "M", "IN", "FT", "KG", "G", "LB", "L", "ML"]),
196        ),
197        part("Material", CellKind::Text),
198        part("Finish", CellKind::Text),
199        part("Mass", CellKind::Numeric { step: 0.01 }),
200        part("Manufacturer", CellKind::Text),
201        part("Manufacturer_Part_Number", CellKind::Text),
202        part("Supplier", CellKind::Text),
203        part("Supplier_Part_Number", CellKind::Text),
204        part(
205            "Lifecycle_State",
206            choice(&["In Work", "In Review", "Released", "Obsolete"]),
207        ),
208        // --- Occurrence ---------------------------------------------------
209        // Item / Find numbers are TEXT, not numeric: real BOMs use `1.2.3`
210        // and `010`, and a numeric editor would eat both.
211        occurrence("Item_Number", CellKind::Text),
212        // Derived — see `QUANTITY_KEY`.
213        occurrence("Quantity", CellKind::ReadOnly),
214        occurrence("Reference_Designator", CellKind::Text),
215        occurrence("Find_Number", CellKind::Text),
216        occurrence("Effectivity", CellKind::Text),
217        occurrence("Occurrence_Name", CellKind::Text),
218        occurrence("Position", CellKind::Text),
219        occurrence("Notes", CellKind::Text),
220        occurrence(
221            "BOM_Structure",
222            choice(&["Normal", "Phantom", "Reference", "Inseparable"]),
223        ),
224        occurrence("Alternate_Substitute", CellKind::Text),
225    ]
226}
227
228/// The shipped configuration: every built-in field, in a sensible BOM reading
229/// order, with the ones a parts list is actually read for starred. Everything
230/// else is one `*` away.
231pub fn default_text() -> String {
232    let shown: HashSet<&str> = [
233        "occurrence.Item_Number",
234        "occurrence.Quantity",
235        "part.Part_Number",
236        "part.Revision",
237        "part.Description",
238        "part.Material",
239        "occurrence.Reference_Designator",
240        "occurrence.Notes",
241    ]
242    .into_iter()
243    .collect();
244    // Reading order: what identifies the line, then what the part IS, then the
245    // sourcing tail.
246    let order = [
247        "occurrence.Item_Number",
248        "occurrence.Quantity",
249        "part.Part_Number",
250        "part.Revision",
251        "part.Description",
252        "part.Part_Type",
253        "part.Make_Buy",
254        "part.Unit_of_Measure",
255        "part.Material",
256        "part.Finish",
257        "part.Mass",
258        "part.Lifecycle_State",
259        "occurrence.Reference_Designator",
260        "occurrence.Find_Number",
261        "occurrence.Occurrence_Name",
262        "occurrence.Position",
263        "occurrence.Effectivity",
264        "occurrence.BOM_Structure",
265        "occurrence.Alternate_Substitute",
266        "occurrence.Notes",
267        "part.Manufacturer",
268        "part.Manufacturer_Part_Number",
269        "part.Supplier",
270        "part.Supplier_Part_Number",
271    ];
272    let mut out = String::from(
273        "# One BOM column per line, in display order. A leading * shows it.\n\
274         # part.<Field> is stored on the part; occurrence.<Field> on one placement.\n\
275         # A name not listed here is a custom text field, not an error.\n\
276         # A line that is just - freezes the columns above it; the rest scroll.\n",
277    );
278    for key in order {
279        if shown.contains(key) {
280            out.push('*');
281        }
282        out.push_str(key);
283        out.push('\n');
284    }
285    out
286}
287
288/// The configuration text actually in force: the stored setting, or the
289/// shipped default when it is empty. EMPTY is the persisted "never configured"
290/// state (see `RenderSettings::bom_columns`), so a later change to the shipped
291/// default still reaches every user who never overrode it — and a document
292/// that was never configured persists byte-for-byte as before.
293pub fn effective_text(stored: &str) -> String {
294    if stored.trim().is_empty() {
295        default_text()
296    } else {
297        stored.to_string()
298    }
299}
300
301/// EVERY part field, for the toolbar's Part Properties dialog: the whole
302/// `partAttributes` record of one part, not the subset a parts list is read
303/// for.
304///
305/// The BOM table draws the columns the configuration STARS; the dialog draws
306/// them all, because hiding a column is a statement about the table's width,
307/// never about whether the part has a Supplier. So the `shown` flag is read
308/// here only to be ignored — and the returned columns carry it verbatim, so a
309/// caller can still say which ones the table happens to show.
310///
311/// Three sources, in this order, each contributing what the ones before it did
312/// not:
313///
314/// 1. the configured `part.` columns, in the USER's own reading order (their
315///    configuration is the order they think about these fields in, and a
316///    custom field they added is a real field of the part);
317/// 2. the built-in [`catalogue`] fields the configuration leaves out — a
318///    deleted column line must not delete a part's Material;
319/// 3. any key ALREADY STORED that neither names, alphabetically — a record
320///    written by an import, an older configuration or the automation layer is
321///    still the part's data, and a dialog that silently dropped it would be a
322///    dialog that lies about what saving will keep.
323pub fn part_fields(config_text: &str, stored: &serde_json::Value) -> Vec<BomColumn> {
324    let mut out: Vec<BomColumn> = Vec::new();
325    let mut seen: HashSet<String> = HashSet::new();
326    let mut push = |out: &mut Vec<BomColumn>, seen: &mut HashSet<String>, column: BomColumn| {
327        if seen.insert(column.field.clone()) {
328            out.push(column);
329        }
330    };
331    for column in parse(config_text).columns {
332        if column.scope == Scope::Part {
333            push(&mut out, &mut seen, column);
334        }
335    }
336    for entry in catalogue() {
337        if entry.scope == Scope::Part {
338            push(
339                &mut out,
340                &mut seen,
341                BomColumn { scope: Scope::Part, field: entry.field.to_string(), shown: false },
342            );
343        }
344    }
345    if let Some(record) = stored.as_object() {
346        // BTreeMap-backed only under serde_json's `preserve_order` default off;
347        // sort explicitly so the tail is stable whichever map serde_json uses.
348        let mut orphans: Vec<&String> = record.keys().collect();
349        orphans.sort();
350        for key in orphans {
351            push(
352                &mut out,
353                &mut seen,
354                BomColumn { scope: Scope::Part, field: key.clone(), shown: false },
355            );
356        }
357    }
358    out
359}
360
361/// The result of reading the settings text.
362#[derive(Debug, Default, Clone, PartialEq)]
363pub struct ParsedColumns {
364    /// The configured columns, in the text's order.
365    pub columns: Vec<BomColumn>,
366    /// Lines the parser could not use, as `line N: why` — shown under the
367    /// textarea. The text itself is never rewritten by parsing.
368    pub problems: Vec<String>,
369    /// Comment and malformed lines, verbatim, so a write-back can re-emit them
370    /// instead of eating them.
371    pub preserved: Vec<String>,
372    /// How many configured columns stood ABOVE the `-` freeze marker; `None`
373    /// when the text has no marker at all (nothing frozen).
374    pub frozen: Option<usize>,
375}
376
377/// Read the settings text. Never fails: an unusable line costs that one column
378/// and is reported.
379pub fn parse(text: &str) -> ParsedColumns {
380    let mut out = ParsedColumns::default();
381    let mut seen: HashSet<String> = HashSet::new();
382    for (index, raw) in text.lines().enumerate() {
383        let number = index + 1;
384        let line = raw.trim();
385        if line.is_empty() {
386            continue;
387        }
388        if line.starts_with('#') {
389            out.preserved.push(line.to_string());
390            continue;
391        }
392        if line == FREEZE_MARKER {
393            // The FIRST marker decides. A second is reported and dropped — a
394            // preserved one would be re-emitted above the column block on the
395            // next write-back and would then read as "freeze nothing".
396            if out.frozen.is_some() {
397                out.problems.push(format!(
398                    "line {number}: a second '{FREEZE_MARKER}' freeze marker — the first one decides"
399                ));
400            } else {
401                out.frozen = Some(out.columns.len());
402            }
403            continue;
404        }
405        let (shown, rest) = match line.strip_prefix('*') {
406            Some(rest) => (true, rest.trim()),
407            None => (false, line),
408        };
409        let Some((prefix, field)) = rest.split_once('.') else {
410            out.problems.push(format!(
411                "line {number}: '{rest}' has no '.' — write {PART_PREFIX}.Field or {OCCURRENCE_PREFIX}.Field"
412            ));
413            out.preserved.push(raw.trim_end().to_string());
414            continue;
415        };
416        let scope = match prefix.trim() {
417            PART_PREFIX => Scope::Part,
418            OCCURRENCE_PREFIX => Scope::Occurrence,
419            other => {
420                out.problems.push(format!(
421                    "line {number}: unknown prefix '{other}' — only '{PART_PREFIX}.' and '{OCCURRENCE_PREFIX}.' exist"
422                ));
423                out.preserved.push(raw.trim_end().to_string());
424                continue;
425            }
426        };
427        let field = field.trim();
428        if field.is_empty() {
429            out.problems
430                .push(format!("line {number}: '{rest}' names no field"));
431            out.preserved.push(raw.trim_end().to_string());
432            continue;
433        }
434        let column = BomColumn {
435            scope,
436            field: field.to_string(),
437            shown,
438        };
439        let key = column.key();
440        if !seen.insert(key.clone()) {
441            out.problems
442                .push(format!("line {number}: '{key}' is already configured above"));
443            continue;
444        }
445        out.columns.push(column);
446    }
447    out
448}
449
450/// Write a column list back out as settings text, re-emitting the preserved
451/// (comment / malformed) lines above it so an automated rewrite — a header
452/// drag, a hide — can never eat something the user typed. `frozen` re-emits the
453/// `-` marker after that many columns, so dragging a column across the freeze
454/// boundary writes itself down like any other reorder.
455pub fn serialize(columns: &[BomColumn], preserved: &[String], frozen: Option<usize>) -> String {
456    let mut out = String::new();
457    for line in preserved {
458        out.push_str(line);
459        out.push('\n');
460    }
461    for (index, column) in columns.iter().enumerate() {
462        if frozen == Some(index) {
463            out.push_str(FREEZE_MARKER);
464            out.push('\n');
465        }
466        if column.shown {
467            out.push('*');
468        }
469        out.push_str(&column.key());
470        out.push('\n');
471    }
472    // A marker BELOW every column (everything frozen) is legitimate and must
473    // survive the round trip.
474    if frozen.is_some_and(|at| at >= columns.len()) {
475        out.push_str(FREEZE_MARKER);
476        out.push('\n');
477    }
478    out
479}
480
481/// The widget's column SPECS for a parsed configuration: the structural tree
482/// column first, then every configured column (hidden ones included — the
483/// widget's own hide list decides what is drawn, so a hidden column is still
484/// offered in the right-click checklist).
485pub fn column_specs(parsed: &ParsedColumns) -> Vec<ColumnSpec> {
486    let mut specs = vec![
487        ColumnSpec::new(ITEM_KEY, "Item", CellKind::ReadOnly).width(190.0),
488        ColumnSpec::new(VISIBLE_KEY, "", CellKind::Toggle).width(26.0),
489        ColumnSpec::new(FLAGS_KEY, "", CellKind::Badges).width(52.0),
490    ];
491    for column in &parsed.columns {
492        let width = match column.kind() {
493            CellKind::ReadOnly => 60.0,
494            CellKind::Numeric { .. } => 70.0,
495            _ => 120.0,
496        };
497        specs.push(ColumnSpec::new(column.key(), column.label(), column.kind()).width(width));
498    }
499    specs.push(
500        ColumnSpec::new(
501            ACTIONS_KEY,
502            "",
503            // The MENU trigger, not one action: the ellipsis says "there is a
504            // list behind this", which a pencil did not.
505            CellKind::Actions {
506                label: "\u{22EF}".to_string(),
507            },
508        )
509        .width(34.0),
510    );
511    specs
512}
513
514/// The widget LAYOUT for a parsed configuration: the text's order, and its
515/// unstarred columns hidden. Widths and sort are the caller's — they are
516/// session state, not configuration, so they are deliberately not written into
517/// the user's text.
518pub fn layout_from(parsed: &ParsedColumns, keep: &ColumnLayout) -> ColumnLayout {
519    let mut order = vec![
520        ITEM_KEY.to_string(),
521        VISIBLE_KEY.to_string(),
522        FLAGS_KEY.to_string(),
523    ];
524    let mut hidden = HashSet::new();
525    for column in &parsed.columns {
526        let key = column.key();
527        if !column.shown {
528            hidden.insert(key.clone());
529        }
530        order.push(key);
531    }
532    order.push(ACTIONS_KEY.to_string());
533    ColumnLayout {
534        order,
535        hidden,
536        widths: keep.widths.clone(),
537        sort: keep.sort.clone(),
538        // +1 for the structural Item column, which leads the order and is
539        // always inside the frozen band when there is a marker at all.
540        frozen: parsed.frozen.map_or(STRUCTURAL_LEADING, |above| above + STRUCTURAL_LEADING),
541    }
542}
543
544/// The freeze marker's place in the settings text for a layout the USER changed
545/// by dragging: how many CONFIGURED columns fall inside the frozen band (the
546/// structural Item / Actions columns have no line of their own). `None` when
547/// nothing is frozen, so a document that never froze anything keeps a text with
548/// no marker in it.
549/// The structural columns that always lead the arrangement: Item, the
550/// visibility toggle and the status badges. They have no lines in the settings
551/// text, so every conversion between "columns the user typed" and "columns the
552/// widget arranges" has to step over exactly this many.
553pub const STRUCTURAL_LEADING: usize = 3;
554
555pub fn frozen_from_layout(layout: &ColumnLayout) -> Option<usize> {
556    // The structural columns freeze IMPLICITLY — they are not configurable and
557    // have no lines — so a band holding only them is spelled by no marker at
558    // all. Emitting one here would invent a `-` in the user's text on the first
559    // resize of a table nobody had frozen anything in.
560    if layout.frozen <= STRUCTURAL_LEADING {
561        return None;
562    }
563    Some(
564        layout
565            .order
566            .iter()
567            .take(layout.frozen)
568            .filter(|key| {
569                !matches!(
570                    key.as_str(),
571                    ITEM_KEY | VISIBLE_KEY | FLAGS_KEY | ACTIONS_KEY
572                )
573            })
574            .count(),
575    )
576}
577
578/// Fold a layout the USER changed by dragging back into a column list, so it
579/// can be serialized into the settings text. The structural columns have no
580/// lines of their own, so they are skipped; a column the layout somehow does
581/// not name keeps its configured place at the end.
582pub fn columns_from_layout(parsed: &ParsedColumns, layout: &ColumnLayout) -> Vec<BomColumn> {
583    let mut out: Vec<BomColumn> = Vec::new();
584    for key in &layout.order {
585        if matches!(
586            key.as_str(),
587            ITEM_KEY | VISIBLE_KEY | FLAGS_KEY | ACTIONS_KEY
588        ) {
589            continue;
590        }
591        if let Some(column) = parsed.columns.iter().find(|column| &column.key() == key) {
592            out.push(BomColumn {
593                shown: !layout.hidden.contains(key),
594                ..column.clone()
595            });
596        }
597    }
598    for column in &parsed.columns {
599        if !out.iter().any(|kept| kept.key() == column.key()) {
600            out.push(BomColumn {
601                shown: !layout.hidden.contains(&column.key()),
602                ..column.clone()
603            });
604        }
605    }
606    out
607}
608
609// BREP private tests: 1c0a4b0e90918f44