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