1use geop_core_program::{
37 numeric::{ParameterValue, read_parameter_values},
38 placed::instance_path,
39 reader::Reader,
40};
41use std::collections::HashMap;
42
43use geop_core_editor::output::Bounded;
44use geop_core_math::{
45 geop_error::{GeopError, GeopResult, WithContext},
46 scalars::Scalar,
47};
48use serde::{Deserialize, Serialize};
49
50use crate::{
51 notes_of,
52 parameters::{instances, read_properties, stem},
53 placed::solids::{Shapes, own_solids},
54};
55
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum Structure {
60 #[default]
63 Flat,
64 Indented,
67}
68
69#[derive(Clone, Debug, PartialEq, Serialize)]
71#[serde(tag = "kind", rename_all = "snake_case")]
72pub enum LineKind {
73 Part {
75 file: String,
77 parameters: String,
79 material: Option<String>,
81 thickness: Vec<f64>,
84 unit_mass: Option<Bounded>,
89 total_mass: Option<Bounded>,
91 error: Option<String>,
92 },
93 Wire {
95 cable: String,
97 gauge: Option<i32>,
99 diameter: f64,
101 colour: String,
103 cut_length: Bounded,
105 total_length: Bounded,
107 },
108}
109
110#[derive(Clone, Debug, PartialEq, Serialize)]
112pub struct Line {
113 pub item: String,
116 pub level: usize,
119 pub quantity: u64,
120 pub name: String,
122 pub designation: Option<String>,
124 #[serde(flatten)]
125 pub kind: LineKind,
126 #[serde(skip)]
132 pub placements: Vec<String>,
133}
134
135#[derive(Clone, Debug, PartialEq, Serialize)]
137pub struct Bom {
138 pub structure: Structure,
139 pub lines: Vec<Line>,
140 pub total_mass: Option<Bounded>,
144}
145
146fn key(reader: &Reader, file: &str) -> GeopResult<String> {
149 Ok(format!("{file}\n{}", parameters(reader)?))
150}
151
152fn parameters(reader: &Reader) -> GeopResult<String> {
155 Ok((read_parameter_values(reader)?.0.iter())
156 .filter(|(name, _)| !name.contains('.'))
158 .map(|(name, value)| match value {
159 ParameterValue::Text(row) => format!("{name}={row}"),
160 ParameterValue::Number(n) => format!("{name}={n}"),
161 })
162 .collect::<Vec<_>>()
163 .join(", "))
164}
165
166struct Info {
169 name: String,
170 file: String,
171 parameters: String,
172 designation: Option<String>,
173 material: Option<String>,
174 thickness: Vec<f64>,
175 own_bodies: bool,
178 own_mass: Weighed,
180}
181
182struct Group<'a> {
186 key: String,
187 reader: Reader<'a>,
188 file: String,
189 names: Vec<String>,
190}
191
192impl Group<'_> {
193 fn count(&self) -> u64 {
195 self.names.len() as u64
196 }
197}
198
199fn groups<'a>(reader: &Reader<'a>) -> GeopResult<Vec<Group<'a>>> {
201 let mut out: Vec<Group<'a>> = Vec::new();
202 for (name, instance) in instances(reader)? {
203 let placed = reader.placed_part(name)?;
204 let name = name.to_string();
205 let key = key(&placed, &instance.file)?;
206 match out.iter_mut().find(|g| g.key == key) {
207 Some(group) => group.names.push(name),
208 None => out.push(Group {
209 key,
210 reader: placed,
211 file: instance.file.clone(),
212 names: vec![name],
213 }),
214 }
215 }
216 Ok(out)
217}
218
219fn places_nothing(reader: &Reader) -> bool {
221 reader.instances().is_empty()
222}
223
224fn placed_in(paths: &[String], names: &[String]) -> Vec<String> {
227 paths
228 .iter()
229 .flat_map(|path| {
230 names.iter().map(move |name| match path.as_str() {
231 "" => name.clone(),
232 _ => instance_path(path, name),
233 })
234 })
235 .collect()
236}
237
238#[derive(Default)]
240struct Builder {
241 infos: HashMap<String, Info>,
242 whole: HashMap<String, Weighed>,
245 shapes: Shapes,
246}
247
248impl Builder {
249 fn info(&mut self, key: &str, reader: &Reader, file: &str) -> GeopResult<&Info> {
252 if !self.infos.contains_key(key) {
253 let info = self.work_out(reader, file).with_context(&|e: GeopError| {
254 e.with_context(format!("the bill of materials line of {file:?}"))
255 })?;
256 self.infos.insert(key.to_string(), info);
257 }
258 Ok(&self.infos[key])
259 }
260
261 fn work_out(&mut self, reader: &Reader, file: &str) -> GeopResult<Info> {
262 let properties = read_properties(reader)?;
263 let material = properties.material().map(|m| m.name.clone());
264 let bodies = own_solids(reader)?;
265 let mut thickness: Vec<f64> = bodies.iter().filter_map(|s| s.thickness()).collect();
266 thickness.sort_by(f64::total_cmp);
267 thickness.dedup();
268 let mut own_mass = Ok(Some(Scalar::ZERO));
269 for solid in &bodies {
270 let mass = (solid.mass_properties(&mut self.shapes))
271 .map(|p| p.map(|p| p.mass))
272 .map_err(|e| format!("the mass of {}: {e}", solid.name));
273 own_mass = add(own_mass, mass);
274 }
275 Ok(Info {
276 name: properties.name(stem(file)).to_string(),
277 file: file.to_string(),
278 parameters: parameters(reader)?,
279 designation: properties.designation.clone(),
280 material,
281 thickness,
282 own_bodies: !bodies.is_empty(),
283 own_mass,
284 })
285 }
286
287 fn whole_mass(&mut self, key: &str, reader: &Reader, file: &str) -> GeopResult<Weighed> {
290 if let Some(mass) = self.whole.get(key) {
291 return Ok(mass.clone());
292 }
293 let mut mass = self.info(key, reader, file)?.own_mass.clone();
294 for group in groups(reader)? {
295 let placed = self.whole_mass(&group.key, &group.reader, &group.file)?;
296 let placed = placed.map(|p| p.map(|p| times(group.count(), p)));
297 mass = add(mass, placed);
298 }
299 self.whole.insert(key.to_string(), mass.clone());
300 Ok(mass)
301 }
302
303 fn part_line(
306 &self,
307 key: &str,
308 level: usize,
309 placements: Vec<String>,
310 unit_mass: &Weighed,
311 ) -> Line {
312 let quantity = placements.len() as u64;
313 let info = &self.infos[key];
314 let (unit, total, error) = match unit_mass {
315 Ok(Some(m)) => (
316 Some(Bounded::of(*m)),
317 Some(Bounded::of(times(quantity, *m))),
318 None,
319 ),
320 Ok(None) => (None, None, None),
321 Err(e) => (None, None, Some(e.clone())),
322 };
323 Line {
324 item: String::new(),
325 level,
326 quantity,
327 name: info.name.clone(),
328 designation: info.designation.clone(),
329 kind: LineKind::Part {
330 file: info.file.clone(),
331 parameters: info.parameters.clone(),
332 material: info.material.clone(),
333 thickness: info.thickness.clone(),
334 unit_mass: unit,
335 total_mass: total,
336 error,
337 },
338 placements,
339 }
340 }
341
342 fn flat(
347 &mut self,
348 key: &str,
349 reader: &Reader,
350 file: &str,
351 paths: &[String],
352 lines: &mut Vec<(String, Line)>,
353 ) -> GeopResult<()> {
354 let multiplier = paths.len() as u64;
355 let info = self.info(key, reader, file)?;
356 if info.own_bodies || places_nothing(reader) {
357 let mass = info.own_mass.clone();
358 match lines.iter_mut().find(|(k, _)| k == key) {
359 Some((_, line)) => {
360 add_quantity(line, multiplier);
361 line.placements.extend_from_slice(paths);
362 }
363 None => {
364 let line = self.part_line(key, 0, paths.to_vec(), &mass);
365 lines.push((key.to_string(), line));
366 }
367 }
368 }
369 for group in groups(reader)? {
370 let paths = placed_in(paths, &group.names);
371 self.flat(&group.key, &group.reader, &group.file, &paths, lines)?;
372 }
373 for (cable, wire) in wire_lines(reader, 0, multiplier) {
374 let wire_key = format!("{key}\n{cable}\n{}", wire.name);
375 match lines.iter_mut().find(|(k, _)| *k == wire_key) {
376 Some((_, line)) => add_quantity(line, multiplier),
377 None => lines.push((wire_key, wire)),
378 }
379 }
380 Ok(())
381 }
382
383 fn indented(
387 &mut self,
388 reader: &Reader,
389 item: &str,
390 after: usize,
391 level: usize,
392 lines: &mut Vec<Line>,
393 ) -> GeopResult<()> {
394 let mut number = after;
395 let mut next = |line: &mut Line| {
396 number += 1;
397 line.item = match item {
398 "" => number.to_string(),
399 _ => format!("{item}.{number}"),
400 };
401 line.item.clone()
402 };
403 for group in groups(reader)? {
404 let mass = self.whole_mass(&group.key, &group.reader, &group.file)?;
405 let placements = group.names.clone();
406 let mut line = self.part_line(&group.key, level, placements, &mass);
407 let item = next(&mut line);
408 lines.push(line);
409 self.indented(&group.reader, &item, 0, level + 1, lines)?;
410 }
411 for (_, mut wire) in wire_lines(reader, level, 1) {
412 next(&mut wire);
413 lines.push(wire);
414 }
415 Ok(())
416 }
417}
418
419type Weighed = Result<Option<Scalar>, String>;
422
423fn add(a: Weighed, b: Weighed) -> Weighed {
426 Ok(a?.zip(b?).map(|(a, b)| a.add(b)))
427}
428
429fn times(n: u64, m: Scalar) -> Scalar {
431 Scalar::from_i64(n as i64).mul(m)
432}
433
434fn add_quantity(line: &mut Line, more: u64) {
436 line.quantity += more;
437 match &mut line.kind {
438 LineKind::Part {
439 unit_mass,
440 total_mass,
441 ..
442 } => *total_mass = unit_mass.map(|unit| unit.times(line.quantity as f64)),
443 LineKind::Wire {
444 cut_length,
445 total_length,
446 ..
447 } => *total_length = cut_length.times(line.quantity as f64),
448 }
449}
450
451fn wire_lines(reader: &Reader, level: usize, quantity: u64) -> Vec<(String, Line)> {
455 let mut out = Vec::new();
456 let Some(notes) = notes_of(reader) else {
457 return out;
458 };
459 for (cable_name, note) in notes.iter() {
460 for wire in ¬e.wires {
461 let cut_length = Bounded::of(wire.cut_length);
462 let designation = match wire.gauge {
463 Some(gauge) => format!("AWG {gauge}"),
464 None => format!("Ø{} mm", wire.diameter),
465 };
466 out.push((
467 cable_name.to_string(),
468 Line {
469 item: String::new(),
470 level,
471 quantity,
472 name: wire.name.clone(),
473 designation: Some(designation),
474 kind: LineKind::Wire {
475 cable: cable_name.to_string(),
476 gauge: wire.gauge,
477 diameter: wire.diameter,
478 colour: wire.colour.clone(),
479 cut_length,
480 total_length: cut_length.times(quantity as f64),
481 },
482 placements: Vec::new(),
483 },
484 ));
485 }
486 }
487 out
488}
489
490pub fn bom(reader: &Reader, file: &str, structure: Structure) -> GeopResult<Bom> {
499 let mut builder = Builder::default();
500 let root = key(reader, file)?;
501 let lines = match structure {
502 Structure::Flat => {
503 let mut keyed = Vec::new();
504 builder.flat(&root, reader, file, &[String::new()], &mut keyed)?;
505 let mut lines: Vec<Line> = keyed.into_iter().map(|(_, line)| line).collect();
506 for (k, line) in lines.iter_mut().enumerate() {
507 line.item = (k + 1).to_string();
508 }
509 lines
510 }
511 Structure::Indented => {
512 let mut lines = Vec::new();
513 let info = builder.info(&root, reader, file)?;
514 if info.own_bodies || places_nothing(reader) {
515 let mass = info.own_mass.clone();
516 let mut line = builder.part_line(&root, 1, vec![String::new()], &mass);
517 line.item = "1".into();
518 lines.push(line);
519 }
520 let after = lines.len();
521 builder.indented(reader, "", after, 1, &mut lines)?;
522 lines
523 }
524 };
525 let total_mass = builder
526 .whole_mass(&root, reader, file)?
527 .ok()
528 .flatten()
529 .map(Bounded::of);
530 Ok(Bom {
531 structure,
532 lines,
533 total_mass,
534 })
535}
536
537impl Bom {
538 pub fn to_csv(&self) -> String {
541 let mut rows: Vec<Vec<String>> = vec![
542 [
543 "Item",
544 "Level",
545 "Quantity",
546 "Name",
547 "Designation",
548 "File",
549 "Parameters",
550 "Material",
551 "Thickness (mm)",
552 "Unit mass (kg)",
553 "Total mass (kg)",
554 "Cut length (mm)",
555 "Total length (mm)",
556 ]
557 .map(String::from)
558 .to_vec(),
559 ];
560 let mass = |b: &Option<Bounded>| b.map(|b| format!("{:.6}", b.value)).unwrap_or_default();
561 for line in &self.lines {
562 let mut row = vec![
563 line.item.clone(),
564 line.level.to_string(),
565 line.quantity.to_string(),
566 line.name.clone(),
567 line.designation.clone().unwrap_or_default(),
568 ];
569 match &line.kind {
570 LineKind::Part {
571 file,
572 parameters,
573 material,
574 thickness,
575 unit_mass,
576 total_mass,
577 error,
578 ..
579 } => row.extend([
580 file.clone(),
581 parameters.clone(),
582 material.clone().unwrap_or_default(),
583 thickness
584 .iter()
585 .map(|t| t.to_string())
586 .collect::<Vec<_>>()
587 .join("; "),
588 match error {
589 Some(e) => format!("not computed: {e}"),
590 None => mass(unit_mass),
591 },
592 mass(total_mass),
593 String::new(),
594 String::new(),
595 ]),
596 LineKind::Wire {
597 cable,
598 colour,
599 cut_length,
600 total_length,
601 ..
602 } => row.extend([
603 String::new(),
604 format!("cable={cable}, colour={colour}"),
605 String::new(),
606 String::new(),
607 String::new(),
608 String::new(),
609 format!("{:.1}", cut_length.value),
610 format!("{:.1}", total_length.value),
611 ]),
612 }
613 rows.push(row);
614 }
615 let mut total = vec![String::new(); 13];
616 total[3] = "Total".into();
617 total[10] = mass(&self.total_mass);
618 rows.push(total);
619 let mut out = String::new();
620 for row in rows {
621 let cells: Vec<String> = row.iter().map(|c| csv_cell(c)).collect();
622 out.push_str(&cells.join(","));
623 out.push_str("\r\n");
624 }
625 out
626 }
627}
628
629fn csv_cell(text: &str) -> String {
632 if text.contains([',', '"', '\n', '\r']) {
633 format!("\"{}\"", text.replace('"', "\"\""))
634 } else {
635 text.to_string()
636 }
637}
638
639impl Bom {
640 pub fn line(&self, item: &str) -> Option<&Line> {
642 self.lines.iter().find(|l| l.item == item)
643 }
644}