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