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