Skip to main content

svg2gcode/
lib.rs

1/// Approximate [Bézier curves](https://en.wikipedia.org/wiki/B%C3%A9zier_curve) with [Circular arcs](https://en.wikipedia.org/wiki/Circular_arc)
2mod arc;
3/// Converts an SVG to an internal representation
4mod converter;
5/// Emulates the state of an arbitrary machine that can run G-Code
6mod machine;
7/// Operations that are easier to implement while/after G-Code is generated, or would
8/// otherwise over-complicate SVG conversion
9mod postprocess;
10/// Provides an interface for drawing lines in G-Code
11/// This concept is referred to as [Turtle graphics](https://en.wikipedia.org/wiki/Turtle_graphics).
12mod turtle;
13
14pub use converter::{ConversionConfig, ConversionOptions, svg2program};
15pub use machine::{Machine, MachineConfig, SupportedFunctionality};
16pub use postprocess::PostprocessConfig;
17pub use turtle::Turtle;
18
19/// A cross-platform type used to store all configuration types.
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21#[derive(Debug, Default, Clone, PartialEq)]
22pub struct Settings {
23    pub conversion: ConversionConfig,
24    pub machine: MachineConfig,
25    pub postprocess: PostprocessConfig,
26    #[cfg_attr(feature = "serde", serde(default = "Version::unknown"))]
27    pub version: Version,
28}
29
30impl Settings {
31    /// Try to automatically upgrade the supported version.
32    ///
33    /// This will return an error if:
34    ///
35    /// - Settings version is [`Version::Unknown`].
36    /// - There are breaking changes requiring manual intervention. In which case this does a partial update to that point.
37    pub fn try_upgrade(&mut self) -> Result<(), &'static str> {
38        loop {
39            match self.version {
40                // Compatibility for M2 by default
41                Version::V0 => {
42                    self.machine.end_sequence = Some(format!(
43                        "{} M2",
44                        self.machine.end_sequence.take().unwrap_or_default()
45                    ));
46                    self.version = Version::V5;
47                }
48                Version::V5 => break Ok(()),
49                Version::Unknown(_) => break Err("cannot upgrade unknown version"),
50            }
51        }
52    }
53}
54
55/// Used to control breaking change behavior for [`Settings`].
56///
57/// There were already 3 non-breaking version bumps (V1 -> V4) so versioning starts off with [`Version::V5`].
58#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
59#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
60pub enum Version {
61    /// Implicitly versioned settings from before this type was introduced.
62    V0,
63    /// M2 is no longer appended to the program by default
64    V5,
65    #[cfg_attr(feature = "serde", serde(untagged))]
66    Unknown(String),
67}
68
69impl Version {
70    /// Returns the most recent [`Version`]. This is useful for asking users to upgrade externally-stored settings.
71    pub const fn latest() -> Self {
72        Self::V5
73    }
74
75    /// Default version for old settings.
76    pub const fn unknown() -> Self {
77        Self::V0
78    }
79}
80
81impl std::fmt::Display for Version {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        match self {
84            Version::V0 => f.write_str("V0"),
85            Version::V5 => f.write_str("V5"),
86            Version::Unknown(unknown) => f.write_str(unknown),
87        }
88    }
89}
90
91impl Default for Version {
92    fn default() -> Self {
93        Self::latest()
94    }
95}
96
97#[cfg(test)]
98mod test {
99    use g_code::emit::{FormatOptions, Token};
100    use pretty_assertions::assert_eq;
101    use roxmltree::ParsingOptions;
102    use svgtypes::{Length, LengthUnit};
103
104    use super::*;
105
106    /// The values change between debug and release builds for circular interpolation,
107    /// so only check within a rough tolerance
108    const TOLERANCE: f64 = 1E-10;
109
110    fn get_actual(
111        input: &str,
112        circular_interpolation: bool,
113        dimensions: [Option<Length>; 2],
114    ) -> Vec<Token<'_>> {
115        let config = ConversionConfig::default();
116        let options = ConversionOptions { dimensions };
117        let document = roxmltree::Document::parse_with_options(
118            input,
119            ParsingOptions {
120                allow_dtd: true,
121                ..Default::default()
122            },
123        )
124        .unwrap();
125
126        let machine = Machine::new(
127            SupportedFunctionality {
128                circular_interpolation,
129            },
130            None,
131            None,
132            None,
133            None,
134        );
135        converter::svg2program(&document, &config, options, machine)
136    }
137
138    fn assert_close(left: Vec<Token<'_>>, right: Vec<Token<'_>>) {
139        let mut code = String::new();
140        g_code::emit::format_gcode_fmt(left.iter(), FormatOptions::default(), &mut code).unwrap();
141        assert_eq!(left.len(), right.len(), "{code}");
142        for (i, pair) in left.into_iter().zip(right.into_iter()).enumerate() {
143            match pair {
144                (Token::Field(l), Token::Field(r)) => {
145                    assert_eq!(l.letters, r.letters);
146                    if let (Some(l_value), Some(r_value)) = (l.value.as_f64(), r.value.as_f64()) {
147                        assert!(
148                            (l_value - r_value).abs() < TOLERANCE,
149                            "Values differ significantly at {i}: {l} vs {r} ({})",
150                            (l_value - r_value).abs()
151                        );
152                    } else {
153                        assert_eq!(l, r);
154                    }
155                }
156                (l, r) => {
157                    assert_eq!(l, r, "Differs at {i}");
158                }
159            }
160        }
161    }
162
163    #[test]
164    fn square_produces_expected_gcode() {
165        let expected = g_code::parse::file_parser(include_str!("../tests/square.gcode"))
166            .unwrap()
167            .iter_emit_tokens()
168            .collect::<Vec<_>>();
169        let actual = get_actual(include_str!("../tests/square.svg"), false, [None; 2]);
170
171        assert_close(actual, expected);
172    }
173
174    #[test]
175    fn square_dimension_override_produces_expected_gcode() {
176        let side_length = Length {
177            number: 10.,
178            unit: LengthUnit::Mm,
179        };
180
181        let expected = g_code::parse::file_parser(include_str!("../tests/square.gcode"))
182            .unwrap()
183            .iter_emit_tokens()
184            .collect::<Vec<_>>();
185
186        for square in [
187            include_str!("../tests/square.svg"),
188            include_str!("../tests/square_dimensionless.svg"),
189        ] {
190            assert_close(
191                get_actual(square, false, [Some(side_length); 2]),
192                expected.clone(),
193            );
194            assert_close(
195                get_actual(square, false, [Some(side_length), None]),
196                expected.clone(),
197            );
198            assert_close(
199                get_actual(square, false, [None, Some(side_length)]),
200                expected.clone(),
201            );
202        }
203    }
204
205    #[test]
206    fn square_transformed_produces_expected_gcode() {
207        let square_transformed = include_str!("../tests/square_transformed.svg");
208        let expected =
209            g_code::parse::file_parser(include_str!("../tests/square_transformed.gcode"))
210                .unwrap()
211                .iter_emit_tokens()
212                .collect::<Vec<_>>();
213        let actual = get_actual(square_transformed, false, [None; 2]);
214
215        assert_close(actual, expected)
216    }
217
218    #[test]
219    fn square_transformed_nested_produces_expected_gcode() {
220        let square_transformed = include_str!("../tests/square_transformed_nested.svg");
221        let expected =
222            g_code::parse::file_parser(include_str!("../tests/square_transformed_nested.gcode"))
223                .unwrap()
224                .iter_emit_tokens()
225                .collect::<Vec<_>>();
226        let actual = get_actual(square_transformed, false, [None; 2]);
227
228        assert_close(actual, expected)
229    }
230
231    #[test]
232    fn square_viewport_produces_expected_gcode() {
233        let square_viewport = include_str!("../tests/square_viewport.svg");
234        let expected = g_code::parse::file_parser(include_str!("../tests/square_viewport.gcode"))
235            .unwrap()
236            .iter_emit_tokens()
237            .collect::<Vec<_>>();
238        let actual = get_actual(square_viewport, false, [None; 2]);
239
240        assert_close(actual, expected);
241    }
242
243    #[test]
244    fn circular_interpolation_produces_expected_gcode() {
245        let circular_interpolation = include_str!("../tests/circular_interpolation.svg");
246        let expected =
247            g_code::parse::file_parser(include_str!("../tests/circular_interpolation.gcode"))
248                .unwrap()
249                .iter_emit_tokens()
250                .collect::<Vec<_>>();
251        let actual = get_actual(circular_interpolation, true, [None; 2]);
252
253        assert_close(actual, expected)
254    }
255
256    #[test]
257    fn svg_with_smooth_curves_produces_expected_gcode() {
258        let svg = include_str!("../tests/smooth_curves.svg");
259
260        let expected = g_code::parse::file_parser(include_str!("../tests/smooth_curves.gcode"))
261            .unwrap()
262            .iter_emit_tokens()
263            .collect::<Vec<_>>();
264
265        let file = if cfg!(debug) {
266            include_str!("../tests/smooth_curves_circular_interpolation.gcode")
267        } else {
268            include_str!("../tests/smooth_curves_circular_interpolation_release.gcode")
269        };
270        let expected_circular_interpolation = g_code::parse::file_parser(file)
271            .unwrap()
272            .iter_emit_tokens()
273            .collect::<Vec<_>>();
274        assert_close(get_actual(svg, false, [None; 2]), expected);
275
276        assert_close(
277            get_actual(svg, true, [None; 2]),
278            expected_circular_interpolation,
279        );
280    }
281
282    #[test]
283    fn shapes_produces_expected_gcode() {
284        let shapes = include_str!("../tests/shapes.svg");
285        let expected = g_code::parse::file_parser(include_str!("../tests/shapes.gcode"))
286            .unwrap()
287            .iter_emit_tokens()
288            .collect::<Vec<_>>();
289        let actual = get_actual(shapes, false, [None; 2]);
290
291        assert_close(actual, expected)
292    }
293
294    #[test]
295    fn use_defs_produces_expected_gcode() {
296        let svg = include_str!("../tests/use_defs.svg");
297        let expected = g_code::parse::file_parser(include_str!("../tests/use_defs.gcode"))
298            .unwrap()
299            .iter_emit_tokens()
300            .collect::<Vec<_>>();
301        let actual = get_actual(svg, false, [None; 2]);
302
303        assert_close(actual, expected)
304    }
305
306    #[test]
307    fn use_xlink_href_produces_expected_gcode() {
308        let svg = include_str!("../tests/use_xlink_href.svg");
309        let expected = g_code::parse::file_parser(include_str!("../tests/use_xlink_href.gcode"))
310            .unwrap()
311            .iter_emit_tokens()
312            .collect::<Vec<_>>();
313        let actual = get_actual(svg, false, [None; 2]);
314
315        assert_close(actual, expected)
316    }
317
318    #[test]
319    fn use_symbol_produces_expected_gcode() {
320        let svg = include_str!("../tests/use_symbol.svg");
321        let expected = g_code::parse::file_parser(include_str!("../tests/use_symbol.gcode"))
322            .unwrap()
323            .iter_emit_tokens()
324            .collect::<Vec<_>>();
325        let actual = get_actual(svg, false, [None; 2]);
326
327        assert_close(actual, expected);
328    }
329
330    #[test]
331    fn transform_origin_produces_expected_gcode() {
332        let svg = include_str!("../tests/transform_origin.svg");
333        let expected = g_code::parse::file_parser(include_str!("../tests/transform_origin.gcode"))
334            .unwrap()
335            .iter_emit_tokens()
336            .collect::<Vec<_>>();
337        let actual = get_actual(svg, false, [None; 2]);
338        assert_close(actual, expected)
339    }
340
341    /// `transform-origin="5 5"` with `rotate(90)` should be identical to the
342    /// manual SVG equivalent `translate(5,5) rotate(90) translate(-5,-5)`
343    #[test]
344    fn transform_origin_matches_manual_equivalent() {
345        let with_origin = get_actual(
346            include_str!("../tests/transform_origin.svg"),
347            false,
348            [None; 2],
349        );
350        let manual = get_actual(
351            include_str!("../tests/transform_origin_equivalent.svg"),
352            false,
353            [None; 2],
354        );
355        assert_close(with_origin, manual)
356    }
357
358    #[test]
359    #[cfg(feature = "serde")]
360    fn deserialize_v1_config_succeeds() {
361        let json = r#"
362        {
363            "conversion": {
364              "tolerance": 0.002,
365              "feedrate": 300.0,
366              "dpi": 96.0
367            },
368            "machine": {
369              "supported_functionality": {
370                "circular_interpolation": true
371              },
372              "tool_on_sequence": null,
373              "tool_off_sequence": null,
374              "begin_sequence": null,
375              "end_sequence": null
376            },
377            "postprocess": {
378              "origin": [
379                0.0,
380                0.0
381              ]
382            }
383          }
384        "#;
385        serde_json::from_str::<Settings>(json).unwrap();
386    }
387
388    #[test]
389    #[cfg(feature = "serde")]
390    fn deserialize_v2_config_succeeds() {
391        let json = r#"
392        {
393            "conversion": {
394              "tolerance": 0.002,
395              "feedrate": 300.0,
396              "dpi": 96.0
397            },
398            "machine": {
399              "supported_functionality": {
400                "circular_interpolation": true
401              },
402              "tool_on_sequence": null,
403              "tool_off_sequence": null,
404              "begin_sequence": null,
405              "end_sequence": null
406            },
407            "postprocess": { }
408          }
409        "#;
410        serde_json::from_str::<Settings>(json).unwrap();
411    }
412
413    #[test]
414    #[cfg(feature = "serde")]
415    fn deserialize_v3_config_succeeds() {
416        let json = r#"
417        {
418            "conversion": {
419              "tolerance": 0.002,
420              "feedrate": 300.0,
421              "dpi": 96.0
422            },
423            "machine": {
424              "supported_functionality": {
425                "circular_interpolation": true
426              },
427              "tool_on_sequence": null,
428              "tool_off_sequence": null,
429              "begin_sequence": null,
430              "end_sequence": null
431            },
432            "postprocess": {
433                "checksums": false,
434                "line_numbers": false
435            }
436          }
437        "#;
438        serde_json::from_str::<Settings>(json).unwrap();
439    }
440
441    #[test]
442    #[cfg(feature = "serde")]
443    fn deserialize_v4_config_succeeds() {
444        let json = r#"
445        {
446            "conversion": {
447              "tolerance": 0.002,
448              "feedrate": 300.0,
449              "dpi": 96.0
450            },
451            "machine": {
452              "supported_functionality": {
453                "circular_interpolation": true
454              },
455              "tool_on_sequence": null,
456              "tool_off_sequence": null,
457              "begin_sequence": null,
458              "end_sequence": null
459            },
460            "postprocess": {
461                "checksums": false,
462                "line_numbers": false,
463                "newline_before_comment": false
464            }
465          }
466        "#;
467        serde_json::from_str::<Settings>(json).unwrap();
468    }
469
470    #[test]
471    #[cfg(feature = "serde")]
472    fn deserialize_v5_config_succeeds() {
473        let json = r#"
474        {
475            "conversion": {
476              "tolerance": 0.002,
477              "feedrate": 300.0,
478              "dpi": 96.0
479            },
480            "machine": {
481              "supported_functionality": {
482                "circular_interpolation": true
483              },
484              "tool_on_sequence": null,
485              "tool_off_sequence": null,
486              "begin_sequence": null,
487              "end_sequence": null
488            },
489            "postprocess": {
490                "checksums": false,
491                "line_numbers": false,
492                "newline_before_comment": false
493            },
494            "version": "V5"
495          }
496        "#;
497        serde_json::from_str::<Settings>(json).unwrap();
498    }
499}