mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
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:
1 parent
3e5b5ecb60
commit
34685c4cdc
5 files changed
+337
No files matched your search
Generated
+7
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user