Add craft-gcode: Grbl-style G-code writer for laser cutters and plotters (#801)

Polylines in millimetres to G0/G1 moves with laser power, feed and passes. Comments are kept free of Grbl real-time bytes.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 06:22:20 -05:00
1 parent 3e5b5ecb60
commit 34685c4cdc
5 files changed
+337

No files matched your search

Generated
+7
View File
@@ -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"
+1
View File
@@ -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" }
+2
View File
@@ -71,6 +71,8 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
- [`craft-fonts-build`](crates/fonts-build/README.md): embeds the optional craft-fonts faces
from an app's `build.rs` (`CRAFT_FONTS_DIR`, `CRAFT_FONTS_REQUIRED`, script filters) as a table
of `EmbeddedFace`s. No dependencies; the app picks the faces.
- [`craft-gcode`](crates/gcode/src/lib.rs): Grbl-style G-code writer: polylines in
millimetres to `G0`/`G1` moves with laser power and feed, for cutting lasers and plotters.
- [`craft-heif`](crates/heif/README.md): optional HEIF/HEIC still-image decoder, from LightCraft:
heic-rs reads the container and HEVC pictures, and this crate converts to 16-bit RGB to match
libheif (VUI range and matrix, chroma upsampling, `imir`, ICC on grid tiles). Also orientation,
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "craft-gcode"
description = "Grbl-style G-code writer for laser cutters and plotters: polylines in millimetres to G0/G1 moves"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
thiserror = { workspace = true }
+313
View File
@@ -0,0 +1,313 @@
//! A Grbl-style G-code writer for cutting lasers and plotters.
//!
//! Modality: `G21` millimetres, `G90` absolute, feed in mm/min. Each contour is one cut: rapid
//! (`G0`) to the start with the beam off, power on, `G1` along the path, power off. Laser
//! machines take `M4` (dynamic power) plus an `S` power value in the conventional 0–1000 range,
//! or `M3` for controllers that only model a spindle ([`LaserMode`]). A contour can be traced in
//! several passes, with an optional power ramp. Coordinates are absolute millimetres, exactly as
//! given.
//!
//! Flattening curves, choosing what to cut and at what power, and machine or material presets
//! are the caller's business. Depends on no other Craft crate.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
use std::fmt::Write as _;
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum Error {
/// A setting or power no machine should be sent. Names the knob and the value.
#[error("{0}")]
Settings(String),
#[error("a vertex is not finite")]
NotFinite,
}
/// Most repeated passes a job may ask for: past this the settings are a mistake, not a thick
/// material.
pub const MAX_PASSES: u32 = 10;
/// Highest power Grbl's `S` word conventionally accepts.
pub const MAX_POWER: u32 = 1000;
/// How the controller is told to fire.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LaserMode {
/// `M4`: power tracks motion (Grbl laser mode). Rapids go dark on their own, but the writer
/// still switches off explicitly so the output is safe on any sender.
#[default]
Dynamic,
/// `M3`: constant power while moving, for controllers that only understand a spindle on/off.
Constant,
}
impl LaserMode {
/// The G-code word that turns the tool on.
pub fn on_command(self) -> &'static str {
match self {
Self::Dynamic => "M4",
Self::Constant => "M3",
}
}
/// Parse the CLI/protocol spelling.
pub fn parse(value: &str) -> Option<Self> {
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<Writer, Error> {
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);
}
}