Skip to main content

geop_ops_model/placed/
bom.rs

1//! The bill of materials of an assembly: the table a part is ordered and
2//! made from ([`bom`]).
3//!
4//! **Lines.** Placed parts are grouped into one line when they are the same
5//! part: placed from the same file, built with the same parameter values —
6//! every `std:iso4762_socket_head_cap_screw.geop` at size `M4x12` is one
7//! line, however many there are and wherever they are placed. Where a part
8//! is placed, and where the parts placed in it are, is no part of what it
9//! is; its colour neither, since it is not ordered by it.
10//!
11//! **Structure.** [`Structure::Flat`] lists what is made or bought: every
12//! part with bodies of its own, however deep it is placed, its quantity
13//! the count in the whole assembly. [`Structure::Indented`] lists the tree:
14//! the parts placed in the assembly, then under each sub-assembly the parts
15//! placed in it, its quantity the count per one of the sub-assembly — and a
16//! sub-assembly's mass that of all of it.
17//!
18//! **Columns.** Per part its name — a standard part's title, else its
19//! file's — the file, the parameter values it is built with, a standard
20//! part's designation (`ISO 4762 M4x12`, see [`designation`]), its
21//! material, the thickness of its sheet-metal bodies, and its mass: per
22//! piece and for the quantity, from the exact geometry of its bodies and
23//! its own material (see [`super::solids`]). A part of no given material
24//! has none, and no mass; nor has an assembly with such a part in it.
25//!
26//! **Wires.** The wires noted on a solid (see [`crate::SolidNote::wires`])
27//! — a routed harness' — are ordered: each is a line of its own under the
28//! part that routes it, with its gauge, colour and the length to cut it to.
29//! Its swept bundle is how it is drawn, not something ordered: a solid only
30//! drawn ([`crate::SolidNote::drawn_only`]) is neither a part's body nor
31//! weighed.
32//!
33//! [`Bom::to_csv`] writes the table for a spreadsheet. `geop_ops_bom`
34//! offers the bill as an inspector and as a CSV file; a drawing lists it.
35
36use 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/// How the lines of a bill of materials are laid out.
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum Structure {
60    /// Every part that is made or bought, once, with its count in the whole
61    /// assembly.
62    #[default]
63    Flat,
64    /// The tree of sub-assemblies, each part counted per one of the
65    /// assembly it is placed in.
66    Indented,
67}
68
69/// What a line of a bill of materials is.
70#[derive(Clone, Debug, PartialEq, Serialize)]
71#[serde(tag = "kind", rename_all = "snake_case")]
72pub enum LineKind {
73    /// A part, or a sub-assembly.
74    Part {
75        /// Its program file, as the library names it.
76        file: String,
77        /// The values of the parameters it defines, as built: `size=M4x12`.
78        parameters: String,
79        /// What it is made of: none for a part of no given material.
80        material: Option<String>,
81        /// The thickness of each sheet-metal body of its own (mm), each
82        /// thickness once.
83        thickness: Vec<f64>,
84        /// The mass of one (kg): its own bodies' — and, in an indented
85        /// bill, a sub-assembly's of all of it. `None` for a part with no
86        /// material, or with a part of none in it, and if it could not be
87        /// computed: then `error` says why.
88        unit_mass: Option<Bounded>,
89        /// `unit_mass` times the quantity (kg).
90        total_mass: Option<Bounded>,
91        error: Option<String>,
92    },
93    /// A wire of a routed harness, cut to length.
94    Wire {
95        /// The cable it is routed in: the name of its swept bundle.
96        cable: String,
97        /// Its American wire gauge, if it was given by one.
98        gauge: Option<i32>,
99        /// Its outer diameter (mm), insulation included.
100        diameter: f64,
101        /// `#rrggbb`.
102        colour: String,
103        /// The length to cut each piece to (mm).
104        cut_length: Bounded,
105        /// `cut_length` times the quantity (mm).
106        total_length: Bounded,
107    },
108}
109
110/// One line of a bill of materials.
111#[derive(Clone, Debug, PartialEq, Serialize)]
112pub struct Line {
113    /// Its item number: `3` in a flat bill, `2.1` — the first under item
114    /// 2 — in an indented one.
115    pub item: String,
116    /// How deep it is placed: 0 in a flat bill, 1 for what the assembly
117    /// places itself in an indented one.
118    pub level: usize,
119    pub quantity: u64,
120    /// A standard part's title, else its file's name; a wire's name.
121    pub name: String,
122    /// A standard part's designation; a wire's gauge or diameter.
123    pub designation: Option<String>,
124    #[serde(flatten)]
125    pub kind: LineKind,
126    /// Where the parts it counts are placed, as the entities of placed
127    /// parts are named: in a flat bill from the assembly, `arm/screw` for
128    /// the part placed as `screw` in the one placed as `arm`, `""` for the
129    /// assembly's own bodies; in an indented one from the sub-assembly it
130    /// is listed under. Empty for a wire. What a drawing balloons.
131    #[serde(skip)]
132    pub placements: Vec<String>,
133}
134
135/// A bill of materials.
136#[derive(Clone, Debug, PartialEq, Serialize)]
137pub struct Bom {
138    pub structure: Structure,
139    pub lines: Vec<Line>,
140    /// The mass of everything listed (kg) — if every part has a material
141    /// and every line's mass could be computed: a total missing a part
142    /// would mislead.
143    pub total_mass: Option<Bounded>,
144}
145
146/// What a part is, by what tells it apart from another: the file it is
147/// placed from and the values of the parameters it defines.
148fn key(reader: &Reader, file: &str) -> GeopResult<String> {
149    Ok(format!("{file}\n{}", parameters(reader)?))
150}
151
152/// The values of the parameters the part `reader` reads defines, as built:
153/// `size=M4x12, length=40`.
154fn parameters(reader: &Reader) -> GeopResult<String> {
155    Ok((read_parameter_values(reader)?.0.iter())
156        // A table's columns follow from its row.
157        .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
166/// What a bill needs of one part, worked out once however often it is
167/// placed.
168struct Info {
169    name: String,
170    file: String,
171    parameters: String,
172    designation: Option<String>,
173    material: Option<String>,
174    thickness: Vec<f64>,
175    /// Whether it has bodies of its own — what makes a part something to
176    /// make or buy, rather than only an assembly of others.
177    own_bodies: bool,
178    /// The mass of its own bodies, or why it could not be computed.
179    own_mass: Weighed,
180}
181
182/// The parts placed in a part, grouped: one per kind of part, in the order
183/// each was first placed, with the file they are placed from and the names
184/// they are placed as.
185struct Group<'a> {
186    key: String,
187    reader: Reader<'a>,
188    file: String,
189    names: Vec<String>,
190}
191
192impl Group<'_> {
193    /// How many there are.
194    fn count(&self) -> u64 {
195        self.names.len() as u64
196    }
197}
198
199/// The parts placed in the part `reader` reads itself, grouped.
200fn 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
219/// Whether the part `reader` reads places no part.
220fn places_nothing(reader: &Reader) -> bool {
221    reader.instances().is_empty()
222}
223
224/// The paths of the parts placed as `name` in each of the parts placed at
225/// `paths` (see [`Line::placements`]).
226fn 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/// Builds a bill of materials, remembering what it worked out of each part.
239#[derive(Default)]
240struct Builder {
241    infos: HashMap<String, Info>,
242    /// The mass of all of a part — its own bodies and those of the parts
243    /// placed in it — by key.
244    whole: HashMap<String, Weighed>,
245    shapes: Shapes,
246}
247
248impl Builder {
249    /// What the bill needs of the part `key`, which `reader` reads, placed
250    /// from `file`.
251    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    /// The mass of all of the part `key`, which `reader` reads, placed from
288    /// `file`: its own bodies, and every part placed in it.
289    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    /// The line of the part `key`, placed at `placements`, weighing
304    /// `unit_mass` each.
305    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    /// Adds the parts of the part `key`, which `reader` reads, placed from
343    /// `file` — at `paths` (see [`Line::placements`]) — to the flat bill `lines`, by
344    /// key: itself if it has bodies of its own, or is no assembly; the
345    /// parts placed in it, however deep; its wires.
346    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    /// Adds what is placed in the part `reader` reads — its groups, each
384    /// followed by what is placed in it, then its wires — to the indented
385    /// bill `lines`, at `level`, numbered after `item` from `after + 1` on.
386    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
419/// A mass (kg): none where a part has no material, or why it could not be
420/// computed.
421type Weighed = Result<Option<Scalar>, String>;
422
423/// The mass of two things together: none if either has none, and the
424/// first reason one could not be computed.
425fn add(a: Weighed, b: Weighed) -> Weighed {
426    Ok(a?.zip(b?).map(|(a, b)| a.add(b)))
427}
428
429/// `n` times `m`.
430fn times(n: u64, m: Scalar) -> Scalar {
431    Scalar::from_i64(n as i64).mul(m)
432}
433
434/// Adds `more` to a line's quantity, and its total with it.
435fn 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
451/// The wires noted on the solids of the part `reader` reads itself, each a
452/// line at `level` of `quantity` pieces, with the name of the solid — the
453/// cable — it is noted on.
454fn 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 &note.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
490/// The bill of materials of the part `reader` reads, the program of the
491/// file `file`, laid out as `structure` says. A part placed is listed by
492/// its own title and designation, if it has them (see
493/// [`crate::parameters::Properties`]).
494///
495/// The part's own bodies — those it is modelled with besides what it
496/// places — are a line of their own, named after its file: for a part
497/// that places nothing, that line is the whole bill.
498pub 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    /// The table as CSV (RFC 4180): a header, a row per line, and the
539    /// total mass. Masses in kilograms, lengths in millimetres.
540    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
629/// `text` as a CSV cell: quoted, its quotes doubled, if it holds a comma,
630/// a quote or a line break.
631fn 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    /// The line numbered `item`.
641    pub fn line(&self, item: &str) -> Option<&Line> {
642        self.lines.iter().find(|l| l.item == item)
643    }
644}