1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Deserialize)]
27#[serde(default)]
28pub struct BomOptions {
29 pub structure: Structure,
30}
31
32pub 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 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
124fn 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 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
206pub 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
228pub fn register(r: Registry) -> GeopResult<Registry> {
231 r.exporter(BomCsv)?.inspector(BillOfMaterials)
232}