gcode_nom/binary/gcode_block/
svg.rs

1use core::f64;
2use core::f64::consts::TAU;
3use core::fmt::Display;
4
5use crate::command::Command;
6use crate::params::head::PosVal;
7use crate::{compute_arc, ArcParams, PositionMode, MM_PER_ARC_SEGMENT};
8
9/// SVG representation of a G-Code file.
10///
11/// wraps the min and max x, y values of the SVG.
12#[derive(Debug, Clone, PartialEq)]
13pub struct Svg {
14    min_x: f64,
15    min_y: f64,
16    max_x: f64,
17    max_y: f64,
18    parts: Vec<String>,
19}
20
21impl Default for Svg {
22    fn default() -> Self {
23        Self {
24            min_x: f64::INFINITY,
25            max_x: -f64::INFINITY,
26            min_y: f64::INFINITY,
27            max_y: -f64::INFINITY,
28            parts: Vec::default(),
29        }
30    }
31}
32
33impl Svg {
34    fn update_view_box(&mut self, proj_x: f64, proj_y: f64) {
35        // Record min max x, y
36        if proj_x > self.max_x {
37            self.max_x = proj_x;
38        }
39        if proj_x < self.min_x {
40            self.min_x = proj_x;
41        }
42        if proj_y > self.max_y {
43            self.max_y = proj_y;
44        }
45        if proj_y < self.min_y {
46            self.min_y = proj_y;
47        }
48    }
49}
50// A line could not be decoded as an G-Code command
51// #[derive(Debug, Clone)]
52// struct GCodeError;
53
54// impl std::fmt::Display for GCodeError {
55//     fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
56//         write!(f, "invalid g-code statement")
57//     }
58// }
59
60impl Display for Svg {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        let width = self.max_x - self.min_x;
63        let height = self.max_y - self.min_y;
64        // An empty gcode file will not change min_x/y or max_x/y from
65        // its default of +/-INF respectively.
66        //
67        let parameters = if width.is_finite() && height.is_finite() {
68            format!(
69                "width=\"{width}\" height=\"{height}\" viewBox=\"{} {} {} {}\"",
70                self.min_x, self.min_y, width, height
71            )
72        } else {
73            // In this case silently fail by returning a empty SVG element, without a viewBox parameter.
74            String::new()
75        };
76        writeln!(
77            f,
78            "<svg xmlns=\"http://www.w3.org/2000/svg\" {parameters} >"
79        )?;
80        write!(f, "  <path d=\"")?;
81
82        for part in &self.parts {
83            write!(f, "{part}")?;
84        }
85        write!(
86            f,
87            r#""
88style="fill:none;stroke:green;stroke-width:0.05" />
89 </svg>"#
90        )?;
91        Ok(())
92    }
93}
94
95/// Returns a SVG given a collection of G-Code commands.
96///
97/// TODO: Want to iterate over something more flexible
98/// ie. Drop String for something more generic `AsRef<&str>`?
99impl FromIterator<String> for Svg {
100    fn from_iter<I>(iter: I) -> Self
101    where
102        I: IntoIterator<Item = String>,
103    {
104        let mut svg = Self::default();
105
106        // Invalid if the <path>'s d string does not start with a move.
107        svg.parts.push("M0 0".to_string());
108
109        let part_id = Some(0);
110        let mut is_extruding = false;
111        // Positioning mode for all axes (A, B, C), (U, V, W),  (X, Y, Z).
112        let mut position_mode = PositionMode::default();
113        // X and Y position of tool head (before projection).
114        let mut current_x = 0_f64;
115        let mut current_y = 0_f64;
116        let mut current_z = 0_f64;
117
118        let mut origin_x = 0_f64;
119        let mut origin_y = 0_f64;
120        let mut origin_z = 0_f64;
121
122        for line in iter {
123            let (_, command) = Command::parse_line(&line).expect("Command is not parsable");
124
125            match command {
126                // Treat G0 and G1 command identically.
127                //
128                // A G0 is a non-printing move but E is present in files seen in the wild.
129                // (In the assets directory see the gears and benchy2 files.)
130                Command::G0(mut payload) | Command::G1(mut payload) => {
131                    // Candidate value of params X<number> Y<number>
132                    let mut x_param = f64::NAN;
133                    let mut y_param = f64::NAN;
134                    let mut z_param = f64::NAN;
135
136                    for param in payload.drain() {
137                        match param {
138                            PosVal::X(val) => x_param = val,
139                            PosVal::Y(val) => y_param = val,
140                            PosVal::Z(val) => z_param = val,
141                            // Negative values the extruder is "wiping"
142                            // or sucking filament back into the extruder.
143                            PosVal::E(val) => is_extruding = val > 0_f64,
144                            _ => {}
145                        }
146                    }
147
148                    if !x_param.is_nan() {
149                        current_x = match position_mode {
150                            PositionMode::Absolute => x_param,
151                            PositionMode::Relative => current_x + x_param,
152                        }
153                    }
154
155                    if !y_param.is_nan() {
156                        current_y = match position_mode {
157                            PositionMode::Absolute => y_param,
158                            PositionMode::Relative => current_y + y_param,
159                        }
160                    }
161
162                    if !z_param.is_nan() {
163                        current_z = match position_mode {
164                            PositionMode::Absolute => z_param,
165                            PositionMode::Relative => current_z + z_param,
166                        };
167                    }
168
169                    let proj_x = (origin_y + current_y) / 2. + (origin_x + current_x) / 2.;
170                    let proj_y = -(origin_z + current_z) - (origin_y + current_y) / 2.
171                        + (origin_x + current_x) / 2.;
172                    svg.update_view_box(proj_x, proj_y);
173
174                    if is_extruding && part_id.is_some() {
175                        svg.parts.push(format!("L{proj_x:.3} {proj_y:.3}"));
176                    } else {
177                        svg.parts.push(format!("M{proj_x:.3} {proj_y:.3}"));
178                    }
179                }
180                Command::G2(arc_form) => {
181                    // Clockwise arc
182                    let ArcParams {
183                        center,
184                        radius,
185                        mut theta_start,
186                        theta_end,
187                    } = compute_arc(current_x, current_y, &arc_form);
188
189                    // Regarding the Ambiguity/Equivalence  of the angles 0 and 2PI
190                    // All values here are in the range 0<=theta<2PI
191                    // We are rotating clockwise
192                    // in this cased the start angle of 0 should be read as 2PI
193                    if theta_start == 0_f64 {
194                        theta_start = TAU;
195                    }
196
197                    let delta_theta = if theta_start < theta_end {
198                        // Adjust for zero crossing
199                        // say 115 -> 304 degrees
200                        // delta_theta = 115 + (360 - 304 ) = 170
201                        theta_start + (TAU - theta_end)
202                    } else {
203                        theta_start - theta_end
204                    };
205                    let total_arc_length = delta_theta * radius;
206                    // n_steps must be a number > 0
207                    let n_steps = (total_arc_length / MM_PER_ARC_SEGMENT).ceil();
208                    let theta_step = delta_theta / n_steps;
209
210                    // x,y are the position of the head in absolute units.
211                    let mut x = f64::NAN;
212                    let mut y = f64::NAN;
213                    // For loop: f64 has a problem with numerical accuracy
214                    // specifically the comparing limit.
215                    // rust idiomatically insists on indexed here
216                    for i in 0..=n_steps as u64 {
217                        let theta = (i as f64).mul_add(-theta_step, theta_start) % TAU;
218                        x = radius.mul_add(theta.cos(), center.0);
219                        y = radius.mul_add(theta.sin(), center.1);
220
221                        let proj_x = (origin_x + x + origin_y + y) / 2_f64;
222                        let proj_y =
223                            -(origin_z + current_z) - (origin_y + y) / 2. + (origin_x + x) / 2.;
224                        svg.update_view_box(proj_x, proj_y);
225                        match position_mode {
226                            PositionMode::Absolute => {
227                                svg.parts.push(format!("L{proj_x:.3} {proj_y:.3}"));
228                            }
229                            PositionMode::Relative => {
230                                svg.parts.push(format!("l{proj_x:.3} {proj_y:.3}"));
231                            }
232                        }
233                    }
234
235                    current_x = x;
236                    current_y = y;
237                }
238                Command::G3(arc_form) => {
239                    // Anti-Clockwise arc
240                    let ArcParams {
241                        center,
242                        radius,
243                        theta_start,
244                        mut theta_end,
245                    } = compute_arc(current_x, current_y, &arc_form);
246
247                    // Regarding the Ambiguity/Equivalence  of the angles 0 and 2PI
248                    // All values here are in the range 0<=theta<2PI
249                    // We are rotating anticlockwise
250                    // in this cased the final angle of 0 should be read as 2PI
251                    if theta_end == 0_f64 {
252                        theta_end = TAU;
253                    }
254
255                    let delta_theta = if theta_start > theta_end {
256                        // Adjust for zero crossing
257                        // say 306 -> 115 degrees
258                        // delta_theta = (360 - 305 ) + 115 = 170
259                        TAU - theta_start + theta_end
260                    } else {
261                        theta_end - theta_start
262                    };
263                    let total_arc_length = delta_theta * radius;
264                    // n_steps must be a number > 0
265                    let n_steps = (total_arc_length / MM_PER_ARC_SEGMENT).ceil();
266                    let theta_step = delta_theta / n_steps;
267
268                    // For loop with f64 have a problem with numerical accuracy
269                    // specifically the comparing limit.
270                    // rust idiomatically insists on indexed here
271                    let mut x = f64::NAN;
272                    let mut y = f64::NAN;
273                    for i in 0..=n_steps as u64 {
274                        let theta = (i as f64).mul_add(theta_step, theta_start) % TAU;
275                        x = radius.mul_add(theta.cos(), center.0);
276                        y = radius.mul_add(theta.sin(), center.1);
277
278                        let proj_x = (origin_x + x + origin_y + y) / 2.;
279                        let proj_y =
280                            -(origin_z + current_z) - (origin_y + y) / 2. + (origin_x + x) / 2.;
281                        svg.update_view_box(proj_x, proj_y);
282                        match position_mode {
283                            PositionMode::Absolute => {
284                                svg.parts.push(format!("L{proj_x:.3} {proj_y:.3}"));
285                            }
286                            PositionMode::Relative => {
287                                svg.parts.push(format!("l{proj_x:.3} {proj_y:.3}"));
288                            }
289                        }
290                    }
291
292                    current_x = x;
293                    current_y = y;
294                }
295                Command::G90 => position_mode = PositionMode::Absolute,
296                Command::G91 => position_mode = PositionMode::Relative,
297
298                // If the current position is at X=4 and G92 X7 is programmed,
299                //  the current position is redefined as X=7, effectively
300                // moving the origin of the coordinate system -3 units in X.""
301                Command::G92(mut params) => {
302                    // The extrude rate is going to zero
303                    // enter MoveMode ..ie not laying down filament.
304                    for param in params.drain() {
305                        match param {
306                            PosVal::E(val) => {
307                                // Negative values the extruder is "wiping"
308                                // or sucking filament back into the extruder.
309                                is_extruding = val > 0_f64;
310                            }
311                            PosVal::X(val) => match position_mode {
312                                PositionMode::Absolute => {
313                                    origin_x = current_x - val;
314                                    current_x = val;
315                                }
316                                PositionMode::Relative => {
317                                    unimplemented!("Relative position mode origin adjust ");
318                                }
319                            },
320                            PosVal::Y(val) => match position_mode {
321                                PositionMode::Absolute => {
322                                    origin_y = current_x - val;
323                                    current_y = val;
324                                }
325                                PositionMode::Relative => {
326                                    unimplemented!("Relative position mode origin adjust ");
327                                }
328                            },
329                            PosVal::Z(val) => match position_mode {
330                                PositionMode::Absolute => {
331                                    origin_z = current_z - val;
332                                    current_z = val;
333                                }
334                                PositionMode::Relative => {
335                                    unimplemented!("Relative position mode origin adjust ");
336                                }
337                            },
338                            _ => { /* Silently drop. */ }
339                        }
340                    }
341
342                    // Set Position is by definition a move only.
343                    let proj_x = (origin_x + current_x + origin_y + current_y) / 2.;
344                    let proj_y = -(origin_z + current_z) - (origin_y + current_y) / 2.
345                        + (origin_x + current_x) / 2.;
346                    svg.update_view_box(proj_x, proj_y);
347
348                    svg.parts.push(format!("M{proj_x} {proj_y}"));
349                }
350                _ => {}
351            }
352        }
353
354        svg
355    }
356}
357
358#[cfg(test)]
359mod svg {
360    use super::*;
361    use crate::command::Command;
362    use insta::assert_debug_snapshot;
363
364    // The first few lines of assets/3dBench.gcode
365    static INPUT: &str = r"
366; generated by Slic3r 1.2.9 on 2015-10-01 at 20:51:53
367
368; external perimeters extrusion width = 0.40mm
369; perimeters extrusion width = 0.67mm
370; infill extrusion width = 0.67mm
371; solid infill extrusion width = 0.67mm
372; top infill extrusion width = 0.67mm
373
374M107
375M190 S65 ; set bed temperature
376M104 S205 ; set temperature
377G28 ; home all axes
378G1 Z5 F5000 ; lift nozzle
379M109 S205 ; wait for temperature to be reached
380G21 ; set units to millimeters
381G90 ; use absolute coordinates
382M82 ; use absolute distances for extrusion
383G92 E0
384G1 E-1.00000 F1800.00000
385G92 E0
386G1 Z0.350 F7800.000
387";
388
389    #[test]
390    fn nothing_unhandled() {
391        // The first few lines of the benchy file must be recognized.
392        for line in INPUT.lines() {
393            assert!(Command::parse_line(line).is_ok());
394        }
395    }
396
397    #[test]
398    fn arc_clockwise() {
399        // SNAPSHOT tests
400        //
401        // Simple pattern essential for code coverage
402        //
403        // Ensures calculated theta values are in the range 0..360
404        // as measured in anticlockwise from the positive x-axis.
405        //
406        // 0 and 360 are the same point
407        // This test asserts that the cases where 360 must be used are correct.
408        let buffer = include_str!("../../../../assets/g3_box_rounded_anticlockwise.gcode");
409        let svg = buffer
410            .lines()
411            .map(std::string::ToString::to_string)
412            .collect::<Svg>();
413        assert_debug_snapshot!(svg);
414    }
415
416    #[test]
417    fn arc_anti_clockwise() {
418        // SNAPSHOT tests
419        //
420        // Simple pattern essential for code coverage
421        //
422        // Ensures calculated theta values are in the range 0..360
423        // as measured in anticlockwise from the positive x-axis.
424        //
425        // 0 and 360 are the same point
426        // This test asserts that the cases where 360 must be used are correct.
427        let buffer = include_str!("../../../../assets/g2_box_nibble_clockwise.gcode");
428        let svg = buffer
429            .lines()
430            .map(std::string::ToString::to_string)
431            .collect::<Svg>();
432        assert_debug_snapshot!(svg);
433    }
434
435    #[test]
436    fn arc_demo() {
437        // SNAPSHOT tests
438        let buffer = include_str!("../../../../assets/arc_demo.gcode");
439        let svg = buffer
440            .lines()
441            .map(std::string::ToString::to_string)
442            .collect::<Svg>();
443        assert_debug_snapshot!(svg);
444    }
445
446    #[test]
447    fn zero_crossing() {
448        // SNAPSHOT tests
449        //
450        // Complex model with lots of curves.
451        //
452        // NB This is the only test that covers both clockwise and anticlockwise
453        // zero crossings.
454        let buffer = include_str!("../../../../assets/both.gcode");
455        let svg = buffer
456            .lines()
457            .map(std::string::ToString::to_string)
458            .collect::<Svg>();
459        assert_debug_snapshot!(svg);
460    }
461}