Skip to main content

geop_ops_bom/
lib.rs

1//! The bill of materials of an assembly (see
2//! [`geop_ops_model::placed::bom`]), offered as an inspector
3//! ([`BillOfMaterials`]) and as a CSV file ([`BomCsv`]).
4
5use geop_core_editor::{
6    editing::{Tone, Visual},
7    output::{
8        Answer, AnswerAction, Block, Content, Does, Export, Exporter, ExporterInfo, Inspector,
9        InspectorInfo, Output, Row,
10    },
11    registry::Registry,
12};
13use geop_core_math::geop_error::GeopResult;
14use geop_core_program::element::PickedElement;
15use geop_ops_model::{
16    parameters::stem,
17    placed::bom::{Bom, Line, LineKind, Structure, bom},
18};
19use serde::Deserialize;
20
21mod icons;
22#[cfg(test)]
23mod tests;
24
25/// How a bill of materials is asked for.
26#[derive(Clone, Copy, Debug, Default, PartialEq, Deserialize)]
27#[serde(default)]
28pub struct BomOptions {
29    pub structure: Structure,
30}
31
32/// The bill of materials of the part, as the inspect panel shows it.
33pub struct BillOfMaterials;
34
35impl Inspector for BillOfMaterials {
36    const INFO: InspectorInfo = InspectorInfo {
37        kind: "bom",
38        label: "Bill of materials",
39        doc: "Every part with its quantity, designation, material and mass, and every wire cut to length.",
40        icon: Some(crate::icons::BOM),
41        picks: None,
42    };
43    type Options = BomOptions;
44    type Report = Bom;
45
46    fn inspect(
47        output: &mut Output,
48        _: &[PickedElement],
49        options: BomOptions,
50    ) -> GeopResult<(Bom, Vec<Visual>)> {
51        Ok((
52            bom(&output.part(), output.file_name(), options.structure)?,
53            Vec::new(),
54        ))
55    }
56
57    /// The structures to ask for, the file to save, and the table: per
58    /// line its item, quantity, name and designation, and its total mass
59    /// or length — in full while pointed at.
60    fn show(bom: &Bom) -> Answer {
61        let options = |structure: Structure| serde_json::json!({ "structure": structure });
62        let ask = |structure, label: &str, doc: &str| AnswerAction {
63            label: label.into(),
64            doc: doc.into(),
65            chosen: bom.structure == structure,
66            does: Does::Inspect {
67                options: options(structure),
68            },
69        };
70        let mut blocks = vec![Block::Actions {
71            actions: vec![
72                ask(
73                    Structure::Flat,
74                    "Flat",
75                    "Every part once, counted in the whole assembly",
76                ),
77                ask(
78                    Structure::Indented,
79                    "Indented",
80                    "By sub-assembly, counted per assembly",
81                ),
82                AnswerAction {
83                    label: "Save CSV".into(),
84                    doc: "Save the bill of materials as a CSV file".into(),
85                    chosen: false,
86                    does: Does::Export {
87                        exporter: BomCsv::INFO.kind,
88                        options: options(bom.structure),
89                    },
90                },
91            ],
92        }];
93        if bom.lines.is_empty() {
94            blocks.push(Block::Text {
95                text: "Nothing to list.".into(),
96                tone: Tone::Hint,
97            });
98            return Answer {
99                blocks,
100                plane: None,
101            };
102        }
103        let rows = (bom.lines.iter()).map(shown).collect();
104        let foot = (bom.total_mass.iter())
105            .map(|mass| {
106                Row::labelled(
107                    "Total",
108                    vec![String::new(), String::new(), mass.show_mass()],
109                )
110            })
111            .collect();
112        blocks.push(Block::Table {
113            head: ["#", "Qty", "Part", "Total"].map(String::from).to_vec(),
114            rows,
115            foot,
116        });
117        Answer {
118            blocks,
119            plane: None,
120        }
121    }
122}
123
124/// The line `line` as the table shows it: its item, quantity, name —
125/// indented by its level, its designation under it — and its total mass or
126/// length; all it says in full while pointed at.
127fn shown(line: &Line) -> Row {
128    let indent = "\u{2003}".repeat(line.level.saturating_sub(1));
129    let mut name = vec![format!("{indent}{}", line.name)];
130    name.extend(line.designation.iter().map(|d| format!("{indent}{d}")));
131    let (total, detail, tone) = match &line.kind {
132        LineKind::Wire {
133            cable,
134            diameter,
135            colour,
136            cut_length,
137            total_length,
138            ..
139        } => (
140            format!("{:.0} mm", total_length.value),
141            [
142                format!("cable {cable}"),
143                colour.clone(),
144                format!("Ø{diameter} mm"),
145                format!("cut to {:.1} mm", cut_length.value),
146            ]
147            .join("\n"),
148            Tone::Normal,
149        ),
150        LineKind::Part {
151            file,
152            parameters,
153            material,
154            thickness,
155            unit_mass,
156            total_mass,
157            error,
158        } => {
159            let thickness = (!thickness.is_empty()).then(|| {
160                let each: Vec<String> = thickness.iter().map(f64::to_string).collect();
161                format!("sheet {} mm", each.join(", "))
162            });
163            let detail = [
164                Some(file.clone()),
165                Some(parameters.clone()),
166                Some(match material {
167                    Some(material) => material.clone(),
168                    None => "No material: set one under Properties to get its mass".into(),
169                }),
170                thickness,
171                unit_mass.map(|m| format!("{} each", m.show_mass())),
172                error.clone(),
173            ]
174            .into_iter()
175            .flatten()
176            .filter(|s| !s.is_empty())
177            .collect::<Vec<_>>()
178            .join("\n");
179            // No mass and no error: a part of no material, or with one in it.
180            let total = match (total_mass, error) {
181                (Some(m), _) => m.show_mass(),
182                (None, Some(_)) => "?".into(),
183                (None, None) => "—".into(),
184            };
185            let tone = match error {
186                Some(_) => Tone::Error,
187                None => Tone::Normal,
188            };
189            (total, detail, tone)
190        }
191    };
192    Row {
193        label: None,
194        cells: vec![
195            line.item.clone(),
196            line.quantity.to_string(),
197            name.join("\n"),
198            total,
199        ],
200        detail: Some(detail),
201        light: Vec::new(),
202        tone,
203    }
204}
205
206/// The bill of materials of the part as a CSV file, for a spreadsheet.
207pub struct BomCsv;
208
209impl Exporter for BomCsv {
210    const INFO: ExporterInfo = ExporterInfo {
211        kind: "bom",
212        label: "Bill of materials",
213        doc: "parts and wires, for a spreadsheet",
214        formats: &["csv"],
215    };
216    type Options = BomOptions;
217
218    fn export(output: &mut Output, _: &str, options: BomOptions) -> GeopResult<Export> {
219        let bom = bom(&output.part(), output.file_name(), options.structure)?;
220        Ok(Export {
221            name: format!("{}_bom.csv", stem(output.file_name())),
222            media_type: "text/csv".into(),
223            content: Content::Text(bom.to_csv()),
224        })
225    }
226}
227
228/// Adds what this crate offers an editor to `r`: the bill of materials:
229/// asked of a part, and written as CSV.
230pub fn register(r: Registry) -> GeopResult<Registry> {
231    r.exporter(BomCsv)?.inspector(BillOfMaterials)
232}