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}