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 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