diff --git a/Cargo.lock b/Cargo.lock index e7ae1d0e1..13b4f60fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -958,6 +958,13 @@ dependencies = [ "craft-fonts-build", ] +[[package]] +name = "craft-gcode" +version = "0.1.0" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "craft-heif" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3177250c9..fdfcf3b67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ craft-ecproj-format = { path = "crates/ecproj-format" } craft-font-catalog = { path = "crates/font-catalog" } craft-font-manager = { path = "crates/font-manager" } craft-font-store = { path = "crates/font-store" } +craft-gcode = { path = "crates/gcode" } craft-heif = { path = "crates/heif" } craft-fonts-build = { path = "crates/fonts-build" } craft-i18n = { path = "crates/i18n" } diff --git a/README.md b/README.md index 6b64e33e7..460b45bea 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " &'static str { + match self { + Self::Dynamic => "M4", + Self::Constant => "M3", + } + } + + /// Parse the CLI/protocol spelling. + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "dynamic" | "m4" => Some(Self::Dynamic), + "constant" | "m3" => Some(Self::Constant), + _ => None, + } + } +} + +/// How every contour of a program is traced. Power is given per contour. +#[derive(Debug, Clone, PartialEq)] +pub struct Settings { + /// Feed rate while cutting, millimetres per minute. + pub feed_mm_per_min: f64, + /// How many times each contour is traced, 1–[`MAX_PASSES`]. + pub passes: u32, + /// Spread the power across passes so the last one runs at full power. Useful on material + /// that scorches at full power in one hit. + pub ramp_power: bool, + /// Which on/off command the controller understands. + pub mode: LaserMode, +} + +impl Default for Settings { + fn default() -> Self { + Self { feed_mm_per_min: 1000.0, passes: 1, ramp_power: false, mode: LaserMode::Dynamic } + } +} + +impl Settings { + /// Checks every knob is inside its range. + pub fn validate(&self) -> Result<(), Error> { + // Written to three decimals: a smaller rate would come out as `F0`, which Grbl rejects. + if !self.feed_mm_per_min.is_finite() || self.feed_mm_per_min < 0.001 { + return Err(Error::Settings(format!("feed rate {} mm/min is not a finite rate of at least 0.001", self.feed_mm_per_min))); + } + if self.passes == 0 || self.passes > MAX_PASSES { + return Err(Error::Settings(format!("passes {} is outside the 1–{MAX_PASSES} range", self.passes))); + } + Ok(()) + } + + /// Power for one pass of a contour running at `base` power. Without a ramp every pass is at + /// `base`; with one, pass `n` of `N` gets `base × n / N`, so the last pass is the full-power + /// pass. + pub fn power_for_pass(&self, base: u32, pass: u32) -> u32 { + if !self.ramp_power || self.passes <= 1 || pass >= self.passes { + return base; + } + let scaled = u64::from(base) * u64::from(pass) / u64::from(self.passes); + let scaled = u32::try_from(scaled).unwrap_or(base); + // A pass at S0 would trace the path with the beam off. + scaled.max(1).min(base) + } +} + +/// Checks a power is one the `S` word accepts; `what` names it in the error ("power", +/// "engrave power"). +pub fn check_power(what: &str, power: u32) -> Result<(), Error> { + if power > MAX_POWER { + return Err(Error::Settings(format!("{what} {power} is outside the 0–{MAX_POWER} range"))); + } + Ok(()) +} + +/// Builds one program: [`Writer::new`] writes the header, [`Writer::finish`] the footer. +#[derive(Debug)] +pub struct Writer { + out: String, + settings: Settings, +} + +impl Writer { + /// Starts a program with `banner` as its first comment line. + pub fn new(settings: Settings, banner: &str) -> Result { + settings.validate()?; + let mut w = Writer { out: String::with_capacity(4096), settings }; + w.comment(banner); + let _ = writeln!(w.out, "G21 ; millimetres"); + let _ = writeln!(w.out, "G90 ; absolute coordinates"); + let _ = writeln!(w.out, "G17 ; XY plane"); + // Start dark: the first move is a rapid to the first contour. + let _ = writeln!(w.out, "{} S0 ; laser off until the first cut", w.settings.mode.on_command()); + Ok(w) + } + + /// A comment line. Control characters become spaces: a line break in `text` (a layer name + /// from a file, say) would otherwise start a line the machine runs as a command. Grbl acts + /// on its real-time bytes (`!` feed hold, `?`, `~` and everything past ASCII) wherever they + /// arrive, comments included, so those become `_`. + pub fn comment(&mut self, text: &str) { + let safe = |c: char| match c { + c if c.is_control() => ' ', + '!' | '?' | '~' => '_', + c if !c.is_ascii() => '_', + c => c, + }; + let text: String = text.chars().map(safe).collect(); + let _ = writeln!(self.out, "; {text}"); + } + + /// One contour along `points` (x, y in millimetres) at `power`, traced once per pass. A + /// closed contour ends where it started; a single point is a minimal in-and-out dot; an + /// empty one writes nothing. A refused contour writes nothing either. + pub fn contour(&mut self, points: &[(f64, f64)], closed: bool, power: u32) -> Result<(), Error> { + check_power("power", power)?; + let Some(&first) = points.first() else { + return Ok(()); + }; + if points.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { + return Err(Error::NotFinite); + } + let mut path = points.to_vec(); + if points.len() < 2 { + path.extend([(first.0 + 0.001, first.1), first]); + } else if closed { + path.push(first); + } + let on = self.settings.mode.on_command(); + let passes = self.settings.passes; + for pass in 1..=passes { + let _ = writeln!(self.out, "G0 X{:.4} Y{:.4}", first.0, first.1); + let power = self.settings.power_for_pass(power, pass); + if passes > 1 { + let _ = writeln!(self.out, "{on} S{power} ; pass {pass} of {passes}"); + } else { + let _ = writeln!(self.out, "{on} S{power}"); + } + for p in path.iter().skip(1) { + let _ = writeln!(self.out, "G1 X{:.4} Y{:.4} F{}", p.0, p.1, (self.settings.feed_mm_per_min * 1000.0).round() / 1000.0); + } + // Never rapid with the beam on, whatever the mode or controller. + let _ = writeln!(self.out, "M5"); + } + Ok(()) + } + + /// Turns the tool off, returns to the origin and gives the program. + pub fn finish(mut self) -> String { + let _ = writeln!(self.out, "M5 ; tool off"); + let _ = writeln!(self.out, "G0 X0 Y0 ; return to origin"); + self.out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn program(settings: Settings, points: &[(f64, f64)], closed: bool) -> String { + let mut w = Writer::new(settings, "test").unwrap(); + w.contour(points, closed, 500).unwrap(); + w.finish() + } + + #[test] + fn a_line_is_a_whole_program() { + assert_eq!( + program(Settings::default(), &[(0.0, 0.0), (10.0, 0.0)], false), + "; test\nG21 ; millimetres\nG90 ; absolute coordinates\nG17 ; XY plane\nM4 S0 ; laser off until the first cut\n\ + G0 X0.0000 Y0.0000\nM4 S500\nG1 X10.0000 Y0.0000 F1000\nM5\nM5 ; tool off\nG0 X0 Y0 ; return to origin\n" + ); + } + + #[test] + fn closed_loops_return_to_start_and_a_point_is_a_dot() { + let g = program(Settings::default(), &[(1.0, 2.0), (10.0, 2.0), (10.0, 10.0)], true); + assert!(g.contains("G1 X10.0000 Y10.0000 F1000\nG1 X1.0000 Y2.0000 F1000\nM5\n"), "{g}"); + // Closed or not, a dot is the same in-and-out move. + for closed in [false, true] { + let dot = program(Settings::default(), &[(5.0, 5.0)], closed); + assert!(dot.contains("G0 X5.0000 Y5.0000\nM4 S500\nG1 X5.0010 Y5.0000 F1000\nG1 X5.0000 Y5.0000 F1000\nM5\n"), "{dot}"); + } + assert!(!program(Settings::default(), &[], true).contains("G1 ")); + } + + #[test] + fn rapids_never_run_with_the_beam_on() { + let settings = Settings { passes: 3, mode: LaserMode::Constant, ..Settings::default() }; + let mut w = Writer::new(settings, "test").unwrap(); + w.contour(&[(0.0, 0.0), (10.0, 0.0)], false, 500).unwrap(); + w.contour(&[(20.0, 0.0), (30.0, 0.0), (30.0, 5.0)], true, 200).unwrap(); + let g = w.finish(); + let mut lit = false; + for line in g.lines().filter(|l| !l.starts_with(';')) { + if line.starts_with("M3 S0") || line.starts_with("M5") { + lit = false; + } else if line.starts_with("M3 ") { + lit = true; + } else if line.starts_with("G0") { + assert!(!lit, "rapid with the beam on:\n{g}"); + } + } + assert!(!g.contains("M4"), "{g}"); + assert_eq!(g.matches("G1 X10.0000 Y0.0000 F1000").count(), 3, "{g}"); + assert_eq!(g.matches("M3 S500 ; pass ").count(), 3, "{g}"); + } + + #[test] + fn ramp_power_climbs_to_the_set_power() { + let ramp = Settings { passes: 4, ramp_power: true, ..Settings::default() }; + assert_eq!([1, 2, 3, 4].map(|pass| ramp.power_for_pass(800, pass)), [200, 400, 600, 800]); + let flat = Settings { passes: 4, ..Settings::default() }; + assert_eq!(flat.power_for_pass(800, 1), 800); + // A ramped pass never drops to S0, which would trace with the beam off. + let fine = Settings { passes: 10, ramp_power: true, ..Settings::default() }; + assert_eq!(fine.power_for_pass(3, 1), 1); + let g = program(ramp, &[(0.0, 0.0), (1.0, 0.0)], false); + assert!(g.contains("M4 S125 ; pass 1 of 4") && g.contains("M4 S500 ; pass 4 of 4"), "{g}"); + } + + #[test] + fn bad_settings_powers_and_vertices_are_errors() { + for feed in [0.0, -1.0, f64::NAN, f64::INFINITY] { + assert!(matches!(Writer::new(Settings { feed_mm_per_min: feed, ..Settings::default() }, ""), Err(Error::Settings(_)))); + } + for passes in [0, MAX_PASSES + 1] { + assert!(matches!(Settings { passes, ..Settings::default() }.validate(), Err(Error::Settings(_)))); + } + assert_eq!(check_power("engrave power", 1001), Err(Error::Settings("engrave power 1001 is outside the 0–1000 range".into()))); + let mut w = Writer::new(Settings::default(), "").unwrap(); + assert!(matches!(w.contour(&[(0.0, 0.0), (1.0, 1.0)], false, MAX_POWER + 1), Err(Error::Settings(_)))); + assert_eq!(w.contour(&[(0.0, 0.0), (f64::NAN, 1.0)], false, 500), Err(Error::NotFinite)); + // Nothing of a refused contour is written, not even its travel move. + assert!(!w.finish().contains("G0 X0.0000")); + } + + #[test] + fn grbl_real_time_bytes_never_reach_the_program() { + let mut w = Writer::new(Settings::default(), "stop! now? ~ café").unwrap(); + w.comment("\u{18}reset"); + let g = w.finish(); + assert!(g.starts_with("; stop_ now_ _ caf_\n"), "{g}"); + assert!(g.is_ascii() && !g.contains(['!', '?', '~', '\u{18}']), "{g}"); + } + + #[test] + fn a_fractional_feed_is_written_not_rounded_to_zero() { + let slow = Settings { feed_mm_per_min: 0.25, ..Settings::default() }; + assert!(program(slow, &[(0.0, 0.0), (1.0, 0.0)], false).contains(" F0.25\n")); + assert!(program(Settings::default(), &[(0.0, 0.0), (1.0, 0.0)], false).contains(" F1000\n")); + assert!(matches!(Writer::new(Settings { feed_mm_per_min: 0.0004, ..Settings::default() }, ""), Err(Error::Settings(_)))); + } + + #[test] + fn a_line_break_in_a_comment_cannot_start_a_command() { + let mut w = Writer::new(Settings::default(), "banner").unwrap(); + w.comment("layer x\nM3 S1000\r\nG1 X9"); + let g = w.finish(); + assert!(g.contains("; layer x M3 S1000 G1 X9\n"), "{g}"); + assert!(g.lines().all(|l| !l.starts_with("M3"))); + } + + #[test] + fn laser_modes_parse_their_spellings() { + assert_eq!(LaserMode::parse(" M3 "), Some(LaserMode::Constant)); + assert_eq!(LaserMode::parse("Dynamic"), Some(LaserMode::Dynamic)); + assert_eq!(LaserMode::parse("m5"), None); + } +}