Skip to main content

brep_render/engine_state/
bom.rs

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