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 already warm via
7// the assembly sync (`sync_assembly` re-executes main-side after each applied
8// run). One row per 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). Exported as CSV and JSON through the file dialog's
12// Export modal; deliberately small: no per-configuration quantities, no
13// extra columns, no localization.
14// ===========================================================================
15
16/// One BOM row: a parts-library entry + its live instance count. Field order
17/// IS the exported JSON key order (serde serializes structs in declaration
18/// order), so the record shape stays `{partName, sourceKey, quantity}`.
19#[derive(serde::Serialize)]
20struct BomRow {
21    #[serde(rename = "partName")]
22    part_name: String,
23    #[serde(rename = "sourceKey")]
24    source_key: String,
25    quantity: usize,
26}
27
28/// Quote a CSV field only when it needs it (comma / quote / CR / LF),
29/// doubling embedded quotes — minimal RFC-4180 so a part name or store path
30/// with a comma can never shear a row.
31fn csv_field(text: &str) -> String {
32    if text.contains([',', '"', '\n', '\r']) {
33        format!("\"{}\"", text.replace('"', "\"\""))
34    } else {
35        text.to_string()
36    }
37}
38
39impl EngineState {
40    /// The BOM rows in parts-library order (BTreeMap ⇒ alphabetical part
41    /// name — deterministic). Quantity counts component RECORDS (instances),
42    /// not member solids, so a multi-body part is still one per placement.
43    /// Errs (`"no components in the assembly"`) on a componentless document.
44    fn bom_rows(&mut self) -> Result<Vec<BomRow>, String> {
45        self.ensure_assembly_synced();
46        if self.assembly_components.is_empty() {
47            return Err("no components in the assembly".into());
48        }
49        let library: std::collections::BTreeMap<String, Value> =
50            serde_json::from_str(&brep_kernel::parts_library_json())
51                .map_err(|error| format!("parts library unreadable: {error}"))?;
52        Ok(library
53            .into_iter()
54            .map(|(part_name, entry)| {
55                let quantity = self
56                    .assembly_components
57                    .iter()
58                    .filter(|record| record.part_name == part_name)
59                    .count();
60                BomRow {
61                    source_key: entry
62                        .get("sourceKey")
63                        .and_then(Value::as_str)
64                        .unwrap_or_default()
65                        .to_string(),
66                    part_name,
67                    quantity,
68                }
69            })
70            // The kernel GCs zero-instance entries at the end of every run;
71            // the filter keeps the export honest mid-mutation regardless.
72            .filter(|row| row.quantity > 0)
73            .collect())
74    }
75
76    /// Export the assembly BOM as CSV: the exact `partName,sourceKey,quantity`
77    /// header, one line per parts-library entry, LF endings.
78    pub fn export_bom_csv(&mut self) -> Result<String, String> {
79        let mut out = String::from("partName,sourceKey,quantity\n");
80        for row in self.bom_rows()? {
81            out.push_str(&format!(
82                "{},{},{}\n",
83                csv_field(&row.part_name),
84                csv_field(&row.source_key),
85                row.quantity
86            ));
87        }
88        Ok(out)
89    }
90
91    /// Export the assembly BOM as a JSON array of the same records — the CSV
92    /// sibling of [`Self::export_bom_csv`].
93    pub fn export_bom_json(&mut self) -> Result<String, String> {
94        serde_json::to_string(&self.bom_rows()?)
95            .map_err(|error| format!("BOM serialize: {error}"))
96    }
97
98    // ======================================================================
99    // BOM ATTRIBUTES — the editable columns behind the BOM panel
100    //
101    // Two stores, because the data has two lifetimes:
102    //
103    // * PART attributes (Part Number, Material, Mass…) describe the PART, so
104    //   they live on the part's OWN document, under the top-level
105    //   [`PART_ATTRIBUTES`] key: `partsLibrary[part].document.partAttributes`.
106    //   Being on the part document means they travel WITH the part — the
107    //   write-through lane saves that same document back to its `sourceKey`,
108    //   so opening the part standalone shows the same Part Number. `History`
109    //   keeps unknown top-level document keys verbatim (`from_request_json`
110    //   parses into a `Value` and only lifts out the keys it owns), so the key
111    //   round-trips through save/open with no format work.
112    //
113    // * OCCURRENCE attributes (Item Number, Reference Designator, Find
114    //   Number…) describe ONE PLACEMENT, so they live on the placing ACOMP
115    //   feature, under [`OCCURRENCE_ATTRIBUTES`] —
116    //   `feature.inputParams.bom`. NESTED rather than flat so a user-added
117    //   custom column can never collide with a schema param (`isFixed`,
118    //   `partName`, `transform`); the ACOMP builder reads its four keys by
119    //   name and ignores the rest, and the solver write-back fold
120    //   (`assembly_apply_document_json`) INSERTS `transform`/`isFixed` into the
121    //   existing params object rather than rebuilding it, so a solve never
122    //   strips this.
123    //
124    // Quantity is deliberately absent from both: it is DERIVED (how many
125    // occurrences a packed row rolls up), never stored, so it can never
126    // disagree with the model.
127    // ======================================================================
128
129    /// Read a part's attribute record (`{}` when the part has none / is
130    /// unknown). Never errs — a BOM row for a part mid-import simply shows
131    /// blanks.
132    pub fn part_attributes(&self, part_name: &str) -> Value {
133        self.history
134            .parts_library()
135            .get(part_name)
136            .and_then(|entry| entry.get("document"))
137            .and_then(|document| document.get(PART_ATTRIBUTES))
138            .filter(|value| value.is_object())
139            .cloned()
140            .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
141    }
142
143    /// A part's `(sourceKey, sourceSignature)` — what the app's write-through
144    /// lane needs to decide whether the file on disk is still the one this
145    /// entry was built from. `None` for an unknown part; the key is returned
146    /// even when EMPTY (embedded-only), because "" is exactly what the
147    /// write-through lane checks for.
148    pub fn part_source(&self, part_name: &str) -> Option<(String, String)> {
149        let entry = self.history.parts_library().get(part_name)?;
150        Some((
151            entry
152                .get("sourceKey")
153                .and_then(Value::as_str)
154                .unwrap_or_default()
155                .to_string(),
156            entry
157                .get("sourceSignature")
158                .and_then(Value::as_str)
159                .unwrap_or_default()
160                .to_string(),
161        ))
162    }
163
164    /// A part's embedded document as text — the payload the app writes through
165    /// to the part's `sourceKey` after an attribute edit.
166    pub fn part_document_json(&self, part_name: &str) -> Option<String> {
167        self.history
168            .parts_library()
169            .get(part_name)
170            .and_then(|entry| entry.get("document"))
171            .map(|document| document.to_string())
172    }
173
174    /// Write ONE part attribute. `Value::Null` (or an empty string) REMOVES the
175    /// key, so clearing a cell leaves no `""` litter in the saved document.
176    ///
177    /// # Why this is not just a document edit
178    ///
179    /// The document's `partsLibrary` block is a MIRROR of the kernel's
180    /// main-side store, not the truth: `sync_assembly` re-serializes the store
181    /// over the block after every run, and the per-run request does not carry
182    /// the block at all ([`History::prefix_request`] omits it). So a change
183    /// written only into the block is erased by the next sync. This therefore
184    /// writes BOTH: the kernel store (via `refresh_library_entry`, which is the
185    /// same door edit-in-place and update-components use) and the document
186    /// block + undo checkpoint. [`Self::undo`] re-installs the rewound block
187    /// into the store, which is what makes the pair rewind together.
188    ///
189    /// The `snapshot` is deliberately KEPT: an attribute is not geometry, so
190    /// there is nothing to re-evaluate. `refresh_library_entry` still marks the
191    /// entry dirty (its contract), so the ACOMP self-heal re-derives it on the
192    /// next run — correct, just not free. That is the price of the attributes
193    /// living on the part document, and it is why the panel commits a text cell
194    /// on focus-loss rather than per keystroke.
195    pub fn set_part_attribute(
196        &mut self,
197        part_name: &str,
198        key: &str,
199        value: Value,
200    ) -> Result<(), String> {
201        if key.is_empty() {
202            return Err("part attribute: empty key".to_string());
203        }
204        let mut block = self.history.parts_library().clone();
205        let entry = block
206            .get_mut(part_name)
207            .ok_or_else(|| format!("no parts-library entry '{part_name}'"))?;
208        let document = entry
209            .get_mut("document")
210            .filter(|value| value.is_object())
211            .ok_or_else(|| format!("part '{part_name}': malformed document"))?;
212        write_attribute(document, PART_ATTRIBUTES, key, value)?;
213        let document_text = document.to_string();
214        let signature = super::document_signature(&document_text);
215        entry["sourceSignature"] = Value::String(signature.clone());
216
217        // The kernel store is the block's authority — write it there too, or
218        // the next `sync_assembly` mirrors the OLD entry back over this edit.
219        brep_kernel::refresh_library_entry(part_name, &signature, &document_text)
220            .map_err(|error| format!("part '{part_name}': {error:?}"))?;
221        self.history
222            .set_parts_library_edited(block, Some(&format!("partattr:{part_name}:{key}")));
223        self.rerun_history();
224        Ok(())
225    }
226
227    // --- The document's OWN part attributes -------------------------------
228    //
229    // The same `partAttributes` record, on the document you have OPEN rather
230    // than on a library entry's embedded one. A part document IS a part, so it
231    // carries the BOM data of the part it describes — and an assembly document
232    // does too, because a rigidly nested assembly is one BOM row in its parent.
233    // The toolbar's Properties dialog is the door; the BOM panel's part columns
234    // are the other one, onto the same key of a different document.
235
236    /// The open document's own attribute record (`{}` when it has none).
237    /// Never errs — a document that was never annotated simply reads blank.
238    pub fn document_part_attributes(&self) -> Value {
239        self.history
240            .part_attributes_block()
241            .filter(|value| value.is_object())
242            .cloned()
243            .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
244    }
245
246    /// Write ONE attribute on the open document. `Value::Null` (or an empty
247    /// string) REMOVES the key, and the last key takes the empty record with
248    /// it — [`write_attribute`]'s rules, shared with the library-entry lane, so
249    /// a document that was never annotated serializes exactly as before.
250    ///
251    /// Unlike [`Self::set_part_attribute`] there is no kernel store to keep in
252    /// step: this record belongs to the document in hand, not to a
253    /// parts-library mirror, so the write is document + undo checkpoint and
254    /// nothing else. The re-run that follows is not for geometry — the history
255    /// is unchanged, so every feature is a cache hit — it is what moves the
256    /// applied-run generation, which is what refreshes the tab's dirty dot.
257    pub fn set_document_part_attribute(
258        &mut self,
259        key: &str,
260        value: Value,
261    ) -> Result<(), String> {
262        if key.is_empty() {
263            return Err("part attribute: empty key".to_string());
264        }
265        // Rebuild the record through the SHARED writer by handing it an owner
266        // shaped like the document, so create / clear / drop-empty behave
267        // identically on both doors rather than being written twice.
268        let mut owner = Value::Object(serde_json::Map::new());
269        if let Some(block) = self.history.part_attributes_block() {
270            let block = block.clone();
271            if let Some(object) = owner.as_object_mut() {
272                object.insert(PART_ATTRIBUTES.to_string(), block);
273            }
274        }
275        write_attribute(&mut owner, PART_ATTRIBUTES, key, value)?;
276        let block = owner
277            .as_object_mut()
278            .and_then(|object| object.remove(PART_ATTRIBUTES));
279        self.history
280            .set_part_attributes_block(block, Some(&format!("docpartattr:{key}")));
281        self.rerun_history();
282        Ok(())
283    }
284
285    /// Read ONE occurrence's attribute record (`{}` when it has none).
286    pub fn occurrence_attributes(&self, component_id: &str) -> Value {
287        self.history
288            .index_of(component_id)
289            .and_then(|index| self.history.feature_params(index))
290            .and_then(|params| params.get(OCCURRENCE_ATTRIBUTES).cloned())
291            .filter(Value::is_object)
292            .unwrap_or_else(|| Value::Object(serde_json::Map::new()))
293    }
294
295    /// Write ONE occurrence attribute across `component_ids` — ONE undo step
296    /// however many ids there are.
297    ///
298    /// The fan-out lane: a PACKED BOM row rolls up every occurrence of a part
299    /// whose occurrence data matches, and editing that row's cell must apply to
300    /// all of them. One id (the unpacked case) is the same call with a
301    /// one-element slice, so there is no second code path to keep in step.
302    /// `Value::Null` / `""` removes the key.
303    pub fn set_occurrence_attribute(
304        &mut self,
305        component_ids: &[String],
306        key: &str,
307        value: Value,
308    ) -> Result<(), String> {
309        if key.is_empty() {
310            return Err("occurrence attribute: empty key".to_string());
311        }
312        let mut edits: Vec<(String, Value)> = Vec::with_capacity(component_ids.len());
313        for id in component_ids {
314            let index = self
315                .history
316                .index_of(id)
317                .ok_or_else(|| format!("no component feature '{id}'"))?;
318            let mut params = self
319                .history
320                .feature_params(index)
321                .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
322            if !params.is_object() {
323                return Err(format!("component '{id}': malformed inputParams"));
324            }
325            write_attribute(&mut params, OCCURRENCE_ATTRIBUTES, key, value.clone())?;
326            edits.push((id.clone(), params));
327        }
328        self.update_many_feature_params(&edits)?;
329        Ok(())
330    }
331}
332
333/// The part document's attribute-record key (see the module's BOM-attributes
334/// block). A top-level document key, so it rides save/open untouched.
335pub const PART_ATTRIBUTES: &str = "partAttributes";
336
337/// The ACOMP `inputParams` attribute-record key.
338pub const OCCURRENCE_ATTRIBUTES: &str = "bom";
339
340/// Set (or, for a null/empty value, REMOVE) `record[key]` inside `owner`'s
341/// attribute record, creating the record on first write and dropping it again
342/// when the last attribute goes — so a document that was never annotated
343/// serializes exactly as it did before (the `metadata` field's convention).
344fn write_attribute(
345    owner: &mut Value,
346    record_key: &str,
347    key: &str,
348    value: Value,
349) -> Result<(), String> {
350    let object = owner
351        .as_object_mut()
352        .ok_or_else(|| "attribute owner is not an object".to_string())?;
353    let clearing = matches!(&value, Value::Null)
354        || matches!(&value, Value::String(text) if text.is_empty());
355    if clearing {
356        let mut empty = false;
357        if let Some(record) = object.get_mut(record_key).and_then(Value::as_object_mut) {
358            record.remove(key);
359            empty = record.is_empty();
360        }
361        if empty {
362            object.remove(record_key);
363        }
364        return Ok(());
365    }
366    let record = object
367        .entry(record_key.to_string())
368        .or_insert_with(|| Value::Object(serde_json::Map::new()));
369    if !record.is_object() {
370        *record = Value::Object(serde_json::Map::new());
371    }
372    record
373        .as_object_mut()
374        .expect("just normalized to an object")
375        .insert(key.to_string(), value);
376    Ok(())
377}
378
379// BREP private tests: e54c6e3cc5cd69f9