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