libgcad/
gcode.rs

1use std::{collections::HashMap, io::Write};
2
3use anyhow::{bail, Result};
4use nalgebra::{Matrix3, Point2};
5
6const RETRACT: f64 = 0.25;
7
8pub struct GcodeState {
9	pub stepover: f64,
10	pub depth_per_pass: f64,
11	pub feed_rate: f64,
12	pub plunge_rate: f64,
13	pub cutter_diameter: f64,
14
15	pub transformation: Matrix3<f64>,
16
17	program: Vec<GCode>,
18}
19
20impl GcodeState {
21	pub fn new() -> GcodeState {
22		GcodeState {
23			stepover: 0.0,
24			depth_per_pass: 0.0,
25			feed_rate: 0.0,
26			plunge_rate: 0.0,
27			cutter_diameter: 0.0,
28
29			transformation: Matrix3::identity(),
30
31			program: Vec::new(),
32		}
33	}
34
35	pub fn write_header(&mut self) {
36		self.program.push(GCode::AbsoluteDistanceMode);
37		self.program.push(GCode::MetricUnits);
38		self.program.push(GCode::Comment("Move to safe Z".to_string()));
39		self.program.push(GCode::MoveInAbsoluteCoordinates(Box::new(GCode::RapidMove {
40			x: None,
41			y: None,
42			z: Some(-5.0),
43		})));
44		self.program.push(GCode::SpindleStop);
45	}
46
47	pub fn set_rpm(&mut self, rpm: f64) {
48		self.program.push(GCode::SpindleOnCW { rpm });
49	}
50
51	pub fn write_comment(&mut self, comment: &str) {
52		self.program.push(GCode::Comment(comment.to_string()));
53	}
54
55	pub fn cutting_move(&mut self, x: f64, y: f64, z: Option<f64>) {
56		let xy = Point2::new(x, y);
57		let xy = self.transformation.transform_point(&xy);
58
59		self.program.push(GCode::LinearMove {
60			x: Some(xy.x),
61			y: Some(xy.y),
62			z,
63			feed: self.feed_rate,
64		});
65	}
66
67	pub fn plunge(&mut self, z: f64) {
68		self.program.push(GCode::LinearMove {
69			x: None,
70			y: None,
71			z: Some(z),
72			feed: self.plunge_rate,
73		});
74	}
75
76	pub fn rapid_move(&mut self, x: f64, y: f64, z: Option<f64>) {
77		let xy = Point2::new(x, y);
78		let xy = self.transformation.transform_point(&xy);
79
80		self.program.push(GCode::RapidMove {
81			x: Some(xy.x),
82			y: Some(xy.y),
83			z,
84		});
85	}
86
87	pub fn rapid_move_xy(&mut self, x: f64, y: f64) {
88		self.rapid_move(x, y, None)
89	}
90
91	pub fn arc_cut(&mut self, x: f64, y: f64, cx: f64, cy: f64) {
92		let xy = self.transformation.transform_point(&Point2::new(x, y));
93		let cxy = self.transformation.transform_point(&Point2::new(cx, cy));
94
95		self.program.push(GCode::CounterClockwiseArc {
96			x: xy.x,
97			y: xy.y,
98			cx: cxy.x,
99			cy: cxy.y,
100			feed: self.feed_rate,
101		});
102	}
103
104	pub fn drill(&mut self, x: f64, y: f64, depth: f64) {
105		self.rapid_move_xy(x, y);
106		self.rapid_move(x, y, Some(0.25));
107		self.plunge(-depth);
108		self.rapid_move(x, y, Some(5.0));
109	}
110
111	pub fn contour_line(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, depth: f64) -> Result<()> {
112		if self.depth_per_pass <= 0.0 {
113			bail!("Invalid depth per pass");
114		}
115
116		let n_passes = (depth / self.depth_per_pass).ceil() as i64;
117
118		for layer in 1..=n_passes {
119			let z = -(depth * layer as f64 / n_passes as f64);
120			self.rapid_move_xy(x1, y1);
121			self.plunge(z);
122			self.cutting_move(x2, y2, None);
123			self.rapid_move(x2, y2, Some(5.0));
124		}
125
126		Ok(())
127	}
128
129	pub fn circle_pocket(&mut self, cx: f64, cy: f64, diameter: f64, depth: f64) -> Result<()> {
130		if diameter <= self.cutter_diameter {
131			bail!("Diameter must be greater than cutter diameter");
132		}
133
134		if self.depth_per_pass <= 0.0 {
135			bail!("Invalid depth per pass: {}", self.depth_per_pass);
136		}
137
138		if self.cutter_diameter <= 0.0 {
139			bail!("Invalid cutter diameter: {}", self.cutter_diameter);
140		}
141
142		let n_circles = (diameter / self.cutter_diameter).floor() as i64;
143		let n_passes = (depth / self.depth_per_pass).ceil() as i64;
144		let x_offset = (diameter / 2.0) - (self.cutter_diameter * n_circles as f64 / 2.0);
145
146		self.rapid_move_xy(cx + x_offset, cy);
147		self.plunge(2.5);
148
149		for i in 1..=n_passes {
150			self.plunge(-(depth * i as f64 / n_passes as f64));
151
152			for j in 1..=n_circles {
153				self.arc_cut(cx - x_offset - self.cutter_diameter * (j - 1) as f64 / 2.0, cy, cx, cy);
154
155				if j == n_circles {
156					self.arc_cut(cx + x_offset + self.cutter_diameter * (j - 1) as f64 / 2.0, cy, cx, cy);
157				} else {
158					self.arc_cut(cx + x_offset + self.cutter_diameter * j as f64 / 2.0, cy, cx + self.cutter_diameter / 4.0, cy);
159				}
160			}
161
162			if i < n_passes {
163				self.cutting_move(cx + x_offset, cy, None);
164			}
165		}
166
167		self.rapid_move(cx + x_offset + self.cutter_diameter * (n_circles - 1) as f64 / 2.0, cy, Some(5.0));
168
169		Ok(())
170	}
171
172	pub fn finish<W: Write>(&mut self, writer: W) -> Result<()> {
173		self.program.push(GCode::ProgramEnd);
174		self.write_program(writer)
175	}
176
177	fn write_program<W: Write>(&self, mut writer: W) -> Result<()> {
178		let mut last_command = None;
179		let mut state = HashMap::new();
180
181		for line in &self.program {
182			if let GCode::Comment(comment) = &line {
183				writer.write_all(format!("({})\n", comment).as_bytes())?;
184				continue;
185			}
186			let words = line.to_words(state.get(&'X').cloned(), state.get(&'Y').cloned())?;
187			let mut pieces = Vec::new();
188			let mut g53 = false;
189
190			for word in &words {
191				match word {
192					GcodeWord::G(g) => {
193						if *g == 53 {
194							g53 = true;
195							last_command = None;
196						}
197
198						if last_command != Some(*word) {
199							pieces.push(*word);
200						}
201					},
202					GcodeWord::M(_) => {
203						if last_command != Some(*word) {
204							pieces.push(*word);
205						}
206					},
207					GcodeWord::X(v) | GcodeWord::Y(v) | GcodeWord::Z(v) | GcodeWord::I(v) | GcodeWord::J(v) | GcodeWord::F(v) | GcodeWord::S(v) => {
208						if g53 || state.get(&word.to_char()) != Some(v) {
209							pieces.push(*word);
210						}
211					},
212				}
213			}
214
215			// If the command is completely empty or the line does nothing, skip it
216			if pieces.is_empty() || line.is_empty(&pieces) {
217				continue;
218			}
219
220			writer.write_all(pieces.iter().map(|w| w.to_string()).collect::<Vec<String>>().join(" ").as_bytes())?;
221			writer.write_all(b"\n")?;
222
223			// Update state based on the command as written
224			for word in pieces {
225				match word {
226					GcodeWord::G(_) | GcodeWord::M(_) => {
227						if !g53 {
228							last_command = Some(word)
229						}
230					},
231					GcodeWord::X(v) | GcodeWord::Y(v) | GcodeWord::Z(v) | GcodeWord::I(v) | GcodeWord::J(v) | GcodeWord::F(v) | GcodeWord::S(v) => {
232						if !g53 {
233							state.insert(word.to_char(), v);
234						} else {
235							// Since we don't know the machine coordinate system, we have to nuke the state of any modified positions
236							state.remove(&word.to_char());
237						}
238					},
239				}
240			}
241		}
242
243		Ok(())
244	}
245
246	/// Cuts a rectangular pocket with the given dimensions, and x y specifying the lower left corner.
247	/// Note that this only handles narrow rectangles right now, hence the name groove.
248	pub fn groove_pocket(&mut self, x: f64, y: f64, width: f64, height: f64, depth: f64) -> Result<()> {
249		if self.stepover <= 0.0 {
250			bail!("Invalid stepover: {}", self.stepover);
251		}
252
253		if self.depth_per_pass <= 0.0 {
254			bail!("Invalid depth per pass: {}", self.depth_per_pass);
255		}
256
257		// Build the cutting pattern backwards
258		let mut pattern = Vec::new();
259
260		let mut c_x = x + self.cutter_diameter / 2.0;
261		let mut c_y = y + self.cutter_diameter / 2.0;
262		let mut c_width = width - self.cutter_diameter;
263		let mut c_height = height - self.cutter_diameter;
264		let n_passes = (depth / self.depth_per_pass).ceil() as i64;
265		let n_loops = 1 + (((width / 2.0) - self.cutter_diameter) / self.stepover).ceil() as i64;
266
267		for _ in 0..n_loops {
268			pattern.push((c_x, c_y));
269			c_x += c_width;
270			pattern.push((c_x, c_y));
271			c_y += c_height;
272			pattern.push((c_x, c_y));
273			c_x -= c_width;
274			pattern.push((c_x, c_y));
275			c_y -= c_height;
276			pattern.push((c_x, c_y));
277			c_x += self.stepover;
278			c_y += self.stepover;
279			c_width -= 2.0 * self.stepover;
280			c_height -= 2.0 * self.stepover;
281		}
282
283		pattern.reverse();
284
285		for layer in 1..=n_passes {
286			let z = -(depth * layer as f64 / n_passes as f64);
287			let (x, y) = pattern[0];
288
289			if layer == 1 {
290				self.rapid_move_xy(x, y);
291				self.rapid_move(x, y, Some(5.0));
292				self.plunge(z);
293			} else {
294				self.rapid_move_xy(x, y);
295				self.plunge(z);
296			}
297
298			for (x, y) in pattern.iter().skip(1) {
299				self.cutting_move(*x, *y, None);
300			}
301
302			if layer == n_passes {
303				self.rapid_move(x, y, Some(5.0));
304			} else {
305				self.rapid_move(x, y, Some(z + RETRACT));
306			}
307		}
308
309		Ok(())
310	}
311}
312
313
314fn format_number(f: f64) -> String {
315	let mut s = format!("{:.3}", f);
316	let t = s.trim_end_matches('0').trim_end_matches('.').len();
317	s.truncate(t);
318	s
319}
320
321
322#[derive(PartialEq, Clone, Debug)]
323enum GCode {
324	Comment(String),
325	RapidMove {
326		x: Option<f64>,
327		y: Option<f64>,
328		z: Option<f64>,
329	}, // G0
330	LinearMove {
331		x: Option<f64>,
332		y: Option<f64>,
333		z: Option<f64>,
334		feed: f64,
335	}, // G1
336	CounterClockwiseArc {
337		x: f64,
338		y: f64,
339		cx: f64,
340		cy: f64,
341		feed: f64,
342	}, // G3
343	MetricUnits,                          // G21
344	MoveInAbsoluteCoordinates(Box<Self>), // G53
345	AbsoluteDistanceMode,                 // G90
346
347	ProgramEnd, // M02
348	SpindleOnCW {
349		rpm: f64,
350	}, // M03
351	SpindleStop, // M05
352}
353
354#[derive(PartialEq, Clone, Debug, Copy)]
355enum GcodeWord {
356	G(u8),
357	M(u8),
358	F(f64),
359	I(f64),
360	J(f64),
361	S(f64),
362	X(f64),
363	Y(f64),
364	Z(f64),
365}
366
367impl GCode {
368	fn to_words(&self, current_x: Option<f64>, current_y: Option<f64>) -> Result<Vec<GcodeWord>> {
369		Ok(match self {
370			GCode::RapidMove { x, y, z } => vec![Some(GcodeWord::G(0)), x.map(GcodeWord::X), y.map(GcodeWord::Y), z.map(GcodeWord::Z)]
371				.into_iter()
372				.flatten()
373				.collect(),
374			GCode::LinearMove { x, y, z, feed } => vec![
375				Some(GcodeWord::G(1)),
376				x.map(GcodeWord::X),
377				y.map(GcodeWord::Y),
378				z.map(GcodeWord::Z),
379				Some(GcodeWord::F(*feed)),
380			]
381			.into_iter()
382			.flatten()
383			.collect(),
384			GCode::CounterClockwiseArc { x, y, cx, cy, feed } => {
385				if let (Some(current_x), Some(current_y)) = (current_x, current_y) {
386					vec![
387						Some(GcodeWord::G(3)),
388						Some(GcodeWord::X(*x)),
389						Some(GcodeWord::Y(*y)),
390						Some(GcodeWord::I(*cx - current_x)),
391						Some(GcodeWord::J(*cy - current_y)),
392						Some(GcodeWord::F(*feed)),
393					]
394					.into_iter()
395					.flatten()
396					.collect()
397				} else {
398					bail!("Cannot generate G3 arc without current position");
399				}
400			},
401			GCode::MetricUnits => vec![GcodeWord::G(21)],
402			GCode::MoveInAbsoluteCoordinates(gcode) => {
403				let mut words = gcode.to_words(current_x, current_y)?;
404				words.insert(0, GcodeWord::G(53));
405				words
406			},
407			GCode::AbsoluteDistanceMode => vec![GcodeWord::G(90)],
408			GCode::ProgramEnd => vec![GcodeWord::M(2)],
409			GCode::SpindleOnCW { rpm } => vec![GcodeWord::M(3), GcodeWord::S(*rpm)],
410			GCode::SpindleStop => vec![GcodeWord::M(5)],
411			GCode::Comment(_) => unreachable!(),
412		})
413	}
414
415	fn is_empty(&self, words: &[GcodeWord]) -> bool {
416		let pos_present = words.iter().any(|w| matches!(w, GcodeWord::X(_) | GcodeWord::Y(_) | GcodeWord::Z(_)));
417
418		let s_present = words.iter().any(|w| matches!(w, GcodeWord::S(_)));
419
420		match self {
421			GCode::Comment(_) => unreachable!(),
422			GCode::RapidMove { x: _, y: _, z: _ } => !pos_present,
423			GCode::LinearMove { x: _, y: _, z: _, feed: _ } => !pos_present,
424			GCode::CounterClockwiseArc {
425				x: _,
426				y: _,
427				cx: _,
428				cy: _,
429				feed: _,
430			} => !pos_present,
431			GCode::MetricUnits | GCode::AbsoluteDistanceMode | GCode::ProgramEnd | GCode::SpindleStop | GCode::MoveInAbsoluteCoordinates(_) => false,
432			GCode::SpindleOnCW { rpm: _ } => !s_present,
433		}
434	}
435}
436
437impl ToString for GcodeWord {
438	fn to_string(&self) -> String {
439		match self {
440			GcodeWord::G(n) => format!("G{}", n),
441			GcodeWord::M(n) => format!("M{:02}", n),
442			GcodeWord::F(n) => format!("F{}", format_number(*n)),
443			GcodeWord::I(n) => format!("I{}", format_number(*n)),
444			GcodeWord::J(n) => format!("J{}", format_number(*n)),
445			GcodeWord::S(n) => format!("S{}", format_number(*n)),
446			GcodeWord::X(n) => format!("X{}", format_number(*n)),
447			GcodeWord::Y(n) => format!("Y{}", format_number(*n)),
448			GcodeWord::Z(n) => format!("Z{}", format_number(*n)),
449		}
450	}
451}
452
453impl GcodeWord {
454	fn to_char(self) -> char {
455		match self {
456			GcodeWord::G(_) => 'G',
457			GcodeWord::M(_) => 'M',
458			GcodeWord::F(_) => 'F',
459			GcodeWord::I(_) => 'I',
460			GcodeWord::J(_) => 'J',
461			GcodeWord::S(_) => 'S',
462			GcodeWord::X(_) => 'X',
463			GcodeWord::Y(_) => 'Y',
464			GcodeWord::Z(_) => 'Z',
465		}
466	}
467}