diff --git a/Cargo.toml b/Cargo.toml index 4b8579b..2825fd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "svg2gcode" version = "0.0.1" authors = ["Sameer Puri "] edition = "2018" -description = "Convert paths in SVG files to GCode for a pen plotter" +description = "Convert paths in SVG files to GCode for a pen plotter or laser engraver" [dependencies] svgdom = "0" diff --git a/README.md b/README.md index 05183f5..5581973 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Convert any SVG 1.1 path to gcode for a pen plotter, laser engraver, etc. ### Input ```bash -cargo run --release -- examples/Vanderbilt_Commodores_logo.svg +cargo run -- examples/Vanderbilt_Commodores_logo.svg --off 'M4' --on 'M5' -o out.gcode ``` ![Vanderbilt Commodores Logo](examples/Vanderbilt_Commodores_logo.svg) diff --git a/src/code/mod.rs b/src/code/mod.rs new file mode 100644 index 0000000..47737af --- /dev/null +++ b/src/code/mod.rs @@ -0,0 +1,104 @@ +use core::convert::TryFrom; +use std::io::{self, Write}; + +#[macro_use] +mod spec; +pub use spec::*; + +/// Collapses GCode words into higher-level commands +pub struct CommandVecIntoIterator { + vec: Vec, + index: usize, +} + +impl Iterator for CommandVecIntoIterator { + type Item = Command; + fn next(&mut self) -> Option { + if self.vec.len() <= self.index { + return None; + } + let mut i = self.index + 1; + while i < self.vec.len() { + if CommandWord::is_command(&self.vec[i]) { + break; + } + i += 1; + } + let command = Command::try_from(&self.vec[self.index..i]).ok(); + self.index = i; + command + } +} + +impl From> for CommandVecIntoIterator { + fn from(vec: Vec) -> Self { + Self { vec, index: 0 } + } +} + +pub fn parse_gcode(gcode: &str) -> Vec { + let mut vec = vec![]; + let mut in_string = false; + let mut letter: Option = None; + let mut value_range = 0..0; + gcode.char_indices().for_each(|(i, c)| { + if (c.is_alphabetic() || c.is_ascii_whitespace()) && !in_string { + if let Some(l) = letter { + vec.push(Word { + letter: l, + value: parse_value(&gcode[value_range.clone()]), + }); + letter = None; + } + if c.is_alphabetic() { + letter = Some(c); + } + value_range = (i + 1)..(i + 1); + } else if in_string { + value_range = value_range.start..(i + 1); + } else { + if c == '"' { + in_string = !in_string; + } + value_range = value_range.start..(i + 1); + } + }); + if let Some(l) = letter { + vec.push(Word { + letter: l, + value: parse_value(&gcode[value_range.clone()]), + }); + } + vec +} + +fn parse_value(word: &str) -> Value { + if word.starts_with('"') && word.ends_with('"') { + Value::String(Box::new(word.to_string())) + } else { + let index_of_dot = word.find('.'); + Value::Fractional( + word[..index_of_dot.unwrap_or(word.len())] + .parse::() + .unwrap(), + index_of_dot.map(|j| word[j + 1..].parse::().unwrap()), + ) + } +} + +/// Writes a GCode program or sequence to a Writer +/// Each command is placed on a separate line +pub fn program2gcode(program: Vec, mut w: W) -> io::Result<()> { + for command in program.into_iter() { + let words: Vec = command.into(); + let mut it = words.iter(); + if let Some(command_word) = it.next() { + write!(w, "{}{}", command_word.letter, command_word.value)?; + for word in it { + write!(w, " {}{} ", word.letter, word.value)?; + } + writeln!(w, "")?; + } + } + Ok(()) +} diff --git a/src/code.rs b/src/code/spec.rs similarity index 69% rename from src/code.rs rename to src/code/spec.rs index ef5ff93..413e2d7 100644 --- a/src/code.rs +++ b/src/code/spec.rs @@ -1,69 +1,48 @@ -use core::convert::TryFrom; -use std::io::{self, Write}; +use std::convert::TryFrom; -/// Collapses GCode words into higher-level commands -/// Relies on the first word being a command. -pub struct CommandVec { - pub inner: Vec, -} - -impl Default for CommandVec { - fn default() -> Self { - Self { - inner: vec![] - } - } -} - -impl IntoIterator for CommandVec { - type Item = Command; - type IntoIter = CommandVecIntoIterator; - fn into_iter(self) -> Self::IntoIter { - CommandVecIntoIterator { - vec: self, - index: 0, - } - } +/// Fundamental unit of GCode: a value preceded by a descriptive letter. +#[derive(Clone, PartialEq, Debug)] +pub struct Word { + pub letter: char, + pub value: Value, } -pub struct CommandVecIntoIterator { - vec: CommandVec, - index: usize, +/// All the possible variations of a word's value. +/// Fractional is needed to support commands like G91.1 which would be changed by float arithmetic. +/// Some flavors of GCode also allow for strings. +#[derive(Clone, PartialEq, Debug)] +pub enum Value { + Fractional(u32, Option), + Float(f64), + String(Box) } -impl Iterator for CommandVecIntoIterator { - type Item = Command; - fn next(&mut self) -> Option { - if self.vec.inner.len() == self.index { - return None; - } - - let mut i = self.index + 1; - while i < self.vec.inner.len() { - if CommandWord::is_command(&self.vec.inner[i]) { - break; +impl std::fmt::Display for Value { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Fractional(number, Some(fraction)) => { + write!(f, "{}.{}", number, fraction) + }, + Self::Fractional(number, None) => { + write!(f, "{}", number) + }, + Self::Float(float) => { + write!(f, "{}", float) + }, + Self::String(string) => { + write!(f, "\"{}\"", string) } - i += 1; } - Command::try_from(&self.vec.inner[self.index..i]).ok() } } -/// Fundamental unit of GCode: a value preceded by a descriptive letter. -/// A float is used here to encompass all the possible variations of a value. -/// Some flavors of GCode may allow strings, but that is currently not supported. -#[derive(Clone, PartialEq, Debug)] -pub struct Word { - pub letter: char, - pub value: f64, -} - +/// A macro for quickly instantiating a float-valued command #[macro_export] macro_rules! command { ($commandWord: expr, {$($argument: ident : $value: expr,)*}) => { paste::expr! (Command::new($commandWord, vec![$(Word { letter: stringify!([<$argument:upper>]).chars().next().unwrap(), - value: $value as f64, + value: Value::Float($value), },)*])) }; } @@ -124,6 +103,9 @@ macro_rules! commands { impl TryFrom<&[Word]> for Command { type Error = (); fn try_from(words: &[Word]) -> Result { + if words.len() == 0 { + return Err(()); + } let command_word = CommandWord::try_from(&words[0])?; let mut arguments = Vec::with_capacity(words.len() - 1); for i in 1..words.len() { @@ -162,13 +144,9 @@ macro_rules! commands { paste::item! { impl CommandWord { pub fn is_command(word: &Word) -> bool { - let number = word.value as u16; - let fraction_numeric = - f64::from_bits(word.value.fract().to_bits() & 0x00_00_FF_FF_FF_FF_FF_FF) as u16; - let fraction = if fraction_numeric == 0 { - None - } else { - Some(fraction_numeric) + let (number, fraction) = match &word.value { + Value::Fractional(number, fraction) => (number, fraction), + _other => return false }; match (word.letter, number, fraction) { $(($letter, $number, $fraction) => true,)* @@ -183,18 +161,14 @@ macro_rules! commands { impl TryFrom<&Word> for CommandWord { type Error = (); fn try_from(word: &Word) -> Result { - let number = word.value as u16; - let fraction_numeric = - f64::from_bits(word.value.fract().to_bits() & 0x00_00_FF_FF_FF_FF_FF_FF) as u16; - let fraction = if fraction_numeric == 0 { - None - } else { - Some(fraction_numeric) + let (number, fraction) = match &word.value { + Value::Fractional(number, fraction) => (number, fraction), + _other => return Err(()) }; match (word.letter, number, fraction) { $(($letter, $number, $fraction) => Ok(Self::$commandName),)* - ('*', checksum, None) => Ok(Self::Checksum(checksum as u8)), - ('N', line_number, None) => Ok(Self::LineNumber(line_number)), + ('*', checksum, None) => Ok(Self::Checksum(*checksum as u8)), + ('N', line_number, None) => Ok(Self::LineNumber(*line_number as u16)), (_, _, _) => Err(()) } } @@ -208,20 +182,20 @@ macro_rules! commands { Self::$commandName {} => Word { letter: $letter, // TODO: fix fraction - value: $number as f64 + ($fraction.unwrap_or(0) as f64) + value: Value::Fractional($number, $fraction) }, )* Self::Checksum(value) => Word { letter: '*', - value: value as f64 + value: Value::Fractional(value as u32, None) }, Self::LineNumber(value) => Word { letter: 'N', - value: value as f64 + value: Value::Fractional(value as u32, None) }, - Self::Comment(_string) => Word { + Self::Comment(string) => Word { letter: ';', - value: 0.0 + value: Value::String(string) } } } @@ -288,6 +262,9 @@ commands!( RelativeDistanceMode { 'G', 91, None, {} }, + FeedRateUnitsPerMinute { + 'G', 94, None, {} + }, /// Start spinning the spindle clockwise with speed `p` StartSpindleClockwise { 'M', 3, None, { @@ -311,33 +288,3 @@ commands!( 'M', 20, None, {} }, ); - -/// Rudimentary regular expression GCode validator -pub fn validate_gcode(gcode: &&str) -> bool { - use regex::Regex; - let re = Regex::new(r##"^(?:(?:%|\(.*\)|(?:[A-Z^E^U][+-]?\d+(?:\.\d*)?))\h*)*$"##).unwrap(); - gcode.lines().all(|line| re.is_match(line)) -} - -/// Writes a GCode program (or sequence) to a Writer -pub fn program2gcode(program: Vec, mut w: W) -> io::Result<()> { - for command in program.into_iter() { - match &command.command_word { - CommandWord::Comment(string) => { - writeln!(w, ";{}", string)?; - }, - _other => { - let words: Vec = command.into(); - let mut it = words.iter(); - if let Some(command_word) = it.next() { - write!(w, "{}{}", command_word.letter, command_word.value)?; - for word in it { - write!(w, " {}{} ", word.letter, word.value)?; - } - writeln!(w, "")?; - } - } - } - } - Ok(()) -} diff --git a/src/machine.rs b/src/machine.rs index c9b4ea9..db7942d 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -29,23 +29,32 @@ pub struct Machine { distance_mode: Option, tool_on_action: Vec, tool_off_action: Vec, + program_begin_sequence: Vec, + program_end_sequence: Vec, } impl Machine { /// Create a generic machine, given a tool on/off GCode sequence. - pub fn new(tool_on_action: CommandVec, tool_off_action: CommandVec) -> Self { + pub fn new( + tool_on_action: Vec, + tool_off_action: Vec, + program_begin_sequence: Vec, + program_end_sequence: Vec, + ) -> Self { Self { tool_state: None, distance_mode: None, - tool_on_action: tool_on_action.into_iter().collect(), - tool_off_action: tool_off_action.into_iter().collect() + tool_on_action: CommandVecIntoIterator::from(tool_on_action).collect(), + tool_off_action: CommandVecIntoIterator::from(tool_off_action).collect(), + program_begin_sequence: CommandVecIntoIterator::from(program_begin_sequence).collect(), + program_end_sequence: CommandVecIntoIterator::from(program_end_sequence).collect(), } } } impl Machine { /// Output gcode to turn the tool on. - pub fn tool_on(& mut self) -> Vec { + pub fn tool_on(&mut self) -> Vec { if self.tool_state == Some(Tool::Off) || self.tool_state == None { self.tool_state = Some(Tool::On); self.tool_on_action.clone() @@ -63,6 +72,10 @@ impl Machine { vec![] } } + + pub fn program_begin(&self) -> Vec { self.program_begin_sequence.clone() } + pub fn program_end(&self) -> Vec { self.program_end_sequence.clone() } + /// Output relative distance field if mode was absolute or unknown. pub fn absolute(&mut self) -> Vec { if self.distance_mode == Some(Distance::Relative) || self.distance_mode == None { @@ -83,4 +96,3 @@ impl Machine { } } } - diff --git a/src/main.rs b/src/main.rs index ae0a7f2..9231b38 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,3 @@ -/// TODO: documentation - #[macro_use] extern crate clap; extern crate env_logger; @@ -26,9 +24,8 @@ use code::*; use machine::*; use turtle::*; -// TODO: Documentation fn main() -> io::Result<()> { - if env::var("RUST_LOG").is_err() { + if let Err(_) = env::var("RUST_LOG") { env::set_var("RUST_LOG", "svg2gcode=info") } env_logger::init(); @@ -36,12 +33,15 @@ fn main() -> io::Result<()> { (version: crate_version!()) (author: crate_authors!()) (about: crate_description!()) - (@arg FILE: "Selects the input SVG file to use, else reading from stdin") - (@arg tolerance: --tolerance "Sets the interpolation tolerance for curves") - (@arg feedrate: --feedrate "Sets the machine feed rate") - (@arg dpi: --dpi "Sets the DPI for SVGs with units in pt, pc, etc. (default 72.0)") - (@arg tool_on_action: --tool_on_action "Sets the tool on GCode sequence") - (@arg tool_off_action: --tool_off_action "Sets the tool off GCode sequence") + (@arg FILE: "A file path for an SVG, else reads from stdin") + (@arg tolerance: --tolerance +takes_value "Curve interpolation tolerance (default: 0.002mm)") + (@arg feedrate: --feedrate +takes_value "Machine feed rate in mm/min (default: 300mm/min)") + (@arg dpi: --dpi +takes_value "Dots per inch (DPI) for pixels, points, picas, etc. (default: 96dpi)") + (@arg tool_on_sequence: --on +takes_value +required "Tool on GCode sequence") + (@arg tool_off_sequence: --off +takes_value +required "Tool off GCode sequence") + (@arg begin_sequence: --begin +takes_value "Optional GCode begin sequence (i.e. change to a tool)") + (@arg end_sequence: --end +takes_value "Optional GCode end sequence, prior to program end (i.e. change to a tool)") + (@arg out: --out -o +takes_value "Output file path (overwrites old files), else writes to stdout") ) .get_matches(); @@ -54,68 +54,69 @@ fn main() -> io::Result<()> { input } None => { + info!("Reading from stdin"); let mut input = String::new(); io::stdin().read_to_string(&mut input)?; input } }; - let mut opts = ProgramOptions::default(); - let mut mach = Machine::new(CommandVec::default(), CommandVec::default()); - - if let Some(tolerance) = matches.value_of("tolerance").and_then(|x| x.parse().ok()) { - opts.tolerance = tolerance; - } - if let Some(feedrate) = matches.value_of("feedrate").and_then(|x| x.parse().ok()) { - opts.feedrate = feedrate; - } - if let Some(dpi) = matches.value_of("dpi").and_then(|x| x.parse().ok()) { - opts.dpi = dpi; - } + let opts = ProgramOptions { + tolerance: matches + .value_of("tolerance") + .map(|x| x.parse().expect("could not parse tolerance")) + .unwrap_or(0.002), + feedrate: matches + .value_of("feedrate") + .map(|x| x.parse().expect("could not parse feedrate")) + .unwrap_or(300.0), + dpi: matches + .value_of("dpi") + .map(|x| x.parse().expect("could not parse DPI")) + .unwrap_or(96.0), + }; - // if let Some(tool_on_action) = matches.value_of("tool_on_action").filter(validate_gcode) { - // mach.tool_on_action = vec![GCode::Raw(Box::new(tool_on_action.to_string()))]; - // } - // if let Some(tool_off_action) = matches.value_of("tool_off_action").filter(validate_gcode) { - // mach.tool_off_action = vec![GCode::Raw(Box::new(tool_off_action.to_string()))]; - // } + let mach = Machine::new( + matches.value_of("tool_on_sequence").map(parse_gcode).unwrap_or_default(), + matches.value_of("tool_off_sequence").map(parse_gcode).unwrap_or_default(), + matches + .value_of("begin_sequence") + .map(parse_gcode) + .unwrap_or_default(), + matches.value_of("end_sequence").map(parse_gcode).unwrap_or_default(), + ); let doc = svgdom::Document::from_str(&input).expect("Invalid or unsupported SVG file"); let prog = svg2program(&doc, opts, mach); - program2gcode(prog, File::create("out.gcode")?) + if let Some(out_path) = matches.value_of("out") { + program2gcode(prog, File::create(out_path)?) + } else { + program2gcode(prog, std::io::stdout()) + } } -// TODO: Documentation +/// High-level output options struct ProgramOptions { + /// Curve interpolation tolerance in millimeters tolerance: f64, + /// Feedrate in millimeters / minute feedrate: f64, + /// Dots per inch for pixels, picas, points, etc. dpi: f64, } -// Sets the baseline options for the machine. -impl Default for ProgramOptions { - fn default() -> Self { - ProgramOptions { - tolerance: 0.002, // See https://github.com/gnea/grbl/wiki/Grbl-v1.1-Configuration#12--arc-tolerance-mm - feedrate: 3000.0, - dpi: 72.0, - } - } -} - -// TODO: Documentation -// TODO: This function is much too large fn svg2program(doc: &svgdom::Document, opts: ProgramOptions, mach: Machine) -> Vec { - let mut p = vec![]; let mut t = Turtle::new(mach); + let mut p = vec![ + command!(CommandWord::UnitsMillimeters, {}), + command!(CommandWord::FeedRateUnitsPerMinute, {}), + ]; let mut namestack: Vec = vec![]; - - p.push(command!(CommandWord::UnitsMillimeters, {})); - p.append(&mut t.mach.tool_off()); - p.append(&mut - t.move_to(true, 0.0, 0.0)); + p.append(&mut t.mach.program_begin()); + p.append(&mut t.mach.absolute()); + p.append(&mut t.move_to(true, 0.0, 0.0)); for edge in doc.root().traverse() { let (node, is_start) = match edge { @@ -193,8 +194,8 @@ fn svg2program(doc: &svgdom::Document, opts: ProgramOptions, mach: Machine) -> V for segment in path.iter() { p.append(&mut match segment { PathSegment::MoveTo { abs, x, y } => t.move_to(*abs, *x, *y), - PathSegment::ClosePath { abs } => { - // Ignore abs, should have identical effect: https://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand + PathSegment::ClosePath { abs: _ } => { + // Ignore abs, should have identical effect: [9.3.4. The "closepath" command]("https://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand) t.close(None, opts.feedrate) } PathSegment::LineTo { abs, x, y } => { @@ -291,16 +292,17 @@ fn svg2program(doc: &svgdom::Document, opts: ProgramOptions, mach: Machine) -> V p.append(&mut t.mach.tool_off()); p.append(&mut t.mach.absolute()); - p.push(command!(CommandWord::RapidPositioning, { - x: 0.0, - y: 0.0, - })); + p.append(&mut t.move_to(true, 0.0, 0.0)); + p.append(&mut t.mach.program_end()); p.push(command!(CommandWord::ProgramEnd, {})); p } -// TODO: Documentation +/// Convenience function for converting absolute lengths to millimeters +/// Absolute lengths are listed in [CSS 4 §6.2](https://www.w3.org/TR/css-values/#absolute-lengths) +/// Relative lengths in [CSS 4 §6.1](https://www.w3.org/TR/css-values/#relative-lengths) are not supported and will cause a panic. +/// A default DPI of 96 is used as per [CSS 4 §7.4](https://www.w3.org/TR/css-values/#resolution), which you can adjust with --dpi fn length_to_mm(l: svgdom::Length, dpi: f64) -> f64 { use svgdom::LengthUnit::*; use uom::si::f64::Length; @@ -310,9 +312,16 @@ fn length_to_mm(l: svgdom::Length, dpi: f64) -> f64 { Cm => Length::new::(l.num), Mm => Length::new::(l.num), In => Length::new::(l.num), - Pt => Length::new::(l.num) * dpi / 72.0, // See https://github.com/iliekturtles/uom/blob/5cad47d4e67c902304c4c2b7feeb9c3d34fdffba/src/si/length.rs#L61 - Pc => Length::new::(l.num) * dpi / 72.0, // See https://github.com/iliekturtles/uom/blob/5cad47d4e67c902304c4c2b7feeb9c3d34fdffba/src/si/length.rs#L58 - _ => Length::new::(l.num), + Pc => Length::new::(l.num) * dpi / 96.0, + Pt => Length::new::(l.num) * dpi / 96.0, + Px => Length::new::(l.num * dpi / 96.0), + other => { + warn!( + "Converting from '{:?}' to millimeters is not supported, treating as millimeters", + other + ); + Length::new::(l.num) + } }; length.get::() diff --git a/src/turtle.rs b/src/turtle.rs index ef851bd..d693bbb 100644 --- a/src/turtle.rs +++ b/src/turtle.rs @@ -77,13 +77,13 @@ impl Turtle { if let Some(z) = z { linear_interpolation.push(Word { letter: 'Z', - value: z, + value: Value::Float(z), }); } if let Some(f) = f { linear_interpolation.push(Word { letter: 'F', - value: f, + value: Value::Float(f), }); } linear_interpolation