Skip to main content

geop_ops_drawing/
export.rs

1//! [`DrawingExporter`]: a drawing of the part written as SVG or DXF — one a
2//! [`crate::Drawing`] step describes ([`drawing`]), or one of the whole part
3//! by default — and the drawings a part's steps describe.
4
5use geop_core_editor::output::{Content, Export, Exporter, ExporterInfo, Output};
6use geop_core_math::geop_error::{GeopError, GeopResult, WithContext};
7use geop_core_program::{
8    element::{ElementReference, HasElements, Kind, whole},
9    reader::Reader,
10    target::{Target, TargetReference},
11};
12use geop_lib_sheet::Format;
13use geop_ops_model::parameters::{read_properties, stem};
14use serde::Deserialize;
15
16use crate::{DrawingArgs, Projection, SheetSize, ViewKind, compose, parts_list};
17
18/// The drawing the step `step` describes: its arguments, checked against
19/// the part before it.
20pub fn drawing(step: &str) -> TargetReference<DrawingArgs> {
21    TargetReference::new(format!("{PREFIX}{step}"))
22}
23
24/// What the target of a drawing is named, before its step's id.
25const PREFIX: &str = "drawing:";
26
27/// The generator of [`drawing`]: the arguments it is given.
28pub(crate) fn described(_: &Reader, args: &DrawingArgs) -> GeopResult<DrawingArgs> {
29    Ok(args.clone())
30}
31
32/// Each drawing step's own.
33impl Target for DrawingArgs {}
34
35/// Read whole.
36impl Kind for DrawingArgs {
37    const NAME: &'static str = "drawing";
38}
39
40impl HasElements<DrawingArgs> for DrawingArgs {
41    type Element<'a> = &'a DrawingArgs;
42
43    fn element(&self, name: &str) -> GeopResult<&DrawingArgs> {
44        whole(self, name)
45    }
46
47    fn names_target(name: &str) -> bool {
48        name.starts_with(PREFIX)
49    }
50}
51
52/// Every drawing the steps `reader` reads describe, by the id of its step,
53/// in the order of the steps.
54pub fn drawing_steps<'a>(reader: &Reader<'a>) -> GeopResult<Vec<(String, &'a DrawingArgs)>> {
55    let mut out = Vec::new();
56    for target in reader.targets_of::<DrawingArgs>() {
57        let Some(step) = target.name().strip_prefix(PREFIX) else {
58            continue;
59        };
60        let step = step.to_string();
61        out.push((
62            step,
63            reader.retrieve_element(&ElementReference::whole(target))?,
64        ));
65    }
66    Ok(out)
67}
68
69/// Writes a drawing of the part as SVG or DXF.
70pub struct DrawingExporter;
71
72/// Which drawing is written, and what of it is changed from what its step
73/// describes — as the command line asks.
74#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
75#[serde(default)]
76pub struct DrawingOptions {
77    /// The drawing step to write, by id. Else the step being edited, if it
78    /// is a drawing; else the program's last drawing; else a drawing of
79    /// the whole part, as a new step describes it.
80    pub step: Option<String>,
81    /// The views, by name: `front`, `top`, `right`, `iso`, `left`,
82    /// `bottom`, `back`.
83    pub views: Vec<String>,
84    /// First-angle projection (ISO) rather than third angle (ASME).
85    pub first_angle: bool,
86    /// The scale as written, `1:2` or `5:1`.
87    pub scale: Option<String>,
88    /// The paper: `a4` to `a0`.
89    pub sheet: Option<String>,
90    /// The part's name for the title block.
91    pub name: Option<String>,
92    /// What it is made of, for the title block.
93    pub material: Option<String>,
94    /// List the parts placed above the title block.
95    pub bom: bool,
96}
97
98/// A scale as written, `1:2` or `5:1`, as paper length per model length.
99fn parse_scale(text: &str) -> GeopResult<f64> {
100    let bad = || GeopError::new(format!("the scale {text:?} is not like 1:2 or 5:1"));
101    let (a, b) = text.split_once(':').ok_or_else(bad)?;
102    let (a, b): (f64, f64) = (
103        a.trim().parse().map_err(|_| bad())?,
104        b.trim().parse().map_err(|_| bad())?,
105    );
106    match a > 0.0 && b > 0.0 {
107        true => Ok(a / b),
108        false => Err(bad()),
109    }
110}
111
112impl DrawingOptions {
113    /// `args` changed as these options say.
114    fn apply(&self, mut args: DrawingArgs) -> GeopResult<DrawingArgs> {
115        if !self.views.is_empty() {
116            args.views = (self.views.iter())
117                .map(|v| {
118                    ViewKind::from_name(v.trim()).ok_or_else(|| {
119                        let known: Vec<&str> = ViewKind::ALL.iter().map(|k| k.name()).collect();
120                        GeopError::new(format!(
121                            "no view is named {v:?}; the views are {}",
122                            known.join(", ")
123                        ))
124                    })
125                })
126                .collect::<GeopResult<_>>()?;
127        }
128        if self.first_angle {
129            args.projection = Projection::FirstAngle;
130        }
131        if let Some(scale) = &self.scale {
132            args.scale = Some(parse_scale(scale)?);
133        }
134        if let Some(sheet) = &self.sheet {
135            args.sheet = (SheetSize::ALL.into_iter())
136                .find(|s| s.name() == sheet.to_ascii_lowercase())
137                .ok_or_else(|| GeopError::new(format!("no paper is named {sheet:?}: a4 to a0")))?;
138        }
139        if let Some(name) = &self.name {
140            args.name = name.clone();
141        }
142        if let Some(material) = &self.material {
143            args.material = material.clone();
144        }
145        args.bom |= self.bom;
146        Ok(args)
147    }
148}
149
150impl Exporter for DrawingExporter {
151    const INFO: ExporterInfo = ExporterInfo {
152        kind: "drawing",
153        label: "Drawing",
154        doc: "2-D drawing: views, dimensions, title block",
155        formats: &["svg", "dxf"],
156    };
157    type Options = DrawingOptions;
158
159    /// The drawing of the part as the steps before its step built it —
160    /// the title block's name and material, where the drawing gives none,
161    /// the program's (see [`geop_ops_model::parameters::Properties`]).
162    fn export(output: &mut Output, format: &str, options: DrawingOptions) -> GeopResult<Export> {
163        let format = Format::of_extension(format)
164            .ok_or_else(|| GeopError::new(format!("a drawing is no .{format} file")))?;
165        let current = output.part();
166        let steps = drawing_steps(&current)?;
167        let found = match &options.step {
168            Some(step) => Some(
169                (steps.iter().find(|(s, _)| s == step))
170                    .ok_or_else(|| GeopError::new(format!("the step {step:?} is no drawing")))?,
171            ),
172            None => (output.editing)
173                .and_then(|editing| steps.iter().find(|(s, _)| s == editing))
174                .or(steps.last()),
175        };
176        let (step, args) = match found {
177            Some((step, args)) => (Some(step.to_string()), (*args).clone()),
178            None => (None, DrawingArgs::default()),
179        };
180        let mut args = options.apply(args)?;
181        let properties = read_properties(&output.part())?.into_owned();
182        if args.name.is_empty() {
183            args.name = properties.name(stem(output.file_name())).to_string();
184        }
185        if let Some(material) = properties.material().filter(|_| args.material.is_empty()) {
186            args.material = material.name.clone();
187        }
188        let reader = match &step {
189            Some(step) => output.part_before(step)?,
190            None => output.part(),
191        };
192        let ctx = |e: GeopError| match &step {
193            Some(step) => e.with_context(format!("the drawing {step:?}")),
194            None => e.with_context("the drawing of the whole part"),
195        };
196        let parts = parts_list(&reader, output.file_name(), &args).with_context(&ctx)?;
197        let sheet = compose(&reader, &args, output.date, &parts).with_context(&ctx)?;
198        Ok(Export {
199            name: format!("{}.{}", stem(output.file_name()), format.extension()),
200            media_type: format.media_type().into(),
201            content: Content::Text(sheet.write(format)),
202        })
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn scales_are_read_as_written() {
212        assert_eq!(parse_scale("1:5").unwrap(), 0.2);
213        assert_eq!(parse_scale("2:1").unwrap(), 2.0);
214        assert!(parse_scale("0:1").is_err());
215        assert!(parse_scale("half").is_err());
216    }
217}