Skip to main content

brep_render/engine_state/
bom.rs

1use super::*;
2use serde_json::Value;
3
4// ===========================================================================
5// BOM export (assemblies build-spec §9) — the parts list straight off the
6// MAIN-SIDE parts library + live component projection, both adopted from the
7// last applied run's reply (`pipeline::AssemblySync`). One row per
8// parts-library entry: `{partName, sourceKey, quantity}`
9// with quantity = live ACOMP instance count. A sub-assembly is ONE row at
10// this level — its internal parts are its own document's business (the
11// rigid-nesting model). Then one row per harness WIRE (plan decision 6: each
12// wire is its own BOM line): its stock part number as the part name, quantity
13// 1, and `{connectionId, mfQty, status}` — the cut length, or no number and
14// the reason ([`super::WireBomLine`]). Exported as CSV and JSON through the
15// file dialog's Export modal; deliberately small: no per-configuration
16// quantities, no extra columns, no localization.
17// ===========================================================================
18
19/// One BOM row: a parts-library entry + its live instance count, or a wire.
20/// Field order IS the exported JSON key order (serde serializes structs in
21/// declaration order), so a part row stays `{partName, sourceKey, quantity}`
22/// and a wire row adds `{connectionId, mfQty, status}`.
23#[derive(serde::Serialize)]
24struct BomRow {
25    #[serde(rename = "partName")]
26    part_name: String,
27    #[serde(rename = "sourceKey")]
28    source_key: String,
29    quantity: usize,
30    #[serde(flatten, skip_serializing_if = "Option::is_none")]
31    wire: Option<WireFields>,
32}
33
34/// A wire row's own fields. `mfQty` is `null` — never absent, never stale —
35/// when the run on hand cannot give a cut length; `status` says why.
36#[derive(serde::Serialize)]
37struct WireFields {
38    #[serde(rename = "connectionId")]
39    connection_id: String,
40    #[serde(rename = "mfQty")]
41    mf_qty: Option<f64>,
42    status: String,
43}
44
45/// The CSV header: the part columns, then the wire columns (blank on a part
46/// row). One header for every document, so a reader never guesses the shape.
47const BOM_CSV_HEADER: &str = "partName,sourceKey,quantity,connectionId,mfQty,status\n";
48
49/// Quote a CSV field only when it needs it (comma / quote / CR / LF),
50/// doubling embedded quotes — minimal RFC-4180 so a part name or store path
51/// with a comma can never shear a row.
52fn csv_field(text: &str) -> String {
53    if text.contains([',', '"', '\n', '\r']) {
54        format!("\"{}\"", text.replace('"', "\"\""))
55    } else {
56        text.to_string()
57    }
58}
59
60impl EngineState {
61    /// The BOM rows: parts in parts-library order (BTreeMap ⇒ alphabetical
62    /// part name — deterministic), then wires in the harness block's order.
63    /// Quantity counts component RECORDS (instances), not member solids, so a
64    /// multi-body part is still one per placement; a wire is always one. Errs
65    /// (`"no components or harness wires in the document"`) when there is
66    /// neither — a harness-only document is a BOM of wires.
67    fn bom_rows(&mut self) -> Result<Vec<BomRow>, String> {
68        let wires = self.wire_bom_lines();
69        if self.assembly_components.is_empty() && wires.is_empty() {
70            return Err("no components or harness wires in the document".into());
71        }
72        let mut rows: Vec<BomRow> = Vec::new();
73        if !self.assembly_components.is_empty() {
74            let library: std::collections::BTreeMap<String, Value> =
75                serde_json::from_str(&brep_kernel::parts_library_json())
76                    .map_err(|error| format!("parts library unreadable: {error}"))?;
77            rows.extend(
78                library
79                    .into_iter()
80                    .map(|(part_name, entry)| {
81                        let quantity = self
82                            .assembly_components
83                            .iter()
84                            .filter(|record| record.part_name == part_name)
85                            .count();
86                        BomRow {
87                            source_key: entry
88                                .get("sourceKey")
89                                .and_then(Value::as_str)
90                                .unwrap_or_default()
91                                .to_string(),
92                            part_name,
93                            quantity,
94                            wire: None,
95                        }
96                    })
97                    // The kernel GCs zero-instance entries at the end of every
98                    // run; the filter keeps the export honest mid-mutation
99                    // regardless.
100                    .filter(|row| row.quantity > 0),
101            );
102        }
103        rows.extend(wires.into_iter().map(|line| BomRow {
104            part_name: line.stock_part_number,
105            source_key: String::new(),
106            quantity: 1,
107            wire: Some(WireFields {
108                connection_id: line.connection_id,
109                mf_qty: line.mf_qty,
110                status: line.state.as_str().to_string(),
111            }),
112        }));
113        Ok(rows)
114    }
115
116    /// Export the BOM as CSV: the exact [`BOM_CSV_HEADER`], one line per row,
117    /// LF endings. A cut length is written to three decimals; a wire without
118    /// one leaves `mfQty` empty and names the reason in `status`.
119    pub fn export_bom_csv(&mut self) -> Result<String, String> {
120        let mut out = String::from(BOM_CSV_HEADER);
121        for row in self.bom_rows()? {
122            let (connection, mf_qty, status) = match &row.wire {
123                Some(wire) => (
124                    csv_field(&wire.connection_id),
125                    wire.mf_qty.map(|qty| format!("{qty:.3}")).unwrap_or_default(),
126                    wire.status.clone(),
127                ),
128                None => (String::new(), String::new(), String::new()),
129            };
130            out.push_str(&format!(
131                "{},{},{},{},{},{}\n",
132                csv_field(&row.part_name),
133                csv_field(&row.source_key),
134                row.quantity,
135                connection,
136                mf_qty,
137                status
138            ));
139        }
140        Ok(out)
141    }
142
143    /// Export the BOM as a JSON array of the same records — the CSV sibling of
144    /// [`Self::export_bom_csv`], with the cut length at full precision.
145    pub fn export_bom_json(&mut self) -> Result<String, String> {
146        serde_json::to_string(&self.bom_rows()?)
147            .map_err(|error| format!("BOM serialize: {error}"))
148    }
149
150    // ======================================================================
151    // BOM ATTRIBUTES — the editable columns behind the BOM panel
152    //
153    // Two stores, because the data has two lifetimes:
154    //
155    // * PART attributes (Part Number, Material, Mass…) describe the PART, so
156    //   they live on the part's OWN document, under the top-level
157    //   [`PART_ATTRIBUTES`] key: `partsLibrary[part].document.partAttributes`.
158    //   Being on the part document means they travel WITH the part — the
159    //   write-through lane saves that same document back to its `sourceKey`,
160    //   so opening the part standalone shows the same Part Number. `History`
161    //   keeps unknown top-level document keys verbatim (`from_request_json`
162    //   parses into a `Value` and only lifts out the keys it owns), so the key
163    //   round-trips through save/open with no format work.
164    //
165    // * OCCURRENCE attributes (Item Number, Reference Designator, Find
166    //   Number…) describe ONE PLACEMENT, so they live on the placing ACOMP
167    //   feature, under [`OCCURRENCE_ATTRIBUTES`] —
168    //   `feature.inputParams.bom`. NESTED rather than flat so a user-added
169    //   custom column can never collide with a schema param (`isFixed`,
170    //   `partName`, `transform`); the ACOMP builder reads its four keys by
171    //   name and ignores the rest, and the solver write-back fold
172    //   (`assembly_apply_document_json`) INSERTS `transform`/`isFixed` into the
173    //   existing params object rather than rebuilding it, so a solve never
174    //   strips this.
175    //
176    // Quantity is deliberately absent from both: it is DERIVED (how many
177    // occurrences a packed row rolls up), never stored, so it can never
178    // disagree with the model.
179    // ======================================================================
180
181    /// Read a part's attribute record (`{}` when the part has none / is
182    /// unknown). Never errs — a BOM row for a part mid-import simply shows
183    /// blanks.
184    pub fn part_attributes(&self, part_name: &str) -> Value {
185        self.history
186            .parts_library()
187            .get(part_name)
188            .and_then(|entry| entry.get("document"))
189            .and_then(|document| document.get(PART_ATTRIBUTES))
190            .filter(|value| value.is_object())
191            .cloned()
192            .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
193    }
194
195    /// How many PMI annotations a PART's own document carries, summed over its
196    /// views. `0` for a part with no PMI block and for an unknown part.
197    ///
198    /// A STEP assembly import puts a part-scoped dimension or tolerance on the
199    /// PART (once, however many instances place it — the exporter's own rule),
200    /// so it is not in the open document's PMI block and the PMI panel, which
201    /// shows that block, never mentions it. The BOM is where a reader sees it:
202    /// this is the parts list saying "the bolt is toleranced", with the
203    /// annotations themselves one Open Part away.
204    pub fn part_pmi_count(&self, part_name: &str) -> usize {
205        self.history
206            .parts_library()
207            .get(part_name)
208            .and_then(|entry| entry.get("document"))
209            .and_then(|document| document.get("pmi"))
210            .and_then(|pmi| pmi.get("views"))
211            .and_then(Value::as_array)
212            .map(|views| {
213                views
214                    .iter()
215                    .filter_map(|view| view.get("annotations").and_then(Value::as_array))
216                    .map(|annotations| annotations.len())
217                    .sum()
218            })
219            .unwrap_or(0)
220    }
221
222    /// A part's `(sourceKey, sourceSignature)` — what the app's write-through
223    /// lane needs to decide whether the file on disk is still the one this
224    /// entry was built from. `None` for an unknown part; the key is returned
225    /// even when EMPTY (embedded-only), because "" is exactly what the
226    /// write-through lane checks for.
227    pub fn part_source(&self, part_name: &str) -> Option<(String, String)> {
228        let entry = self.history.parts_library().get(part_name)?;
229        Some((
230            entry
231                .get("sourceKey")
232                .and_then(Value::as_str)
233                .unwrap_or_default()
234                .to_string(),
235            entry
236                .get("sourceSignature")
237                .and_then(Value::as_str)
238                .unwrap_or_default()
239                .to_string(),
240        ))
241    }
242
243    /// A part's embedded document as text — the payload the app writes through
244    /// to the part's `sourceKey` after an attribute edit.
245    pub fn part_document_json(&self, part_name: &str) -> Option<String> {
246        self.history
247            .parts_library()
248            .get(part_name)
249            .and_then(|entry| entry.get("document"))
250            .map(|document| document.to_string())
251    }
252
253    /// Write ONE part attribute. `Value::Null` (or an empty string) REMOVES the
254    /// key, so clearing a cell leaves no `""` litter in the saved document.
255    ///
256    /// # Why this is not just a document edit
257    ///
258    /// The document's `partsLibrary` block is a MIRROR of the kernel's
259    /// main-side store, not the truth: a run that heals or GCs the store writes
260    /// its result back over the block (`apply_run_output`), and the per-run
261    /// request does not carry the block at all ([`History::prefix_request`]
262    /// omits it). So a change written only into the block is erased by the next
263    /// such run. This therefore
264    /// writes BOTH: the kernel store (via `refresh_library_entry`, which is the
265    /// same door edit-in-place and update-components use) and the document
266    /// block + undo checkpoint. [`Self::undo`] re-installs the rewound block
267    /// into the store, which is what makes the pair rewind together.
268    ///
269    /// The `snapshot` is KEPT: an attribute is not geometry, so there is
270    /// nothing to re-evaluate. `refresh_library_entry` sees that the new
271    /// document builds the same part (`brep_kernel::same_build`) and leaves the
272    /// entry clean, so the rerun serves every instance from the snapshot. Before
273    /// it did, each attribute edit re-executed the whole part: 23 s for a Mass
274    /// edit on a herringbone gear. The panel still commits a text cell on
275    /// focus-loss rather than per keystroke, since each commit is a run.
276    pub fn set_part_attribute(
277        &mut self,
278        part_name: &str,
279        key: &str,
280        value: Value,
281    ) -> Result<(), String> {
282        if key.is_empty() {
283            return Err("part attribute: empty key".to_string());
284        }
285        let mut block = self.history.parts_library().clone();
286        let entry = block
287            .get_mut(part_name)
288            .ok_or_else(|| format!("no parts-library entry '{part_name}'"))?;
289        let document = entry
290            .get_mut("document")
291            .filter(|value| value.is_object())
292            .ok_or_else(|| format!("part '{part_name}': malformed document"))?;
293        write_attribute(document, PART_ATTRIBUTES, key, value)?;
294        let document_text = document.to_string();
295        let signature = super::document_signature(&document_text);
296        entry["sourceSignature"] = Value::String(signature.clone());
297
298        // The kernel store is the block's authority — write it there too, or
299        // the next run that touches the library mirrors the OLD entry back over
300        // this edit.
301        brep_kernel::refresh_library_entry_impl(part_name, &signature, &document_text)
302            .map_err(|error| format!("part '{part_name}': {error}"))?;
303        self.history
304            .set_parts_library_edited(block, Some(&format!("partattr:{part_name}:{key}")));
305        self.rerun_history();
306        Ok(())
307    }
308
309    // --- The document's OWN part attributes -------------------------------
310    //
311    // The same `partAttributes` record, on the document you have OPEN rather
312    // than on a library entry's embedded one. A part document IS a part, so it
313    // carries the BOM data of the part it describes — and an assembly document
314    // does too, because a rigidly nested assembly is one BOM row in its parent.
315    // The toolbar's Properties dialog is the door; the BOM panel's part columns
316    // are the other one, onto the same key of a different document.
317
318    /// The open document's own attribute record (`{}` when it has none).
319    /// Never errs — a document that was never annotated simply reads blank.
320    pub fn document_part_attributes(&self) -> Value {
321        self.history
322            .part_attributes_block()
323            .filter(|value| value.is_object())
324            .cloned()
325            .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
326    }
327
328    /// Write ONE attribute on the open document. `Value::Null` (or an empty
329    /// string) REMOVES the key, and the last key takes the empty record with
330    /// it — [`write_attribute`]'s rules, shared with the library-entry lane, so
331    /// a document that was never annotated serializes exactly as before.
332    ///
333    /// Unlike [`Self::set_part_attribute`] there is no kernel store to keep in
334    /// step: this record belongs to the document in hand, not to a
335    /// parts-library mirror, so the write is document + undo checkpoint and
336    /// nothing else. The re-run that follows is not for geometry — the history
337    /// is unchanged, so every feature is a cache hit — it is what moves the
338    /// applied-run generation, which is what refreshes the tab's dirty dot.
339    pub fn set_document_part_attribute(
340        &mut self,
341        key: &str,
342        value: Value,
343    ) -> Result<(), String> {
344        if key.is_empty() {
345            return Err("part attribute: empty key".to_string());
346        }
347        // Rebuild the record through the SHARED writer by handing it an owner
348        // shaped like the document, so create / clear / drop-empty behave
349        // identically on both doors rather than being written twice.
350        let mut owner = Value::Object(serde_json::Map::new());
351        if let Some(block) = self.history.part_attributes_block() {
352            let block = block.clone();
353            if let Some(object) = owner.as_object_mut() {
354                object.insert(PART_ATTRIBUTES.to_string(), block);
355            }
356        }
357        write_attribute(&mut owner, PART_ATTRIBUTES, key, value)?;
358        let block = owner
359            .as_object_mut()
360            .and_then(|object| object.remove(PART_ATTRIBUTES));
361        self.history
362            .set_part_attributes_block(block, Some(&format!("docpartattr:{key}")));
363        self.rerun_history();
364        Ok(())
365    }
366
367    /// Read ONE occurrence's attribute record (`{}` when it has none).
368    pub fn occurrence_attributes(&self, component_id: &str) -> Value {
369        self.history
370            .index_of(component_id)
371            .and_then(|index| self.history.feature_params(index))
372            .and_then(|params| params.get(OCCURRENCE_ATTRIBUTES).cloned())
373            .filter(Value::is_object)
374            .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
375    }
376
377    /// Every ACOMP's `occurrenceAttributes` in ONE pass over the history,
378    /// keyed by component id. The BOM reads all of them every frame, and the
379    /// per-id [`Self::occurrence_attributes`] resolves its id with a linear
380    /// `index_of` scan — once per row that is quadratic in the component count
381    /// (70 ms a frame at 633 components). A component with no attributes maps
382    /// to an empty object, so a lookup here reads exactly what the per-id
383    /// accessor answers.
384    pub fn occurrence_attributes_all(&self) -> std::collections::HashMap<String, Value> {
385        (0..self.history.len())
386            .filter(|&index| {
387                self.history
388                    .feature_type(index)
389                    .is_some_and(|ty| super::components::is_acomp_feature_type(&ty))
390            })
391            .filter_map(|index| {
392                let id = self.history.feature_id(index)?;
393                let attributes = self
394                    .history
395                    .feature_params(index)
396                    .and_then(|params| params.get(OCCURRENCE_ATTRIBUTES).cloned())
397                    .filter(Value::is_object)
398                    .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
399                Some((id, attributes))
400            })
401            .collect()
402    }
403
404    /// Write ONE occurrence attribute across `component_ids` — ONE undo step
405    /// however many ids there are.
406    ///
407    /// The fan-out lane: a PACKED BOM row rolls up every occurrence of a part
408    /// whose occurrence data matches, and editing that row's cell must apply to
409    /// all of them. One id (the unpacked case) is the same call with a
410    /// one-element slice, so there is no second code path to keep in step.
411    /// `Value::Null` / `""` removes the key.
412    pub fn set_occurrence_attribute(
413        &mut self,
414        component_ids: &[String],
415        key: &str,
416        value: Value,
417    ) -> Result<(), String> {
418        if key.is_empty() {
419            return Err("occurrence attribute: empty key".to_string());
420        }
421        let mut edits: Vec<(String, Value)> = Vec::with_capacity(component_ids.len());
422        for id in component_ids {
423            let index = self
424                .history
425                .index_of(id)
426                .ok_or_else(|| format!("no component feature '{id}'"))?;
427            let mut params = self
428                .history
429                .feature_params(index)
430                .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
431            if !params.is_object() {
432                return Err(format!("component '{id}': malformed inputParams"));
433            }
434            write_attribute(&mut params, OCCURRENCE_ATTRIBUTES, key, value.clone())?;
435            edits.push((id.clone(), params));
436        }
437        // The BOM owns eCAD reference designators. Validate and rename their
438        // snapshots in the same document checkpoint as the occurrence edits.
439        if key == "Reference_Designator" {
440            let mut document: Value = serde_json::from_str(&self.history.request_json())
441                .map_err(|e| e.to_string())?;
442            let mut renames = Vec::new();
443            for (id, params) in &edits {
444                let feature = document["features"].as_array_mut().unwrap().iter_mut()
445                    .find(|f| f["inputParams"]["id"].as_str() == Some(id)).unwrap();
446                let old = feature["inputParams"][OCCURRENCE_ATTRIBUTES][key].as_str().unwrap_or("").to_owned();
447                let part = params["partName"].as_str().unwrap_or("").to_owned();
448                let new = params[OCCURRENCE_ATTRIBUTES][key].as_str().unwrap_or("").trim().to_owned();
449                renames.push((id.clone(), part, old, if new.is_empty() { id.clone() } else { new }));
450                feature["inputParams"] = params.clone();
451            }
452            // A sheet component's label follows the designator of the
453            // occurrence it was placed from — INCLUDING a nested one, whose
454            // label is the `/`-joined PATH of designators (`A1/J1`) and whose
455            // occurrence is a CHAIN (`ACOMP9:ACOMP1`). The BOM addresses
456            // top-level features alone (`History::index_of`), so the segment a
457            // row owns is the FIRST one, and the rest of the path is the part
458            // document's, untouched here.
459            let relabel = |instance: Option<&str>, part_key: &str, reference: &str| -> Option<String> {
460                renames.iter().find_map(|(id, part, old, new)| match instance {
461                    // The chain is the identity and the key is not consulted:
462                    // a nested component's key is a key of ITS parent's
463                    // library and can never equal this row's part.
464                    Some(chain) if chain == id => Some(new.clone()),
465                    // A label with NO path has no segment this row owns, and
466                    // that is a real shape: `Document::relink` binds a
467                    // component to a nested device and deliberately keeps what
468                    // it is called. Renaming the outer occurrence must not
469                    // overwrite the whole label with its designator.
470                    Some(chain) if chain.strip_prefix(id.as_str()).is_some_and(|rest| rest.starts_with(':')) =>
471                        reference.split_once('/').map(|(_, rest)| format!("{new}/{rest}")),
472                    Some(_) => None,
473                    // Legacy, drawn before a component carried an occurrence:
474                    // matched by its key and its label, as it always was.
475                    None => (part_key == part && reference == old).then(|| new.clone()),
476                })
477            };
478            for block in ["diagram", "pcb"] {
479                let Some(components) = document[block]["components"].as_array() else { continue; };
480                let affected = components.iter().filter(|c| c["part"].is_object()).any(|component| relabel(
481                    component["part"]["instance"].as_str(),
482                    component["part"]["key"].as_str().unwrap_or(""),
483                    component["reference"].as_str().unwrap_or("")).is_some());
484                if !affected { continue; }
485                // The schema owner refuses future versions and malformed blocks
486                // before either the BOM or the sheet can be changed.
487                let mut sheet = brep_ecad_core::Document::from_value(document[block].clone())
488                    .map_err(|e| format!("{block}: {e}"))?;
489                let expected = if block == "diagram" { brep_ecad_core::DocumentKind::Wiring } else { brep_ecad_core::DocumentKind::Schematic };
490                if sheet.kind != expected { return Err(format!("{block}: wrong document kind")); }
491                let changes: Vec<_> = sheet.components.iter().filter_map(|component| {
492                    let source = component.part.as_ref()?;
493                    let new = relabel(source.instance.as_deref(), &source.key, &component.reference)?;
494                    Some((component.id, new))
495                }).collect();
496                for (id, reference) in changes { sheet.set_reference(id, &reference)?; }
497                document[block] = sheet.to_value()?;
498            }
499            self.edit_document_json(&document.to_string())?;
500            return Ok(());
501        }
502        self.update_many_feature_params(&edits)?;
503        Ok(())
504    }
505}
506
507/// The part document's attribute-record key (see the module's BOM-attributes
508/// block). A top-level document key, so it rides save/open untouched. Defined
509/// by the kernel, which reads the record's `Part_Number` for `PRODUCT.id` on a
510/// structured STEP export.
511pub use brep_kernel::PART_ATTRIBUTES;
512
513/// The ACOMP `inputParams` attribute-record key.
514pub const OCCURRENCE_ATTRIBUTES: &str = "bom";
515
516/// Set (or, for a null/empty value, REMOVE) `record[key]` inside `owner`'s
517/// attribute record, creating the record on first write and dropping it again
518/// when the last attribute goes — so a document that was never annotated
519/// serializes exactly as it did before (the `metadata` field's convention).
520fn write_attribute(
521    owner: &mut Value,
522    record_key: &str,
523    key: &str,
524    value: Value,
525) -> Result<(), String> {
526    let object = owner
527        .as_object_mut()
528        .ok_or_else(|| "attribute owner is not an object".to_string())?;
529    let clearing = matches!(&value, Value::Null)
530        || matches!(&value, Value::String(text) if text.is_empty());
531    if clearing {
532        let mut empty = false;
533        if let Some(record) = object.get_mut(record_key).and_then(Value::as_object_mut) {
534            record.remove(key);
535            empty = record.is_empty();
536        }
537        if empty {
538            object.remove(record_key);
539        }
540        return Ok(());
541    }
542    let record = object
543        .entry(record_key.to_string())
544        .or_insert_with(|| Value::Object(serde_json::Map::new()));
545    if !record.is_object() {
546        *record = Value::Object(serde_json::Map::new());
547    }
548    record
549        .as_object_mut()
550        .expect("just normalized to an object")
551        .insert(key.to_string(), value);
552    Ok(())
553}
554