├── .github ├── FUNDING.yml └── workflows │ ├── cli.yml │ ├── lib.yml │ ├── release.yml │ ├── web-deploy.yml │ └── web.yml ├── .gitignore ├── .gitmodules ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── cli ├── Cargo.toml └── src │ └── main.rs ├── codecov.yml ├── examples ├── Vanderbilt_Commodores_logo.svg ├── Vanderbilt_Commodores_logo_gcode.png ├── arrowhead.svg ├── crystal.svg ├── dragon.svg ├── gosper.svg ├── hilbert.svg ├── koch.svg ├── kolam.svg ├── moore.svg ├── plant.svg ├── sierpinski.svg ├── sierpinski_carpet.svg └── snowflake.svg ├── lib ├── Cargo.toml ├── src │ ├── arc.rs │ ├── converter │ │ ├── length_serde.rs │ │ ├── mod.rs │ │ ├── path.rs │ │ ├── transform.rs │ │ ├── units.rs │ │ └── visit.rs │ ├── lib.rs │ ├── machine.rs │ ├── postprocess.rs │ └── turtle │ │ ├── dpi.rs │ │ ├── g_code.rs │ │ ├── mod.rs │ │ └── preprocess.rs └── tests │ ├── circular_interpolation.gcode │ ├── circular_interpolation.svg │ ├── shapes.gcode │ ├── shapes.svg │ ├── smooth_curves.gcode │ ├── smooth_curves.svg │ ├── smooth_curves_circular_interpolation.gcode │ ├── smooth_curves_circular_interpolation_release.gcode │ ├── square.gcode │ ├── square.svg │ ├── square_dimensionless.svg │ ├── square_transformed.gcode │ ├── square_transformed.svg │ ├── square_transformed_nested.gcode │ ├── square_transformed_nested.svg │ ├── square_viewport.gcode │ └── square_viewport.svg └── web ├── Cargo.toml ├── index.html ├── src ├── forms │ ├── editors.rs │ ├── inputs.rs │ └── mod.rs ├── main.rs ├── state.rs ├── ui │ └── mod.rs └── util.rs └── style └── main.scss /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ['sameer'] 2 | -------------------------------------------------------------------------------- /.github/workflows/cli.yml: -------------------------------------------------------------------------------- 1 | name: Build svg2gcode-cli 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: Swatinem/rust-cache@v2 14 | - uses: dtolnay/rust-toolchain@stable 15 | - name: Build 16 | run: cargo build -p svg2gcode-cli 17 | -------------------------------------------------------------------------------- /.github/workflows/lib.yml: -------------------------------------------------------------------------------- 1 | name: Build, test, and publish coverage for svg2gcode 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | CARGO_TERM_COLOR: always 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: Swatinem/rust-cache@v2 14 | - name: Build 15 | run: cargo build -p svg2gcode 16 | coverage: 17 | runs-on: ubuntu-latest 18 | if: github.ref == 'refs/heads/main' 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | - uses: Swatinem/rust-cache@v2 24 | with: 25 | cache-all-crates: true 26 | - uses: dtolnay/rust-toolchain@stable 27 | with: 28 | toolchain: nightly 29 | components: llvm-tools-preview 30 | - run: cargo install grcov 31 | - run: cargo build -p svg2gcode 32 | env: 33 | RUSTFLAGS: '-Cinstrument-coverage' 34 | RUSTDOCFLAGS: '-Cinstrument-coverage' 35 | LLVM_PROFILE_FILE: 'codecov-instrumentation-%p-%m.profraw' 36 | - run: RUSTFLAGS='-Cinstrument-coverage' cargo test --all-features --no-fail-fast -p svg2gcode 37 | env: 38 | RUSTFLAGS: '-Cinstrument-coverage' 39 | RUSTDOCFLAGS: '-Cinstrument-coverage' 40 | LLVM_PROFILE_FILE: 'codecov-instrumentation-%p-%m.profraw' 41 | - run: grcov . -s . --binary-path ./target/debug/ -t lcov --branch -o lcov.info 42 | - uses: codecov/codecov-action@v4 43 | with: 44 | token: ${{ secrets.CODECOV_TOKEN }} 45 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release CLI 2 | on: 3 | release: 4 | types: [created] 5 | 6 | jobs: 7 | release: 8 | name: release ${{ matrix.targets.name }} 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | targets: 14 | [ 15 | { name: Windows, triple: x86_64-pc-windows-gnu, version: stable }, 16 | { name: Linux, triple: x86_64-unknown-linux-musl, version: stable }, 17 | # Fix for https://github.com/rust-build/rust-build.action/issues/88 18 | { name: macOS, triple: x86_64-apple-darwin, version: '1.73.0' } 19 | ] 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Compile and release 23 | uses: rust-build/rust-build.action@v1.4.5 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | RUSTTARGET: ${{ matrix.targets.triple }} 27 | TOOLCHAIN_VERSION: ${{ matrix.targets.version }} 28 | EXTRA_FILES: "README.md LICENSE" 29 | SRC_DIR: "cli/" 30 | -------------------------------------------------------------------------------- /.github/workflows/web-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy svg2gcode-web 2 | on: 3 | workflow_run: 4 | branches: [main] 5 | workflows: [Check svg2gcode-web] 6 | types: [completed] 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | submodules: 'true' 19 | - uses: dtolnay/rust-toolchain@stable 20 | with: 21 | targets: wasm32-unknown-unknown 22 | - uses: Swatinem/rust-cache@v2 23 | - uses: jetli/trunk-action@v0.4.0 24 | with: 25 | version: v0.18.8 26 | - name: Trunk build 27 | run: | 28 | cd web 29 | trunk build --release --public-url svg2gcode 30 | 31 | - name: Publish to GitHub Pages 32 | uses: peaceiris/actions-gh-pages@v3 33 | if: github.ref == 'refs/heads/main' 34 | with: 35 | github_token: ${{ secrets.GITHUB_TOKEN }} 36 | publish_dir: ${{ github.workspace }}/web/dist 37 | -------------------------------------------------------------------------------- /.github/workflows/web.yml: -------------------------------------------------------------------------------- 1 | name: Check svg2gcode-web 2 | on: [push, pull_request] 3 | env: 4 | CARGO_TERM_COLOR: always 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: Swatinem/rust-cache@v2 12 | - uses: dtolnay/rust-toolchain@stable 13 | - name: Build 14 | run: cargo check -p svg2gcode-web 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | ./*.gcode 4 | pkg/ 5 | dist 6 | lib/approx_circle.svg 7 | **/out 8 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "web/style/spectre"] 2 | path = web/style/spectre 3 | url = https://github.com/picturepan2/spectre.git 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["lib", "cli", "web"] 3 | default-members = ["cli"] 4 | resolver = "2" 5 | 6 | [workspace.package] 7 | authors = ["Sameer Puri "] 8 | edition = "2021" 9 | repository = "https://github.com/sameer/svg2gcode" 10 | license = "MIT" 11 | 12 | [workspace.dependencies] 13 | g-code = "0.5.1" 14 | log = "0" 15 | roxmltree = "0.19" 16 | serde_json = "1" 17 | svgtypes = "0.13" 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2021 Sameer Puri 4 | Copyright (C) 2013-2015 by Vitaly Puzrin 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # svg2gcode 2 | 3 | [![Build, test, and publish coverage for svg2gcode](https://github.com/sameer/svg2gcode/actions/workflows/lib.yml/badge.svg)](https://github.com/sameer/svg2gcode/actions/workflows/lib.yml) 4 | 5 | [![Build svg2gcode-cli](https://github.com/sameer/svg2gcode/actions/workflows/cli.yml/badge.svg)](https://github.com/sameer/svg2gcode/actions/workflows/cli.yml) 6 | 7 | [![Build svg2gcode-web](https://github.com/sameer/svg2gcode/actions/workflows/web.yml/badge.svg)](https://github.com/sameer/svg2gcode/actions/workflows/web.yml) 8 | [![Deploy svg2gcode-web](https://github.com/sameer/svg2gcode/actions/workflows/web-deploy.yml/badge.svg)](https://github.com/sameer/svg2gcode/actions/workflows/web-deploy.yml) 9 | 10 | [![codecov](https://codecov.io/gh/sameer/svg2gcode/branch/master/graph/badge.svg)](https://codecov.io/gh/sameer/svg2gcode) 11 | 12 | Convert vector graphics to g-code for pen plotters, laser engravers, and other CNC machines 13 | 14 | ## Usage 15 | 16 | ### Web interface 17 | 18 | Check it out at https://sameer.github.io/svg2gcode. Just select an SVG and click generate! 19 | 20 | ![SVG selected on web interface](https://user-images.githubusercontent.com/11097096/129305765-f78da85d-cf4f-4286-a97c-7124a716b5fa.png) 21 | 22 | ### Command line interface (CLI) 23 | 24 | #### Input 25 | 26 | ```sh 27 | cargo run --release -- examples/Vanderbilt_Commodores_logo.svg --off 'M4' --on 'M5' -o out.gcode 28 | ``` 29 | 30 | To convert curves to G02/G03 Gcode commands, use flag `--circular-interpolation true`. 31 | 32 | ![Vanderbilt Commodores Logo](examples/Vanderbilt_Commodores_logo.svg) 33 | 34 | #### Output, rendered at [https://ncviewer.com]() 35 | 36 | ```sh 37 | cat out.gcode 38 | ``` 39 | 40 | ![Vanderbilt Commodores Logo Gcode](examples/Vanderbilt_Commodores_logo_gcode.png) 41 | 42 | ### Library 43 | 44 | The core functionality of this tool is available as the [svg2gcode crate](https://crates.io/crates/svg2gcode). 45 | 46 | ## Blog Posts 47 | 48 | These go into greater detail on the tool's origins, implementation details, and planned features. 49 | 50 | - https://purisa.me/blog/pen-plotter/ 51 | - https://purisa.me/blog/svg2gcode-progress/ 52 | 53 | ## FAQ / Interesting details 54 | 55 | - Use a 3D printer for plotting: (thanks [@jeevank](https://github.com/jeevank) for sharing this) https://medium.com/@urish/how-to-turn-your-3d-printer-into-a-plotter-in-one-hour-d6fe14559f1a 56 | 57 | - Convert a PDF to GCode: follow [this guide using Inkscape to convert a PDF to an SVG](https://en.wikipedia.org/wiki/Wikipedia:Graphics_Lab/Resources/PDF_conversion_to_SVG#Conversion_with_Inkscape), then use it with svg2gcode 58 | 59 | - Are shapes, fill patterns supported? No, but you can convert them to paths in Inkscape with `Object to Path`. See [#15](https://github.com/sameer/svg2gcode/issues/15) for more discussion. 60 | - Are stroke patterns supported? No, but you can convert them into paths in Inkscape with `Stroke to Path`. 61 | 62 | ## Reference Documents 63 | 64 | - [W3 SVG2 Specification](https://www.w3.org/TR/SVG/Overview.html) 65 | - [CSS absolute lengths](https://www.w3.org/TR/css-values/#absolute-lengths) 66 | - [CSS font-relative lengths](https://www.w3.org/TR/css-values/#font-relative-lengths) 67 | - [CSS compatible units](https://www.w3.org/TR/css-values/#compat) 68 | - [RepRap G-code](https://reprap.org/wiki/G-code) 69 | - [G-Code and M-Code Reference List for Milling](https://www.cnccookbook.com/g-code-m-code-reference-list-cnc-mills/) 70 | -------------------------------------------------------------------------------- /cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "svg2gcode-cli" 3 | version = "0.0.17" 4 | description = "Command line interface for svg2gcode" 5 | authors.workspace = true 6 | edition.workspace = true 7 | repository.workspace = true 8 | license.workspace = true 9 | 10 | [dependencies] 11 | svg2gcode = { path = "../lib", version = "0.3.2", features = ["serde"] } 12 | env_logger = "0.11" 13 | log.workspace = true 14 | g-code.workspace = true 15 | # Latest version of clap supporting Rust 1.73, needed for the macOS release in CI 16 | clap = { version = "^4.0,<=4.4.18", features = ["derive"] } 17 | codespan-reporting = "0.11" 18 | roxmltree.workspace = true 19 | serde_json.workspace = true 20 | svgtypes.workspace = true 21 | 22 | [[bin]] 23 | name = "svg2gcode" 24 | path = "src/main.rs" 25 | -------------------------------------------------------------------------------- /cli/src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use g_code::{ 3 | emit::{format_gcode_io, FormatOptions}, 4 | parse::snippet_parser, 5 | }; 6 | use log::{error, info}; 7 | use roxmltree::ParsingOptions; 8 | use std::{ 9 | env, 10 | fs::File, 11 | io::{self, Read, Write}, 12 | path::PathBuf, 13 | }; 14 | use svgtypes::LengthListParser; 15 | 16 | use svg2gcode::{ 17 | svg2program, ConversionOptions, Machine, Settings, SupportedFunctionality, Version, 18 | }; 19 | 20 | #[derive(Debug, Parser)] 21 | #[command(name = "svg2gcode", version, author, about)] 22 | struct Opt { 23 | /// Curve interpolation tolerance (mm) 24 | #[arg(long)] 25 | tolerance: Option, 26 | /// Machine feed rate (mm/min) 27 | #[arg(long)] 28 | feedrate: Option, 29 | /// Dots per Inch (DPI) 30 | /// Used for scaling visual units (pixels, points, picas, etc.) 31 | #[arg(long)] 32 | dpi: Option, 33 | #[arg(alias = "tool_on_sequence", long = "on")] 34 | /// G-Code for turning on the tool 35 | tool_on_sequence: Option, 36 | #[arg(alias = "tool_off_sequence", long = "off")] 37 | /// G-Code for turning off the tool 38 | tool_off_sequence: Option, 39 | /// G-Code for initializing the machine at the beginning of the program 40 | #[arg(alias = "begin_sequence", long = "begin")] 41 | begin_sequence: Option, 42 | /// G-Code for stopping/idling the machine at the end of the program 43 | #[arg(alias = "end_sequence", long = "end")] 44 | end_sequence: Option, 45 | /// A file path to an SVG, else reads from stdin 46 | file: Option, 47 | /// Output file path (overwrites old files), else writes to stdout 48 | #[arg(short, long)] 49 | out: Option, 50 | /// Provide settings from a JSON file. Overrides command-line arguments. 51 | #[arg(long)] 52 | settings: Option, 53 | /// Export current settings to a JSON file instead of converting. 54 | /// 55 | /// Use `-` to export to standard out. 56 | #[arg(long)] 57 | export: Option, 58 | /// Coordinates for the bottom left corner of the machine 59 | #[arg(long, allow_hyphen_values = true)] 60 | origin: Option, 61 | /// Override the width and height of the SVG (i.e. 210mm,297mm) 62 | /// 63 | /// Useful when the SVG does not specify these (see https://github.com/sameer/svg2gcode/pull/16) 64 | /// 65 | /// Passing "210mm," or ",297mm" calculates the missing dimension to conform to the viewBox aspect ratio. 66 | #[arg(long)] 67 | dimensions: Option, 68 | /// Whether to use circular arcs when generating g-code 69 | /// 70 | /// Please check if your machine supports G2/G3 commands before enabling this. 71 | #[arg(long)] 72 | circular_interpolation: Option, 73 | 74 | #[arg(long)] 75 | /// Include line numbers at the beginning of each line 76 | /// 77 | /// Useful for debugging/streaming g-code 78 | line_numbers: Option, 79 | #[arg(long)] 80 | /// Include checksums at the end of each line 81 | /// 82 | /// Useful for streaming g-code 83 | checksums: Option, 84 | #[arg(long)] 85 | /// Add a newline character before each comment 86 | /// 87 | /// Workaround for parsers that don't accept comments on the same line 88 | newline_before_comment: Option, 89 | } 90 | 91 | fn main() -> io::Result<()> { 92 | if env::var("RUST_LOG").is_err() { 93 | env::set_var("RUST_LOG", "svg2gcode=info") 94 | } 95 | env_logger::init(); 96 | 97 | let opt = Opt::parse(); 98 | 99 | let settings = { 100 | let mut settings = if let Some(path) = opt.settings { 101 | serde_json::from_reader(File::open(path)?)? 102 | } else { 103 | Settings::default() 104 | }; 105 | 106 | { 107 | let conversion = &mut settings.conversion; 108 | conversion.dpi = opt.dpi.unwrap_or(conversion.dpi); 109 | conversion.feedrate = opt.feedrate.unwrap_or(conversion.feedrate); 110 | conversion.tolerance = opt.tolerance.unwrap_or(conversion.tolerance); 111 | } 112 | { 113 | let machine = &mut settings.machine; 114 | machine.supported_functionality = SupportedFunctionality { 115 | circular_interpolation: opt 116 | .circular_interpolation 117 | .unwrap_or(machine.supported_functionality.circular_interpolation), 118 | }; 119 | if let seq @ Some(_) = opt.tool_on_sequence { 120 | machine.tool_on_sequence = seq; 121 | } 122 | if let seq @ Some(_) = opt.tool_off_sequence { 123 | machine.tool_off_sequence = seq; 124 | } 125 | if let seq @ Some(_) = opt.begin_sequence { 126 | machine.begin_sequence = seq; 127 | } 128 | if let seq @ Some(_) = opt.end_sequence { 129 | machine.end_sequence = seq; 130 | } 131 | } 132 | { 133 | if let Some(origin) = opt.origin { 134 | for (i, dimension_origin) in origin 135 | .split(',') 136 | .map(|point| { 137 | if point.is_empty() { 138 | Default::default() 139 | } else { 140 | point.parse::().expect("could not parse coordinate") 141 | } 142 | }) 143 | .take(2) 144 | .enumerate() 145 | { 146 | settings.conversion.origin[i] = Some(dimension_origin); 147 | } 148 | } 149 | } 150 | 151 | if let Some(line_numbers) = opt.line_numbers { 152 | settings.postprocess.line_numbers = line_numbers; 153 | } 154 | 155 | if let Some(checksums) = opt.checksums { 156 | settings.postprocess.checksums = checksums; 157 | } 158 | 159 | if let Some(newline_before_comment) = opt.newline_before_comment { 160 | settings.postprocess.newline_before_comment = newline_before_comment; 161 | } 162 | 163 | if let Version::Unknown(ref unknown) = settings.version { 164 | error!( 165 | "Your settings use an unknown version. Your version: {unknown}, latest: {}. See {} to download the latest CLI version.", 166 | Version::latest(), 167 | env!("CARGO_PKG_REPOSITORY"), 168 | ); 169 | std::process::exit(1); 170 | } 171 | 172 | let old_version = settings.version.clone(); 173 | if let Err(msg) = settings.try_upgrade() { 174 | error!( 175 | "Your settings are out of date and require manual intervention: {msg}. Your version: {old_version}, latest: {}. See {} for instructions.", 176 | Version::latest(), 177 | env!("CARGO_PKG_REPOSITORY"), 178 | ); 179 | std::process::exit(1); 180 | } 181 | 182 | settings 183 | }; 184 | 185 | if let Some(export_path) = opt.export { 186 | let config_json_bytes = serde_json::to_vec_pretty(&settings)?; 187 | if export_path.to_string_lossy() == "-" { 188 | return io::stdout().write_all(&config_json_bytes); 189 | } else { 190 | return File::create(export_path)?.write_all(&config_json_bytes); 191 | } 192 | } 193 | 194 | let options = { 195 | let mut dimensions = [None, None]; 196 | 197 | if let Some(dimensions_str) = opt.dimensions { 198 | dimensions_str 199 | .split(',') 200 | .map(|dimension_str| { 201 | if dimension_str.is_empty() { 202 | None 203 | } else { 204 | LengthListParser::from(dimension_str) 205 | .next() 206 | .transpose() 207 | .expect("could not parse dimension") 208 | } 209 | }) 210 | .take(2) 211 | .enumerate() 212 | .for_each(|(i, dimension_origin)| { 213 | dimensions[i] = dimension_origin; 214 | }); 215 | } 216 | ConversionOptions { dimensions } 217 | }; 218 | 219 | let input = match opt.file { 220 | Some(filename) => { 221 | let mut f = File::open(filename)?; 222 | let len = f.metadata()?.len(); 223 | let mut input = String::with_capacity(len as usize + 1); 224 | f.read_to_string(&mut input)?; 225 | input 226 | } 227 | None => { 228 | info!("Reading from standard input"); 229 | let mut input = String::new(); 230 | io::stdin().read_to_string(&mut input)?; 231 | input 232 | } 233 | }; 234 | 235 | let snippets = [ 236 | settings 237 | .machine 238 | .tool_on_sequence 239 | .as_deref() 240 | .map(snippet_parser) 241 | .transpose(), 242 | settings 243 | .machine 244 | .tool_off_sequence 245 | .as_deref() 246 | .map(snippet_parser) 247 | .transpose(), 248 | settings 249 | .machine 250 | .begin_sequence 251 | .as_deref() 252 | .map(snippet_parser) 253 | .transpose(), 254 | settings 255 | .machine 256 | .end_sequence 257 | .as_deref() 258 | .map(snippet_parser) 259 | .transpose(), 260 | ]; 261 | 262 | let machine = if let [Ok(tool_on_action), Ok(tool_off_action), Ok(program_begin_sequence), Ok(program_end_sequence)] = 263 | snippets 264 | { 265 | Machine::new( 266 | settings.machine.supported_functionality, 267 | tool_on_action, 268 | tool_off_action, 269 | program_begin_sequence, 270 | program_end_sequence, 271 | ) 272 | } else { 273 | use codespan_reporting::term::{ 274 | emit, 275 | termcolor::{ColorChoice, StandardStream}, 276 | }; 277 | let mut writer = StandardStream::stderr(ColorChoice::Auto); 278 | let config = codespan_reporting::term::Config::default(); 279 | 280 | for (i, (filename, gcode)) in [ 281 | ("tool_on_sequence", &settings.machine.tool_on_sequence), 282 | ("tool_off_sequence", &settings.machine.tool_off_sequence), 283 | ("begin_sequence", &settings.machine.begin_sequence), 284 | ("end_sequence", &settings.machine.end_sequence), 285 | ] 286 | .iter() 287 | .enumerate() 288 | { 289 | if let Err(err) = &snippets[i] { 290 | emit( 291 | &mut writer, 292 | &config, 293 | &codespan_reporting::files::SimpleFile::new(filename, gcode.as_ref().unwrap()), 294 | &g_code::parse::into_diagnostic(err), 295 | ) 296 | .unwrap(); 297 | } 298 | } 299 | std::process::exit(1) 300 | }; 301 | 302 | let document = roxmltree::Document::parse_with_options( 303 | &input, 304 | ParsingOptions { 305 | allow_dtd: true, 306 | ..Default::default() 307 | }, 308 | ) 309 | .unwrap(); 310 | 311 | let program = svg2program(&document, &settings.conversion, options, machine); 312 | 313 | if let Some(out_path) = opt.out { 314 | format_gcode_io( 315 | &program, 316 | FormatOptions { 317 | line_numbers: settings.postprocess.line_numbers, 318 | checksums: settings.postprocess.checksums, 319 | ..Default::default() 320 | }, 321 | File::create(out_path)?, 322 | ) 323 | } else { 324 | format_gcode_io( 325 | &program, 326 | FormatOptions { 327 | line_numbers: settings.postprocess.line_numbers, 328 | checksums: settings.postprocess.checksums, 329 | newline_before_comment: settings.postprocess.newline_before_comment, 330 | ..Default::default() 331 | }, 332 | std::io::stdout(), 333 | ) 334 | } 335 | } 336 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | precision: 2 3 | round: down 4 | range: "57...100" 5 | -------------------------------------------------------------------------------- /examples/Vanderbilt_Commodores_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /examples/Vanderbilt_Commodores_logo_gcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sameer/svg2gcode/29b64cf077ef34127d49944ee1e578a0c748eb43/examples/Vanderbilt_Commodores_logo_gcode.png -------------------------------------------------------------------------------- /examples/koch.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /examples/kolam.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /examples/snowflake.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /lib/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "svg2gcode" 3 | version = "0.3.3" 4 | description = "Convert paths in SVG files to GCode for a pen plotter, laser engraver, or other machine." 5 | authors.workspace = true 6 | edition.workspace = true 7 | repository.workspace = true 8 | license.workspace = true 9 | 10 | [features] 11 | serde = ["dep:serde", "dep:serde_repr", "g-code/serde"] 12 | 13 | [dependencies] 14 | g-code.workspace = true 15 | lyon_geom = "1.0.5" 16 | euclid = "0.22" 17 | log.workspace = true 18 | uom = "0.36" 19 | roxmltree.workspace = true 20 | svgtypes.workspace = true 21 | paste = "1.0" 22 | 23 | [dependencies.serde] 24 | default-features = false 25 | optional = true 26 | version = "1" 27 | features = ["derive"] 28 | 29 | [dependencies.serde_repr] 30 | optional = true 31 | version = "0.1" 32 | 33 | [dev-dependencies] 34 | cairo-rs = { version = "0.18", default-features = false, features = [ 35 | "svg", 36 | "v1_16", 37 | ] } 38 | serde_json.workspace = true 39 | pretty_assertions = "1.4.0" 40 | -------------------------------------------------------------------------------- /lib/src/arc.rs: -------------------------------------------------------------------------------- 1 | use euclid::Angle; 2 | use lyon_geom::{ 3 | ArcFlags, CubicBezierSegment, Line, LineSegment, Point, Scalar, SvgArc, Transform, Vector, 4 | }; 5 | 6 | pub enum ArcOrLineSegment { 7 | Arc(SvgArc), 8 | Line(LineSegment), 9 | } 10 | 11 | fn arc_from_endpoints_and_tangents( 12 | from: Point, 13 | from_tangent: Vector, 14 | to: Point, 15 | to_tangent: Vector, 16 | ) -> Option> { 17 | let from_to = (from - to).length(); 18 | let incenter = { 19 | let from_tangent = Line { 20 | point: from, 21 | vector: from_tangent, 22 | }; 23 | let to_tangent = Line { 24 | point: to, 25 | vector: to_tangent, 26 | }; 27 | 28 | let intersection = from_tangent.intersection(&to_tangent)?; 29 | let from_intersection = (from - intersection).length(); 30 | let to_intersection = (to - intersection).length(); 31 | 32 | (((from * to_intersection).to_vector() 33 | + (to * from_intersection).to_vector() 34 | + (intersection * from_to).to_vector()) 35 | / (from_intersection + to_intersection + from_to)) 36 | .to_point() 37 | }; 38 | 39 | let get_perpendicular_bisector = |a, b| { 40 | let vector: Vector = a - b; 41 | let perpendicular_vector = Vector::from([-vector.y, vector.x]).normalize(); 42 | Line { 43 | point: LineSegment { from: a, to: b }.sample(S::HALF), 44 | vector: perpendicular_vector, 45 | } 46 | }; 47 | 48 | let from_incenter_bisector = get_perpendicular_bisector(from, incenter); 49 | let to_incenter_bisector = get_perpendicular_bisector(to, incenter); 50 | let center = from_incenter_bisector.intersection(&to_incenter_bisector)?; 51 | 52 | let radius = (from - center).length(); 53 | 54 | // Use the 2D determinant + dot product to identify winding direction 55 | // See https://www.w3.org/TR/SVG/paths.html#PathDataEllipticalArcCommands for 56 | // a nice visual explanation of large arc and sweep 57 | let flags = { 58 | let from_center = (from - center).normalize(); 59 | let to_center = (to - center).normalize(); 60 | 61 | let det = from_center.x * to_center.y - from_center.y * to_center.x; 62 | let dot = from_center.dot(to_center); 63 | let atan2 = det.atan2(dot); 64 | ArcFlags { 65 | large_arc: atan2.abs() >= S::PI(), 66 | sweep: atan2.is_sign_positive(), 67 | } 68 | }; 69 | 70 | Some(SvgArc { 71 | from, 72 | to, 73 | radii: Vector::splat(radius), 74 | // This is a circular arc 75 | x_rotation: Angle::zero(), 76 | flags, 77 | }) 78 | } 79 | 80 | pub trait FlattenWithArcs { 81 | fn flattened(&self, tolerance: S) -> Vec>; 82 | } 83 | 84 | impl FlattenWithArcs for CubicBezierSegment 85 | where 86 | S: Scalar + Copy, 87 | { 88 | /// Implementation of [Modeling of Bézier Curves Using a Combination of Linear and Circular Arc Approximations](https://sci-hub.st/https://doi.org/10.1109/CGIV.2012.20) 89 | /// 90 | /// There are some slight deviations like using monotonic ranges instead of bounding by inflection points. 91 | /// 92 | /// Kaewsaiha, P., & Dejdumrong, N. (2012). Modeling of Bézier Curves Using a Combination of Linear and Circular Arc Approximations. 2012 Ninth International Conference on Computer Graphics, Imaging and Visualization. doi:10.1109/cgiv.2012.20 93 | /// 94 | fn flattened(&self, tolerance: S) -> Vec> { 95 | if (self.to - self.from).square_length() < S::EPSILON { 96 | return vec![]; 97 | } else if self.is_linear(tolerance) { 98 | return vec![ArcOrLineSegment::Line(self.baseline())]; 99 | } 100 | let mut acc = vec![]; 101 | 102 | self.for_each_monotonic_range(&mut |range| { 103 | let inner_bezier = self.split_range(range); 104 | 105 | if (inner_bezier.to - inner_bezier.from).square_length() < S::EPSILON { 106 | return; 107 | } else if inner_bezier.is_linear(tolerance) { 108 | acc.push(ArcOrLineSegment::Line(inner_bezier.baseline())); 109 | return; 110 | } 111 | 112 | if let Some(svg_arc) = arc_from_endpoints_and_tangents( 113 | inner_bezier.from, 114 | inner_bezier.derivative(S::ZERO), 115 | inner_bezier.to, 116 | inner_bezier.derivative(S::ONE), 117 | ) 118 | .filter(|svg_arc| { 119 | let arc = svg_arc.to_arc(); 120 | let mut max_deviation = S::ZERO; 121 | // TODO: find a better way to check tolerance 122 | // Ideally: derivative of |f(x) - g(x)| and look at 0 crossings 123 | for i in 1..20 { 124 | let t = S::from(i).unwrap() / S::from(20).unwrap(); 125 | max_deviation = 126 | max_deviation.max((arc.sample(t) - inner_bezier.sample(t)).length()); 127 | } 128 | max_deviation < tolerance 129 | }) { 130 | acc.push(ArcOrLineSegment::Arc(svg_arc)); 131 | } else { 132 | let (left, right) = inner_bezier.split(S::HALF); 133 | acc.append(&mut FlattenWithArcs::flattened(&left, tolerance)); 134 | acc.append(&mut FlattenWithArcs::flattened(&right, tolerance)); 135 | } 136 | }); 137 | acc 138 | } 139 | } 140 | 141 | impl FlattenWithArcs for SvgArc 142 | where 143 | S: Scalar, 144 | { 145 | fn flattened(&self, tolerance: S) -> Vec> { 146 | if (self.to - self.from).square_length() < S::EPSILON { 147 | return vec![]; 148 | } else if self.is_straight_line() { 149 | return vec![ArcOrLineSegment::Line(LineSegment { 150 | from: self.from, 151 | to: self.to, 152 | })]; 153 | } else if (self.radii.x.abs() - self.radii.y.abs()).abs() < S::EPSILON { 154 | return vec![ArcOrLineSegment::Arc(*self)]; 155 | } 156 | 157 | let self_arc = self.to_arc(); 158 | if let Some(svg_arc) = arc_from_endpoints_and_tangents( 159 | self.from, 160 | self_arc.sample_tangent(S::ZERO), 161 | self.to, 162 | self_arc.sample_tangent(S::ONE), 163 | ) 164 | .filter(|approx_svg_arc| { 165 | let approx_arc = approx_svg_arc.to_arc(); 166 | let mut max_deviation = S::ZERO; 167 | // TODO: find a better way to check tolerance 168 | // Ideally: derivative of |f(x) - g(x)| and look at 0 crossings 169 | for i in 1..20 { 170 | let t = S::from(i).unwrap() / S::from(20).unwrap(); 171 | max_deviation = 172 | max_deviation.max((approx_arc.sample(t) - self_arc.sample(t)).length()); 173 | } 174 | max_deviation < tolerance 175 | }) { 176 | vec![ArcOrLineSegment::Arc(svg_arc)] 177 | } else { 178 | let (left, right) = self_arc.split(S::HALF); 179 | let mut acc = FlattenWithArcs::flattened(&left.to_svg_arc(), tolerance); 180 | acc.append(&mut FlattenWithArcs::flattened( 181 | &right.to_svg_arc(), 182 | tolerance, 183 | )); 184 | acc 185 | } 186 | } 187 | } 188 | 189 | pub trait Transformed { 190 | fn transformed(&self, transform: &Transform) -> Self; 191 | } 192 | 193 | impl Transformed for SvgArc { 194 | /// A lot of the math here is heavily borrowed from [Vitaly Putzin's svgpath](https://github.com/fontello/svgpath). 195 | /// 196 | /// The code is Rust-ified with only one or two changes, but I plan to understand the math here and 197 | /// merge changes upstream to lyon-geom. 198 | fn transformed(&self, transform: &Transform) -> Self { 199 | let from = transform.transform_point(self.from); 200 | let to = transform.transform_point(self.to); 201 | 202 | // Translation does not affect rotation, radii, or flags 203 | let [a, b, c, d, _tx, _ty] = transform.to_array(); 204 | let (x_rotation, radii) = { 205 | let (sin, cos) = self.x_rotation.sin_cos(); 206 | 207 | // Radii are axis-aligned -- rotate & transform 208 | let ma = [ 209 | self.radii.x * (a * cos + c * sin), 210 | self.radii.x * (b * cos + d * sin), 211 | self.radii.y * (-a * sin + c * cos), 212 | self.radii.y * (-b * sin + d * cos), 213 | ]; 214 | 215 | // ma * transpose(ma) = [ J L ] 216 | // [ L K ] 217 | // L is calculated later (if the image is not a circle) 218 | let J = ma[0].powi(2) + ma[2].powi(2); 219 | let K = ma[1].powi(2) + ma[3].powi(2); 220 | 221 | // the discriminant of the characteristic polynomial of ma * transpose(ma) 222 | let D = ((ma[0] - ma[3]).powi(2) + (ma[2] + ma[1]).powi(2)) 223 | * ((ma[0] + ma[3]).powi(2) + (ma[2] - ma[1]).powi(2)); 224 | 225 | // the "mean eigenvalue" 226 | let JK = (J + K) / S::TWO; 227 | 228 | // check if the image is (almost) a circle 229 | if D < S::EPSILON * JK { 230 | // if it is 231 | (Angle::zero(), Vector::splat(JK.sqrt())) 232 | } else { 233 | // if it is not a circle 234 | let L = ma[0] * ma[1] + ma[2] * ma[3]; 235 | 236 | let D = D.sqrt(); 237 | 238 | // {l1,l2} = the two eigen values of ma * transpose(ma) 239 | let l1 = JK + D / S::TWO; 240 | let l2 = JK - D / S::TWO; 241 | // the x - axis - rotation angle is the argument of the l1 - eigenvector 242 | let ax = if L.abs() < S::EPSILON && (l1 - K).abs() < S::EPSILON { 243 | Angle::frac_pi_2() 244 | } else { 245 | Angle::radians( 246 | (if L.abs() > (l1 - K).abs() { 247 | (l1 - J) / L 248 | } else { 249 | L / (l1 - K) 250 | }) 251 | .atan(), 252 | ) 253 | }; 254 | (ax, Vector::from([l1.sqrt(), l2.sqrt()])) 255 | } 256 | }; 257 | // A mirror transform causes this flag to be flipped 258 | let invert_sweep = { (a * d) - (b * c) < S::ZERO }; 259 | let flags = ArcFlags { 260 | sweep: if invert_sweep { 261 | !self.flags.sweep 262 | } else { 263 | self.flags.sweep 264 | }, 265 | large_arc: self.flags.large_arc, 266 | }; 267 | Self { 268 | from, 269 | to, 270 | radii, 271 | x_rotation, 272 | flags, 273 | } 274 | } 275 | } 276 | 277 | #[cfg(test)] 278 | mod tests { 279 | use cairo::{Context, SvgSurface}; 280 | use lyon_geom::{point, vector, CubicBezierSegment, Point, Vector}; 281 | use std::path::PathBuf; 282 | use svgtypes::PathParser; 283 | 284 | use crate::arc::{ArcOrLineSegment, FlattenWithArcs}; 285 | 286 | #[test] 287 | #[ignore = "Creates an image file, will revise later"] 288 | fn flatten_returns_expected_arcs() { 289 | const PATH: &str = "M 8.0549,11.9023 290 | c 291 | 0.13447,1.69916 8.85753,-5.917903 7.35159,-6.170957 292 | z"; 293 | let mut surf = 294 | SvgSurface::new(128., 128., Some(PathBuf::from("approx_circle.svg"))).unwrap(); 295 | surf.set_document_unit(cairo::SvgUnit::Mm); 296 | let ctx = Context::new(&surf).unwrap(); 297 | ctx.set_line_width(0.2); 298 | 299 | let mut current_position = Point::zero(); 300 | 301 | let mut acc = 0; 302 | 303 | for path in PathParser::from(PATH) { 304 | use svgtypes::PathSegment::*; 305 | match path.unwrap() { 306 | MoveTo { x, y, abs } => { 307 | if abs { 308 | ctx.move_to(x, y); 309 | current_position = point(x, y); 310 | } else { 311 | ctx.rel_move_to(x, y); 312 | current_position += vector(x, y); 313 | } 314 | } 315 | LineTo { x, y, abs } => { 316 | if abs { 317 | ctx.line_to(x, y); 318 | current_position = point(x, y); 319 | } else { 320 | ctx.rel_line_to(x, y); 321 | current_position += vector(x, y); 322 | } 323 | } 324 | ClosePath { .. } => ctx.close_path(), 325 | CurveTo { 326 | x1, 327 | y1, 328 | x2, 329 | y2, 330 | x, 331 | y, 332 | abs, 333 | } => { 334 | ctx.set_dash(&[], 0.); 335 | match acc { 336 | 0 => ctx.set_source_rgb(1., 0., 0.), 337 | 1 => ctx.set_source_rgb(0., 1., 0.), 338 | 2 => ctx.set_source_rgb(0., 0., 1.), 339 | 3 => ctx.set_source_rgb(0., 0., 0.), 340 | _ => unreachable!(), 341 | } 342 | let curve = CubicBezierSegment { 343 | from: current_position, 344 | ctrl1: (vector(x1, y1) 345 | + if !abs { 346 | current_position.to_vector() 347 | } else { 348 | Vector::zero() 349 | }) 350 | .to_point(), 351 | ctrl2: (vector(x2, y2) 352 | + if !abs { 353 | current_position.to_vector() 354 | } else { 355 | Vector::zero() 356 | }) 357 | .to_point(), 358 | to: (vector(x, y) 359 | + if !abs { 360 | current_position.to_vector() 361 | } else { 362 | Vector::zero() 363 | }) 364 | .to_point(), 365 | }; 366 | for segment in FlattenWithArcs::flattened(&curve, 0.02) { 367 | match segment { 368 | ArcOrLineSegment::Arc(svg_arc) => { 369 | let arc = svg_arc.to_arc(); 370 | if svg_arc.flags.sweep { 371 | ctx.arc( 372 | arc.center.x, 373 | arc.center.y, 374 | arc.radii.x, 375 | arc.start_angle.radians, 376 | (arc.start_angle + arc.sweep_angle).radians, 377 | ) 378 | } else { 379 | ctx.arc_negative( 380 | arc.center.x, 381 | arc.center.y, 382 | arc.radii.x, 383 | arc.start_angle.radians, 384 | (arc.start_angle + arc.sweep_angle).radians, 385 | ) 386 | } 387 | } 388 | ArcOrLineSegment::Line(line) => ctx.line_to(line.to.x, line.to.y), 389 | } 390 | } 391 | 392 | ctx.stroke().unwrap(); 393 | 394 | current_position = curve.to; 395 | ctx.set_dash(&[0.1], 0.); 396 | ctx.move_to(curve.from.x, curve.from.y); 397 | ctx.curve_to( 398 | curve.ctrl1.x, 399 | curve.ctrl1.y, 400 | curve.ctrl2.x, 401 | curve.ctrl2.y, 402 | curve.to.x, 403 | curve.to.y, 404 | ); 405 | ctx.stroke().unwrap(); 406 | acc += 1; 407 | } 408 | other => unimplemented!("{:?}", other), 409 | } 410 | } 411 | } 412 | } 413 | -------------------------------------------------------------------------------- /lib/src/converter/length_serde.rs: -------------------------------------------------------------------------------- 1 | //! Makes it possible to serialize an array of two optional [`svgtypes::Length`]s, 2 | //! used for [super::ConversionOptions::dimensions] 3 | 4 | use serde::{ 5 | de::{SeqAccess, Visitor}, 6 | ser::SerializeSeq, 7 | Deserialize, Deserializer, Serialize, Serializer, 8 | }; 9 | use svgtypes::{Length, LengthUnit}; 10 | 11 | pub fn serialize(length: &[Option; 2], serializer: S) -> Result 12 | where 13 | S: Serializer, 14 | { 15 | let mut seq = serializer.serialize_seq(Some(2))?; 16 | for l in length { 17 | let length_def = l.map(|l| LengthDef { 18 | number: l.number, 19 | unit: l.unit, 20 | }); 21 | seq.serialize_element(&length_def)?; 22 | } 23 | seq.end() 24 | } 25 | 26 | struct OptionalLengthArrayVisitor; 27 | impl<'de> Visitor<'de> for OptionalLengthArrayVisitor { 28 | type Value = [Option; 2]; 29 | 30 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 31 | write!(formatter, "SVG dimension array") 32 | } 33 | 34 | fn visit_seq(self, mut seq: A) -> Result 35 | where 36 | A: SeqAccess<'de>, 37 | { 38 | let x = seq.next_element::>()?.flatten(); 39 | let y = seq.next_element::>()?.flatten(); 40 | Ok([ 41 | x.map(|length_def| Length { 42 | number: length_def.number, 43 | unit: length_def.unit, 44 | }), 45 | y.map(|length_def| Length { 46 | number: length_def.number, 47 | unit: length_def.unit, 48 | }), 49 | ]) 50 | } 51 | } 52 | 53 | pub fn deserialize<'de, D>(deserializer: D) -> Result<[Option; 2], D::Error> 54 | where 55 | D: Deserializer<'de>, 56 | { 57 | deserializer.deserialize_seq(OptionalLengthArrayVisitor) 58 | } 59 | 60 | #[derive(Serialize, Deserialize)] 61 | struct LengthDef { 62 | number: f64, 63 | #[serde(with = "LengthUnitDef")] 64 | unit: LengthUnit, 65 | } 66 | 67 | #[derive(Serialize, Deserialize)] 68 | #[serde(remote = "LengthUnit")] 69 | enum LengthUnitDef { 70 | None, 71 | Em, 72 | Ex, 73 | Px, 74 | In, 75 | Cm, 76 | Mm, 77 | Pt, 78 | Pc, 79 | Percent, 80 | } 81 | -------------------------------------------------------------------------------- /lib/src/converter/mod.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | 3 | use g_code::emit::Token; 4 | use lyon_geom::euclid::default::Transform2D; 5 | use roxmltree::{Document, Node}; 6 | #[cfg(feature = "serde")] 7 | use serde::{Deserialize, Serialize}; 8 | use svgtypes::Length; 9 | use uom::si::f64::Length as UomLength; 10 | use uom::si::length::{inch, millimeter}; 11 | 12 | use self::units::CSS_DEFAULT_DPI; 13 | use crate::{turtle::*, Machine}; 14 | 15 | #[cfg(feature = "serde")] 16 | mod length_serde; 17 | mod path; 18 | mod transform; 19 | mod units; 20 | mod visit; 21 | 22 | /// High-level output configuration 23 | #[derive(Debug, Clone, PartialEq)] 24 | #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 25 | pub struct ConversionConfig { 26 | /// Curve interpolation tolerance in millimeters 27 | pub tolerance: f64, 28 | /// Feedrate in millimeters / minute 29 | pub feedrate: f64, 30 | /// Dots per inch for pixels, picas, points, etc. 31 | pub dpi: f64, 32 | /// Set the origin point in millimeters for this conversion 33 | #[cfg_attr(feature = "serde", serde(default = "zero_origin"))] 34 | pub origin: [Option; 2], 35 | } 36 | 37 | const fn zero_origin() -> [Option; 2] { 38 | [Some(0.); 2] 39 | } 40 | 41 | impl Default for ConversionConfig { 42 | fn default() -> Self { 43 | Self { 44 | tolerance: 0.002, 45 | feedrate: 300.0, 46 | dpi: 96.0, 47 | origin: zero_origin(), 48 | } 49 | } 50 | } 51 | 52 | /// Options are specific to this conversion. 53 | /// 54 | /// This is separate from [ConversionConfig] to support bulk processing in the web interface. 55 | #[derive(Debug, Clone, PartialEq, Default)] 56 | #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 57 | pub struct ConversionOptions { 58 | /// Width and height override 59 | /// 60 | /// Useful when an SVG does not have a set width and height or you want to override it. 61 | #[cfg_attr(feature = "serde", serde(with = "length_serde"))] 62 | pub dimensions: [Option; 2], 63 | } 64 | 65 | /// Maps SVG [`Node`]s and their attributes into operations on a [`Terrarium`] 66 | #[derive(Debug)] 67 | struct ConversionVisitor<'a, T: Turtle> { 68 | terrarium: Terrarium, 69 | name_stack: Vec, 70 | /// Used to convert percentage values 71 | viewport_dim_stack: Vec<[f64; 2]>, 72 | _config: &'a ConversionConfig, 73 | options: ConversionOptions, 74 | } 75 | 76 | impl<'a, T: Turtle> ConversionVisitor<'a, T> { 77 | fn comment(&mut self, node: &Node) { 78 | let mut comment = String::new(); 79 | self.name_stack.iter().for_each(|name| { 80 | comment += name; 81 | comment += " > "; 82 | }); 83 | comment += &node_name(node); 84 | 85 | self.terrarium.turtle.comment(comment); 86 | } 87 | 88 | fn begin(&mut self) { 89 | // Part 1 of converting from SVG to GCode coordinates 90 | self.terrarium.push_transform(Transform2D::scale(1., -1.)); 91 | self.terrarium.turtle.begin(); 92 | } 93 | 94 | fn end(&mut self) { 95 | self.terrarium.turtle.end(); 96 | self.terrarium.pop_transform(); 97 | } 98 | } 99 | 100 | /// Top-level function for converting an SVG [`Document`] into g-code 101 | pub fn svg2program<'a, 'input: 'a>( 102 | doc: &'a Document, 103 | config: &ConversionConfig, 104 | options: ConversionOptions, 105 | machine: Machine<'input>, 106 | ) -> Vec> { 107 | let bounding_box_generator = || { 108 | let mut visitor = ConversionVisitor { 109 | terrarium: Terrarium::new(DpiConvertingTurtle { 110 | inner: PreprocessTurtle::default(), 111 | dpi: config.dpi, 112 | }), 113 | _config: config, 114 | options: options.clone(), 115 | name_stack: vec![], 116 | viewport_dim_stack: vec![], 117 | }; 118 | 119 | visitor.begin(); 120 | visit::depth_first_visit(doc, &mut visitor); 121 | visitor.end(); 122 | 123 | visitor.terrarium.turtle.inner.bounding_box 124 | }; 125 | 126 | // Convert from millimeters to user units 127 | let origin = config 128 | .origin 129 | .map(|dim| dim.map(|d| UomLength::new::(d).get::() * CSS_DEFAULT_DPI)); 130 | 131 | let origin_transform = match origin { 132 | [None, Some(origin_y)] => { 133 | let bb = bounding_box_generator(); 134 | Transform2D::translation(0., origin_y - bb.min.y) 135 | } 136 | [Some(origin_x), None] => { 137 | let bb = bounding_box_generator(); 138 | Transform2D::translation(origin_x - bb.min.x, 0.) 139 | } 140 | [Some(origin_x), Some(origin_y)] => { 141 | let bb = bounding_box_generator(); 142 | Transform2D::translation(origin_x - bb.min.x, origin_y - bb.min.y) 143 | } 144 | [None, None] => Transform2D::identity(), 145 | }; 146 | 147 | let mut conversion_visitor = ConversionVisitor { 148 | terrarium: Terrarium::new(DpiConvertingTurtle { 149 | inner: GCodeTurtle { 150 | machine, 151 | tolerance: config.tolerance, 152 | feedrate: config.feedrate, 153 | program: vec![], 154 | }, 155 | dpi: config.dpi, 156 | }), 157 | _config: config, 158 | options, 159 | name_stack: vec![], 160 | viewport_dim_stack: vec![], 161 | }; 162 | 163 | conversion_visitor 164 | .terrarium 165 | .push_transform(origin_transform); 166 | conversion_visitor.begin(); 167 | visit::depth_first_visit(doc, &mut conversion_visitor); 168 | conversion_visitor.end(); 169 | conversion_visitor.terrarium.pop_transform(); 170 | 171 | conversion_visitor.terrarium.turtle.inner.program 172 | } 173 | 174 | fn node_name(node: &Node) -> String { 175 | let mut name = node.tag_name().name().to_string(); 176 | if let Some(id) = node.attribute("id") { 177 | name += "#"; 178 | name += id; 179 | } 180 | name 181 | } 182 | 183 | #[cfg(all(test, feature = "serde"))] 184 | mod test { 185 | use super::*; 186 | use svgtypes::LengthUnit; 187 | 188 | #[test] 189 | fn serde_conversion_options_is_correct() { 190 | let default_struct = ConversionOptions::default(); 191 | let default_json = r#"{"dimensions":[null,null]}"#; 192 | 193 | assert_eq!( 194 | serde_json::to_string(&default_struct).unwrap(), 195 | default_json 196 | ); 197 | assert_eq!( 198 | serde_json::from_str::(default_json).unwrap(), 199 | default_struct 200 | ); 201 | } 202 | 203 | #[test] 204 | fn serde_conversion_options_with_single_dimension_is_correct() { 205 | let mut r#struct = ConversionOptions::default(); 206 | r#struct.dimensions[0] = Some(Length { 207 | number: 4., 208 | unit: LengthUnit::Mm, 209 | }); 210 | let json = r#"{"dimensions":[{"number":4.0,"unit":"Mm"},null]}"#; 211 | 212 | assert_eq!(serde_json::to_string(&r#struct).unwrap(), json); 213 | assert_eq!( 214 | serde_json::from_str::(json).unwrap(), 215 | r#struct 216 | ); 217 | } 218 | 219 | #[test] 220 | fn serde_conversion_options_with_both_dimensions_is_correct() { 221 | let mut r#struct = ConversionOptions::default(); 222 | r#struct.dimensions = [ 223 | Some(Length { 224 | number: 4., 225 | unit: LengthUnit::Mm, 226 | }), 227 | Some(Length { 228 | number: 10.5, 229 | unit: LengthUnit::In, 230 | }), 231 | ]; 232 | let json = r#"{"dimensions":[{"number":4.0,"unit":"Mm"},{"number":10.5,"unit":"In"}]}"#; 233 | 234 | assert_eq!(serde_json::to_string(&r#struct).unwrap(), json); 235 | assert_eq!( 236 | serde_json::from_str::(json).unwrap(), 237 | r#struct 238 | ); 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /lib/src/converter/path.rs: -------------------------------------------------------------------------------- 1 | use euclid::Angle; 2 | use log::debug; 3 | use lyon_geom::{point, vector, ArcFlags}; 4 | use svgtypes::PathSegment; 5 | 6 | use crate::Turtle; 7 | 8 | use super::Terrarium; 9 | 10 | /// Maps [`PathSegment`]s into concrete operations on the [`Terrarium`] 11 | /// 12 | /// Performs a [`Terrarium::reset`] on each call 13 | pub fn apply_path( 14 | terrarium: &mut Terrarium, 15 | path: impl IntoIterator, 16 | ) { 17 | use PathSegment::*; 18 | 19 | terrarium.reset(); 20 | path.into_iter().for_each(|segment| { 21 | debug!("Drawing {:?}", &segment); 22 | match segment { 23 | MoveTo { abs, x, y } => terrarium.move_to(abs, x, y), 24 | ClosePath { abs: _ } => { 25 | // Ignore abs, should have identical effect: [9.3.4. The "closepath" command]("https://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand) 26 | terrarium.close() 27 | } 28 | LineTo { abs, x, y } => terrarium.line(abs, x, y), 29 | HorizontalLineTo { abs, x } => terrarium.line(abs, x, None), 30 | VerticalLineTo { abs, y } => terrarium.line(abs, None, y), 31 | CurveTo { 32 | abs, 33 | x1, 34 | y1, 35 | x2, 36 | y2, 37 | x, 38 | y, 39 | } => terrarium.cubic_bezier(abs, point(x1, y1), point(x2, y2), point(x, y)), 40 | SmoothCurveTo { abs, x2, y2, x, y } => { 41 | terrarium.smooth_cubic_bezier(abs, point(x2, y2), point(x, y)) 42 | } 43 | Quadratic { abs, x1, y1, x, y } => { 44 | terrarium.quadratic_bezier(abs, point(x1, y1), point(x, y)) 45 | } 46 | SmoothQuadratic { abs, x, y } => terrarium.smooth_quadratic_bezier(abs, point(x, y)), 47 | EllipticalArc { 48 | abs, 49 | rx, 50 | ry, 51 | x_axis_rotation, 52 | large_arc, 53 | sweep, 54 | x, 55 | y, 56 | } => terrarium.elliptical( 57 | abs, 58 | vector(rx, ry), 59 | Angle::degrees(x_axis_rotation), 60 | ArcFlags { large_arc, sweep }, 61 | point(x, y), 62 | ), 63 | } 64 | }); 65 | } 66 | -------------------------------------------------------------------------------- /lib/src/converter/transform.rs: -------------------------------------------------------------------------------- 1 | use euclid::{ 2 | default::{Transform2D, Transform3D}, 3 | Angle, 4 | }; 5 | use lyon_geom::vector; 6 | use svgtypes::{Align, AspectRatio, TransformListToken, ViewBox}; 7 | 8 | /// 9 | pub fn get_viewport_transform( 10 | view_box: ViewBox, 11 | preserve_aspect_ratio: Option, 12 | viewport_size: [f64; 2], 13 | viewport_pos: [Option; 2], 14 | ) -> Transform2D { 15 | let [element_width, element_height] = viewport_size; 16 | let [element_x, element_y] = viewport_pos.map(|pos| pos.unwrap_or(0.)); 17 | 18 | let preserve_aspect_ratio = preserve_aspect_ratio.unwrap_or(AspectRatio { 19 | defer: false, 20 | align: Align::XMidYMid, 21 | slice: false, 22 | }); 23 | 24 | let mut scale_x = element_width / view_box.w; 25 | let mut scale_y = element_height / view_box.h; 26 | if preserve_aspect_ratio.align != Align::None { 27 | if preserve_aspect_ratio.slice { 28 | scale_x = scale_x.max(scale_y); 29 | } else { 30 | scale_x = scale_x.min(scale_y); 31 | } 32 | scale_y = scale_x; 33 | } 34 | let mut translate_x = element_x - (view_box.x * scale_x); 35 | let mut translate_y = element_y - (view_box.y * scale_y); 36 | match preserve_aspect_ratio.align { 37 | Align::XMidYMax | Align::XMidYMid | Align::XMidYMin => { 38 | translate_x += (element_width - view_box.w * scale_x) / 2.; 39 | } 40 | Align::XMaxYMax | Align::XMaxYMid | Align::XMaxYMin => { 41 | translate_x += element_width - view_box.w * scale_x; 42 | } 43 | Align::None | Align::XMinYMin | Align::XMinYMid | Align::XMinYMax => {} 44 | } 45 | match preserve_aspect_ratio.align { 46 | Align::XMinYMid | Align::XMidYMid | Align::XMaxYMid => { 47 | translate_y += (element_height - view_box.h * scale_y) / 2.; 48 | } 49 | Align::XMinYMax | Align::XMidYMax | Align::XMaxYMax => { 50 | translate_y += element_height - view_box.h * scale_y; 51 | } 52 | Align::None | Align::XMinYMin | Align::XMidYMin | Align::XMaxYMin => {} 53 | } 54 | Transform2D::scale(scale_x, scale_y).then_translate(vector(translate_x, translate_y)) 55 | } 56 | 57 | pub fn svg_transform_into_euclid_transform(svg_transform: TransformListToken) -> Transform2D { 58 | use TransformListToken::*; 59 | match svg_transform { 60 | Matrix { a, b, c, d, e, f } => Transform2D::new(a, b, c, d, e, f), 61 | Translate { tx, ty } => Transform2D::translation(tx, ty), 62 | Scale { sx, sy } => Transform2D::scale(sx, sy), 63 | Rotate { angle } => Transform2D::rotation(Angle::degrees(angle)), 64 | // https://drafts.csswg.org/css-transforms/#SkewXDefined 65 | SkewX { angle } => Transform3D::skew(Angle::degrees(angle), Angle::zero()).to_2d(), 66 | // https://drafts.csswg.org/css-transforms/#SkewYDefined 67 | SkewY { angle } => Transform3D::skew(Angle::zero(), Angle::degrees(angle)).to_2d(), 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/src/converter/units.rs: -------------------------------------------------------------------------------- 1 | use log::warn; 2 | use roxmltree::Node; 3 | use svgtypes::{Length, LengthListParser}; 4 | 5 | use crate::Turtle; 6 | 7 | use super::ConversionVisitor; 8 | 9 | /// The DPI assumed by CSS is 96. 10 | /// 11 | /// 12 | pub const CSS_DEFAULT_DPI: f64 = 96.; 13 | 14 | /// Used to compute percentages correctly 15 | /// 16 | /// 17 | #[derive(Clone, Copy)] 18 | pub enum DimensionHint { 19 | Horizontal, 20 | Vertical, 21 | Other, 22 | } 23 | 24 | impl<'a, T: Turtle> ConversionVisitor<'a, T> { 25 | /// Convenience function for converting a length attribute to user units 26 | pub fn length_attr_to_user_units(&self, node: &Node, attr: &str) -> Option { 27 | let l = node 28 | .attribute(attr) 29 | .map(LengthListParser::from) 30 | .and_then(|mut parser| parser.next()) 31 | .transpose() 32 | .ok() 33 | .flatten()?; 34 | 35 | Some(self.length_to_user_units( 36 | l, 37 | match attr { 38 | "x" | "x1" | "x2" | "cx" | "rx" | "width" => DimensionHint::Horizontal, 39 | "y" | "y1" | "y2" | "cy" | "ry" | "height" => DimensionHint::Vertical, 40 | _ => DimensionHint::Other, 41 | }, 42 | )) 43 | } 44 | /// Convenience function for converting [`Length`] to user units 45 | /// 46 | /// Absolute lengths are listed in [CSS 4 §6.2](https://www.w3.org/TR/css-values/#absolute-lengths). 47 | /// Relative lengths in [CSS 4 §6.1](https://www.w3.org/TR/css-values/#relative-lengths) are not supported and will simply be interpreted as millimeters. 48 | /// 49 | /// A default DPI of 96 is used as per [CSS 4 §7.4](https://www.w3.org/TR/css-values/#resolution) 50 | pub fn length_to_user_units(&self, l: Length, hint: DimensionHint) -> f64 { 51 | use svgtypes::LengthUnit::*; 52 | use uom::si::f64::Length; 53 | use uom::si::length::*; 54 | 55 | match l.unit { 56 | Cm => Length::new::(l.number).get::() * CSS_DEFAULT_DPI, 57 | Mm => Length::new::(l.number).get::() * CSS_DEFAULT_DPI, 58 | In => Length::new::(l.number).get::() * CSS_DEFAULT_DPI, 59 | Pc => Length::new::(l.number).get::() * CSS_DEFAULT_DPI, 60 | Pt => Length::new::(l.number).get::() * CSS_DEFAULT_DPI, 61 | // https://www.w3.org/TR/SVG/coords.html#ViewportSpace says None should be treated as Px 62 | Px | None => l.number, 63 | Em | Ex => { 64 | warn!("Converting from em/ex to millimeters assumes 1em/ex = 16px"); 65 | 16. * l.number 66 | } 67 | // https://www.w3.org/TR/SVG/coords.html#Units 68 | Percent => { 69 | if let Some([width, height]) = self.viewport_dim_stack.last() { 70 | let scale = match hint { 71 | DimensionHint::Horizontal => *width, 72 | DimensionHint::Vertical => *height, 73 | DimensionHint::Other => { 74 | (width.powi(2) + height.powi(2)).sqrt() / 2.0_f64.sqrt() 75 | } 76 | }; 77 | l.number / 100. * scale 78 | } else { 79 | warn!("A percentage without an established viewport is not valid!"); 80 | l.number / 100. 81 | } 82 | } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /lib/src/lib.rs: -------------------------------------------------------------------------------- 1 | /// Approximate [Bézier curves](https://en.wikipedia.org/wiki/B%C3%A9zier_curve) with [Circular arcs](https://en.wikipedia.org/wiki/Circular_arc) 2 | mod arc; 3 | /// Converts an SVG to an internal representation 4 | mod converter; 5 | /// Emulates the state of an arbitrary machine that can run G-Code 6 | mod machine; 7 | /// Operations that are easier to implement while/after G-Code is generated, or would 8 | /// otherwise over-complicate SVG conversion 9 | mod postprocess; 10 | /// Provides an interface for drawing lines in G-Code 11 | /// This concept is referred to as [Turtle graphics](https://en.wikipedia.org/wiki/Turtle_graphics). 12 | mod turtle; 13 | 14 | pub use converter::{svg2program, ConversionConfig, ConversionOptions}; 15 | pub use machine::{Machine, MachineConfig, SupportedFunctionality}; 16 | pub use postprocess::PostprocessConfig; 17 | pub use turtle::Turtle; 18 | 19 | /// A cross-platform type used to store all configuration types. 20 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 21 | #[derive(Debug, Default, Clone, PartialEq)] 22 | pub struct Settings { 23 | pub conversion: ConversionConfig, 24 | pub machine: MachineConfig, 25 | pub postprocess: PostprocessConfig, 26 | #[cfg_attr(feature = "serde", serde(default = "Version::unknown"))] 27 | pub version: Version, 28 | } 29 | 30 | impl Settings { 31 | /// Try to automatically upgrade the supported version. 32 | /// 33 | /// This will return an error if: 34 | /// 35 | /// - Settings version is [`Version::Unknown`]. 36 | /// - There are breaking changes requiring manual intervention. In which case this does a partial update to that point. 37 | pub fn try_upgrade(&mut self) -> Result<(), &'static str> { 38 | loop { 39 | match self.version { 40 | // Compatibility for M2 by default 41 | Version::V0 => { 42 | self.machine.end_sequence = Some(format!( 43 | "{} M2", 44 | self.machine.end_sequence.take().unwrap_or_default() 45 | )); 46 | self.version = Version::V5; 47 | } 48 | Version::V5 => break Ok(()), 49 | Version::Unknown(_) => break Err("cannot upgrade unknown version"), 50 | } 51 | } 52 | } 53 | } 54 | 55 | /// Used to control breaking change behavior for [`Settings`]. 56 | /// 57 | /// There were already 3 non-breaking version bumps (V1 -> V4) so versioning starts off with [`Version::V5`]. 58 | #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 59 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 60 | pub enum Version { 61 | /// Implicitly versioned settings from before this type was introduced. 62 | V0, 63 | /// M2 is no longer appended to the program by default 64 | V5, 65 | #[cfg_attr(feature = "serde", serde(untagged))] 66 | Unknown(String), 67 | } 68 | 69 | impl Version { 70 | /// Returns the most recent [`Version`]. This is useful for asking users to upgrade externally-stored settings. 71 | pub const fn latest() -> Self { 72 | Self::V5 73 | } 74 | 75 | /// Default version for old settings. 76 | pub const fn unknown() -> Self { 77 | Self::V0 78 | } 79 | } 80 | 81 | impl std::fmt::Display for Version { 82 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 83 | match self { 84 | Version::V0 => f.write_str("V0"), 85 | Version::V5 => f.write_str("V5"), 86 | Version::Unknown(unknown) => f.write_str(unknown), 87 | } 88 | } 89 | } 90 | 91 | impl Default for Version { 92 | fn default() -> Self { 93 | Self::latest() 94 | } 95 | } 96 | 97 | #[cfg(test)] 98 | mod test { 99 | use super::*; 100 | use g_code::emit::{FormatOptions, Token}; 101 | use pretty_assertions::assert_eq; 102 | use roxmltree::ParsingOptions; 103 | use svgtypes::{Length, LengthUnit}; 104 | 105 | /// The values change between debug and release builds for circular interpolation, 106 | /// so only check within a rough tolerance 107 | const TOLERANCE: f64 = 1E-10; 108 | 109 | fn get_actual( 110 | input: &str, 111 | circular_interpolation: bool, 112 | dimensions: [Option; 2], 113 | ) -> Vec> { 114 | let config = ConversionConfig::default(); 115 | let options = ConversionOptions { dimensions }; 116 | let document = roxmltree::Document::parse_with_options( 117 | input, 118 | ParsingOptions { 119 | allow_dtd: true, 120 | ..Default::default() 121 | }, 122 | ) 123 | .unwrap(); 124 | 125 | let machine = Machine::new( 126 | SupportedFunctionality { 127 | circular_interpolation, 128 | }, 129 | None, 130 | None, 131 | None, 132 | None, 133 | ); 134 | converter::svg2program(&document, &config, options, machine) 135 | } 136 | 137 | fn assert_close(left: Vec>, right: Vec>) { 138 | let mut code = String::new(); 139 | g_code::emit::format_gcode_fmt(left.iter(), FormatOptions::default(), &mut code).unwrap(); 140 | assert_eq!(left.len(), right.len(), "{code}"); 141 | for (i, pair) in left.into_iter().zip(right.into_iter()).enumerate() { 142 | match pair { 143 | (Token::Field(l), Token::Field(r)) => { 144 | assert_eq!(l.letters, r.letters); 145 | if let (Some(l_value), Some(r_value)) = (l.value.as_f64(), r.value.as_f64()) { 146 | assert!( 147 | (l_value - r_value).abs() < TOLERANCE, 148 | "Values differ significantly at {i}: {l} vs {r} ({})", 149 | (l_value - r_value).abs() 150 | ); 151 | } else { 152 | assert_eq!(l, r); 153 | } 154 | } 155 | (l, r) => { 156 | assert_eq!(l, r, "Differs at {i}"); 157 | } 158 | } 159 | } 160 | } 161 | 162 | #[test] 163 | fn square_produces_expected_gcode() { 164 | let expected = g_code::parse::file_parser(include_str!("../tests/square.gcode")) 165 | .unwrap() 166 | .iter_emit_tokens() 167 | .collect::>(); 168 | let actual = get_actual(include_str!("../tests/square.svg"), false, [None; 2]); 169 | 170 | assert_close(actual, expected); 171 | } 172 | 173 | #[test] 174 | fn square_dimension_override_produces_expected_gcode() { 175 | let side_length = Length { 176 | number: 10., 177 | unit: LengthUnit::Mm, 178 | }; 179 | 180 | let expected = g_code::parse::file_parser(include_str!("../tests/square.gcode")) 181 | .unwrap() 182 | .iter_emit_tokens() 183 | .collect::>(); 184 | 185 | for square in [ 186 | include_str!("../tests/square.svg"), 187 | include_str!("../tests/square_dimensionless.svg"), 188 | ] { 189 | assert_close( 190 | get_actual(square, false, [Some(side_length); 2]), 191 | expected.clone(), 192 | ); 193 | assert_close( 194 | get_actual(square, false, [Some(side_length), None]), 195 | expected.clone(), 196 | ); 197 | assert_close( 198 | get_actual(square, false, [None, Some(side_length)]), 199 | expected.clone(), 200 | ); 201 | } 202 | } 203 | 204 | #[test] 205 | fn square_transformed_produces_expected_gcode() { 206 | let square_transformed = include_str!("../tests/square_transformed.svg"); 207 | let expected = 208 | g_code::parse::file_parser(include_str!("../tests/square_transformed.gcode")) 209 | .unwrap() 210 | .iter_emit_tokens() 211 | .collect::>(); 212 | let actual = get_actual(square_transformed, false, [None; 2]); 213 | 214 | assert_close(actual, expected) 215 | } 216 | 217 | #[test] 218 | fn square_transformed_nested_produces_expected_gcode() { 219 | let square_transformed = include_str!("../tests/square_transformed_nested.svg"); 220 | let expected = 221 | g_code::parse::file_parser(include_str!("../tests/square_transformed_nested.gcode")) 222 | .unwrap() 223 | .iter_emit_tokens() 224 | .collect::>(); 225 | let actual = get_actual(square_transformed, false, [None; 2]); 226 | 227 | assert_close(actual, expected) 228 | } 229 | 230 | #[test] 231 | fn square_viewport_produces_expected_gcode() { 232 | let square_viewport = include_str!("../tests/square_viewport.svg"); 233 | let expected = g_code::parse::file_parser(include_str!("../tests/square_viewport.gcode")) 234 | .unwrap() 235 | .iter_emit_tokens() 236 | .collect::>(); 237 | let actual = get_actual(square_viewport, false, [None; 2]); 238 | 239 | assert_close(actual, expected); 240 | } 241 | 242 | #[test] 243 | fn circular_interpolation_produces_expected_gcode() { 244 | let circular_interpolation = include_str!("../tests/circular_interpolation.svg"); 245 | let expected = 246 | g_code::parse::file_parser(include_str!("../tests/circular_interpolation.gcode")) 247 | .unwrap() 248 | .iter_emit_tokens() 249 | .collect::>(); 250 | let actual = get_actual(circular_interpolation, true, [None; 2]); 251 | 252 | assert_close(actual, expected) 253 | } 254 | 255 | #[test] 256 | fn svg_with_smooth_curves_produces_expected_gcode() { 257 | let svg = include_str!("../tests/smooth_curves.svg"); 258 | 259 | let expected = g_code::parse::file_parser(include_str!("../tests/smooth_curves.gcode")) 260 | .unwrap() 261 | .iter_emit_tokens() 262 | .collect::>(); 263 | 264 | let file = if cfg!(debug) { 265 | include_str!("../tests/smooth_curves_circular_interpolation.gcode") 266 | } else { 267 | include_str!("../tests/smooth_curves_circular_interpolation_release.gcode") 268 | }; 269 | let expected_circular_interpolation = g_code::parse::file_parser(file) 270 | .unwrap() 271 | .iter_emit_tokens() 272 | .collect::>(); 273 | assert_close(get_actual(svg, false, [None; 2]), expected); 274 | 275 | assert_close( 276 | get_actual(svg, true, [None; 2]), 277 | expected_circular_interpolation, 278 | ); 279 | } 280 | 281 | #[test] 282 | fn shapes_produces_expected_gcode() { 283 | let shapes = include_str!("../tests/shapes.svg"); 284 | let expected = g_code::parse::file_parser(include_str!("../tests/shapes.gcode")) 285 | .unwrap() 286 | .iter_emit_tokens() 287 | .collect::>(); 288 | let actual = get_actual(shapes, false, [None; 2]); 289 | 290 | assert_close(actual, expected) 291 | } 292 | 293 | #[test] 294 | #[cfg(feature = "serde")] 295 | fn deserialize_v1_config_succeeds() { 296 | let json = r#" 297 | { 298 | "conversion": { 299 | "tolerance": 0.002, 300 | "feedrate": 300.0, 301 | "dpi": 96.0 302 | }, 303 | "machine": { 304 | "supported_functionality": { 305 | "circular_interpolation": true 306 | }, 307 | "tool_on_sequence": null, 308 | "tool_off_sequence": null, 309 | "begin_sequence": null, 310 | "end_sequence": null 311 | }, 312 | "postprocess": { 313 | "origin": [ 314 | 0.0, 315 | 0.0 316 | ] 317 | } 318 | } 319 | "#; 320 | serde_json::from_str::(json).unwrap(); 321 | } 322 | 323 | #[test] 324 | #[cfg(feature = "serde")] 325 | fn deserialize_v2_config_succeeds() { 326 | let json = r#" 327 | { 328 | "conversion": { 329 | "tolerance": 0.002, 330 | "feedrate": 300.0, 331 | "dpi": 96.0 332 | }, 333 | "machine": { 334 | "supported_functionality": { 335 | "circular_interpolation": true 336 | }, 337 | "tool_on_sequence": null, 338 | "tool_off_sequence": null, 339 | "begin_sequence": null, 340 | "end_sequence": null 341 | }, 342 | "postprocess": { } 343 | } 344 | "#; 345 | serde_json::from_str::(json).unwrap(); 346 | } 347 | 348 | #[test] 349 | #[cfg(feature = "serde")] 350 | fn deserialize_v3_config_succeeds() { 351 | let json = r#" 352 | { 353 | "conversion": { 354 | "tolerance": 0.002, 355 | "feedrate": 300.0, 356 | "dpi": 96.0 357 | }, 358 | "machine": { 359 | "supported_functionality": { 360 | "circular_interpolation": true 361 | }, 362 | "tool_on_sequence": null, 363 | "tool_off_sequence": null, 364 | "begin_sequence": null, 365 | "end_sequence": null 366 | }, 367 | "postprocess": { 368 | "checksums": false, 369 | "line_numbers": false 370 | } 371 | } 372 | "#; 373 | serde_json::from_str::(json).unwrap(); 374 | } 375 | 376 | #[test] 377 | #[cfg(feature = "serde")] 378 | fn deserialize_v4_config_succeeds() { 379 | let json = r#" 380 | { 381 | "conversion": { 382 | "tolerance": 0.002, 383 | "feedrate": 300.0, 384 | "dpi": 96.0 385 | }, 386 | "machine": { 387 | "supported_functionality": { 388 | "circular_interpolation": true 389 | }, 390 | "tool_on_sequence": null, 391 | "tool_off_sequence": null, 392 | "begin_sequence": null, 393 | "end_sequence": null 394 | }, 395 | "postprocess": { 396 | "checksums": false, 397 | "line_numbers": false, 398 | "newline_before_comment": false 399 | } 400 | } 401 | "#; 402 | serde_json::from_str::(json).unwrap(); 403 | } 404 | 405 | #[test] 406 | #[cfg(feature = "serde")] 407 | fn deserialize_v5_config_succeeds() { 408 | let json = r#" 409 | { 410 | "conversion": { 411 | "tolerance": 0.002, 412 | "feedrate": 300.0, 413 | "dpi": 96.0 414 | }, 415 | "machine": { 416 | "supported_functionality": { 417 | "circular_interpolation": true 418 | }, 419 | "tool_on_sequence": null, 420 | "tool_off_sequence": null, 421 | "begin_sequence": null, 422 | "end_sequence": null 423 | }, 424 | "postprocess": { 425 | "checksums": false, 426 | "line_numbers": false, 427 | "newline_before_comment": false 428 | }, 429 | "version": "V5" 430 | } 431 | "#; 432 | serde_json::from_str::(json).unwrap(); 433 | } 434 | } 435 | -------------------------------------------------------------------------------- /lib/src/machine.rs: -------------------------------------------------------------------------------- 1 | use g_code::{ 2 | command, 3 | emit::Token, 4 | parse::{ast::Snippet, snippet_parser}, 5 | }; 6 | #[cfg(feature = "serde")] 7 | use serde::{Deserialize, Serialize}; 8 | 9 | /// Whether the tool is active (i.e. cutting) 10 | #[derive(Copy, Clone, PartialEq, Eq, Debug)] 11 | pub enum Tool { 12 | Off, 13 | On, 14 | } 15 | 16 | /// The distance mode for movement commands 17 | #[derive(Copy, Clone, PartialEq, Eq, Debug)] 18 | pub enum Distance { 19 | Absolute, 20 | Relative, 21 | } 22 | 23 | /// Generic machine state simulation, assuming nothing is known about the machine when initialized. 24 | /// This is used to reduce output G-Code verbosity and run repetitive actions. 25 | #[derive(Debug, Clone)] 26 | pub struct Machine<'input> { 27 | supported_functionality: SupportedFunctionality, 28 | tool_state: Option, 29 | distance_mode: Option, 30 | tool_on_sequence: Snippet<'input>, 31 | tool_off_sequence: Snippet<'input>, 32 | program_begin_sequence: Snippet<'input>, 33 | program_end_sequence: Snippet<'input>, 34 | /// Empty snippet used to provide the same iterator type when a sequence must be empty 35 | empty_snippet: Snippet<'input>, 36 | } 37 | 38 | #[derive(Debug, Default, Clone, PartialEq)] 39 | #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 40 | pub struct MachineConfig { 41 | pub supported_functionality: SupportedFunctionality, 42 | pub tool_on_sequence: Option, 43 | pub tool_off_sequence: Option, 44 | pub begin_sequence: Option, 45 | pub end_sequence: Option, 46 | } 47 | 48 | #[derive(Debug, Default, Clone, PartialEq)] 49 | #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 50 | pub struct SupportedFunctionality { 51 | /// Indicates support for G2/G3 circular interpolation. 52 | /// 53 | /// Most modern machines support this. Old ones like early MakerBot 3D printers do not. 54 | pub circular_interpolation: bool, 55 | } 56 | 57 | impl<'input> Machine<'input> { 58 | pub fn new( 59 | supported_functionality: SupportedFunctionality, 60 | tool_on_sequence: Option>, 61 | tool_off_sequence: Option>, 62 | program_begin_sequence: Option>, 63 | program_end_sequence: Option>, 64 | ) -> Self { 65 | let empty_snippet = snippet_parser("").expect("empty string is a valid snippet"); 66 | Self { 67 | supported_functionality, 68 | tool_on_sequence: tool_on_sequence.unwrap_or_else(|| empty_snippet.clone()), 69 | tool_off_sequence: tool_off_sequence.unwrap_or_else(|| empty_snippet.clone()), 70 | program_begin_sequence: program_begin_sequence.unwrap_or_else(|| empty_snippet.clone()), 71 | program_end_sequence: program_end_sequence.unwrap_or_else(|| empty_snippet.clone()), 72 | empty_snippet, 73 | tool_state: Default::default(), 74 | distance_mode: Default::default(), 75 | } 76 | } 77 | 78 | pub fn supported_functionality(&self) -> &SupportedFunctionality { 79 | &self.supported_functionality 80 | } 81 | 82 | /// Output gcode to turn the tool on. 83 | pub fn tool_on(&mut self) -> impl Iterator> + '_ { 84 | if self.tool_state == Some(Tool::Off) || self.tool_state.is_none() { 85 | self.tool_state = Some(Tool::On); 86 | self.tool_on_sequence.iter_emit_tokens() 87 | } else { 88 | self.empty_snippet.iter_emit_tokens() 89 | } 90 | } 91 | 92 | /// Output gcode to turn the tool off. 93 | pub fn tool_off(&mut self) -> impl Iterator> + '_ { 94 | if self.tool_state == Some(Tool::On) || self.tool_state.is_none() { 95 | self.tool_state = Some(Tool::Off); 96 | self.tool_off_sequence.iter_emit_tokens() 97 | } else { 98 | self.empty_snippet.iter_emit_tokens() 99 | } 100 | } 101 | 102 | /// Output user-defined setup gcode 103 | pub fn program_begin(&self) -> impl Iterator> + '_ { 104 | self.program_begin_sequence.iter_emit_tokens() 105 | } 106 | 107 | /// Output user-defined teardown gcode 108 | pub fn program_end(&self) -> impl Iterator> + '_ { 109 | self.program_end_sequence.iter_emit_tokens() 110 | } 111 | 112 | /// Output absolute distance field if mode was relative or unknown. 113 | pub fn absolute(&mut self) -> Vec> { 114 | if self.distance_mode == Some(Distance::Relative) || self.distance_mode.is_none() { 115 | self.distance_mode = Some(Distance::Absolute); 116 | command!(AbsoluteDistanceMode {}).into_token_vec() 117 | } else { 118 | vec![] 119 | } 120 | } 121 | 122 | /// Output relative distance field if mode was absolute or unknown. 123 | pub fn relative(&mut self) -> Vec> { 124 | if self.distance_mode == Some(Distance::Absolute) || self.distance_mode.is_none() { 125 | self.distance_mode = Some(Distance::Relative); 126 | command!(RelativeDistanceMode {}).into_token_vec() 127 | } else { 128 | vec![] 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /lib/src/postprocess.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "serde")] 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] 5 | #[derive(Debug, Default, Clone, PartialEq)] 6 | pub struct PostprocessConfig { 7 | /// Convenience field for [g_code::emit::FormatOptions] field 8 | #[cfg_attr(feature = "serde", serde(default))] 9 | pub checksums: bool, 10 | /// Convenience field for [g_code::emit::FormatOptions] field 11 | #[cfg_attr(feature = "serde", serde(default))] 12 | pub line_numbers: bool, 13 | /// Convenience field for [g_code::emit::FormatOptions] field 14 | #[cfg_attr(feature = "serde", serde(default))] 15 | pub newline_before_comment: bool, 16 | } 17 | -------------------------------------------------------------------------------- /lib/src/turtle/dpi.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | 3 | use lyon_geom::{point, vector, CubicBezierSegment, Point, QuadraticBezierSegment, SvgArc, Vector}; 4 | use uom::si::{ 5 | f64::Length, 6 | length::{inch, millimeter}, 7 | }; 8 | 9 | use crate::Turtle; 10 | 11 | /// Wrapper turtle that converts from user units to millimeters at a given DPI 12 | #[derive(Debug)] 13 | pub struct DpiConvertingTurtle { 14 | pub dpi: f64, 15 | pub inner: T, 16 | } 17 | 18 | impl DpiConvertingTurtle { 19 | fn to_mm(&self, value: f64) -> f64 { 20 | Length::new::(value / self.dpi).get::() 21 | } 22 | 23 | fn point_to_mm(&self, p: Point) -> Point { 24 | point(self.to_mm(p.x), self.to_mm(p.y)) 25 | } 26 | 27 | fn vector_to_mm(&self, v: Vector) -> Vector { 28 | vector(self.to_mm(v.x), self.to_mm(v.y)) 29 | } 30 | } 31 | 32 | impl Turtle for DpiConvertingTurtle { 33 | fn begin(&mut self) { 34 | self.inner.begin() 35 | } 36 | 37 | fn end(&mut self) { 38 | self.inner.end() 39 | } 40 | 41 | fn comment(&mut self, comment: String) { 42 | self.inner.comment(comment) 43 | } 44 | 45 | fn move_to(&mut self, to: Point) { 46 | self.inner.move_to(self.point_to_mm(to)) 47 | } 48 | 49 | fn line_to(&mut self, to: Point) { 50 | self.inner.line_to(self.point_to_mm(to)) 51 | } 52 | 53 | fn arc( 54 | &mut self, 55 | SvgArc { 56 | from, 57 | to, 58 | radii, 59 | x_rotation, 60 | flags, 61 | }: SvgArc, 62 | ) { 63 | self.inner.arc(SvgArc { 64 | from: self.point_to_mm(from), 65 | to: self.point_to_mm(to), 66 | radii: self.vector_to_mm(radii), 67 | x_rotation, 68 | flags, 69 | }) 70 | } 71 | 72 | fn cubic_bezier( 73 | &mut self, 74 | CubicBezierSegment { 75 | from, 76 | ctrl1, 77 | ctrl2, 78 | to, 79 | }: CubicBezierSegment, 80 | ) { 81 | self.inner.cubic_bezier(CubicBezierSegment { 82 | from: self.point_to_mm(from), 83 | ctrl1: self.point_to_mm(ctrl1), 84 | ctrl2: self.point_to_mm(ctrl2), 85 | to: self.point_to_mm(to), 86 | }) 87 | } 88 | 89 | fn quadratic_bezier( 90 | &mut self, 91 | QuadraticBezierSegment { from, ctrl, to }: QuadraticBezierSegment, 92 | ) { 93 | self.inner.quadratic_bezier(QuadraticBezierSegment { 94 | from: self.point_to_mm(from), 95 | to: self.point_to_mm(to), 96 | ctrl: self.point_to_mm(ctrl), 97 | }) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /lib/src/turtle/g_code.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::fmt::Debug; 3 | 4 | use ::g_code::{command, emit::Token}; 5 | use lyon_geom::{CubicBezierSegment, Point, QuadraticBezierSegment, SvgArc}; 6 | 7 | use super::Turtle; 8 | use crate::arc::{ArcOrLineSegment, FlattenWithArcs}; 9 | use crate::machine::Machine; 10 | 11 | /// Maps path segments into g-code operations 12 | #[derive(Debug)] 13 | pub struct GCodeTurtle<'input> { 14 | pub machine: Machine<'input>, 15 | pub tolerance: f64, 16 | pub feedrate: f64, 17 | pub program: Vec>, 18 | } 19 | 20 | impl<'input> GCodeTurtle<'input> { 21 | fn circular_interpolation(&self, svg_arc: SvgArc) -> Vec> { 22 | debug_assert!((svg_arc.radii.x.abs() - svg_arc.radii.y.abs()).abs() < f64::EPSILON); 23 | match (svg_arc.flags.large_arc, svg_arc.flags.sweep) { 24 | (false, true) => command!(CounterclockwiseCircularInterpolation { 25 | X: svg_arc.to.x, 26 | Y: svg_arc.to.y, 27 | R: svg_arc.radii.x, 28 | F: self.feedrate, 29 | }) 30 | .into_token_vec(), 31 | (false, false) => command!(ClockwiseCircularInterpolation { 32 | X: svg_arc.to.x, 33 | Y: svg_arc.to.y, 34 | R: svg_arc.radii.x, 35 | F: self.feedrate, 36 | }) 37 | .into_token_vec(), 38 | (true, _) => { 39 | let (left, right) = svg_arc.to_arc().split(0.5); 40 | let mut token_vec = self.circular_interpolation(left.to_svg_arc()); 41 | token_vec.append(&mut self.circular_interpolation(right.to_svg_arc())); 42 | token_vec 43 | } 44 | } 45 | } 46 | 47 | fn tool_on(&mut self) { 48 | self.program.extend(self.machine.tool_on()); 49 | self.program.extend(self.machine.absolute()); 50 | } 51 | 52 | fn tool_off(&mut self) { 53 | self.program.extend(self.machine.tool_off()); 54 | self.program.extend(self.machine.absolute()); 55 | } 56 | } 57 | 58 | impl<'input> Turtle for GCodeTurtle<'input> { 59 | fn begin(&mut self) { 60 | self.program 61 | .append(&mut command!(UnitsMillimeters {}).into_token_vec()); 62 | self.program.extend(self.machine.absolute()); 63 | self.program.extend(self.machine.program_begin()); 64 | self.program.extend(self.machine.absolute()); 65 | } 66 | 67 | fn end(&mut self) { 68 | self.program.extend(self.machine.tool_off()); 69 | self.program.extend(self.machine.absolute()); 70 | self.program.extend(self.machine.program_end()); 71 | } 72 | 73 | fn comment(&mut self, comment: String) { 74 | self.program.push(Token::Comment { 75 | is_inline: false, 76 | inner: Cow::Owned(comment), 77 | }); 78 | } 79 | 80 | fn move_to(&mut self, to: Point) { 81 | self.tool_off(); 82 | self.program 83 | .append(&mut command!(RapidPositioning { X: to.x, Y: to.y }).into_token_vec()); 84 | } 85 | 86 | fn line_to(&mut self, to: Point) { 87 | self.tool_on(); 88 | self.program.append( 89 | &mut command!(LinearInterpolation { 90 | X: to.x, 91 | Y: to.y, 92 | F: self.feedrate, 93 | }) 94 | .into_token_vec(), 95 | ); 96 | } 97 | 98 | fn arc(&mut self, svg_arc: SvgArc) { 99 | if svg_arc.is_straight_line() { 100 | self.line_to(svg_arc.to); 101 | return; 102 | } 103 | 104 | self.tool_on(); 105 | 106 | if self 107 | .machine 108 | .supported_functionality() 109 | .circular_interpolation 110 | { 111 | FlattenWithArcs::flattened(&svg_arc, self.tolerance) 112 | .into_iter() 113 | .for_each(|segment| match segment { 114 | ArcOrLineSegment::Arc(arc) => { 115 | self.program.append(&mut self.circular_interpolation(arc)) 116 | } 117 | ArcOrLineSegment::Line(line) => { 118 | self.line_to(line.to); 119 | } 120 | }); 121 | } else { 122 | svg_arc 123 | .to_arc() 124 | .flattened(self.tolerance) 125 | .for_each(|point| self.line_to(point)); 126 | }; 127 | } 128 | 129 | fn cubic_bezier(&mut self, cbs: CubicBezierSegment) { 130 | self.tool_on(); 131 | 132 | if self 133 | .machine 134 | .supported_functionality() 135 | .circular_interpolation 136 | { 137 | FlattenWithArcs::::flattened(&cbs, self.tolerance) 138 | .into_iter() 139 | .for_each(|segment| match segment { 140 | ArcOrLineSegment::Arc(arc) => { 141 | self.program.append(&mut self.circular_interpolation(arc)) 142 | } 143 | ArcOrLineSegment::Line(line) => self.line_to(line.to), 144 | }); 145 | } else { 146 | cbs.flattened(self.tolerance) 147 | .for_each(|point| self.line_to(point)); 148 | }; 149 | } 150 | 151 | fn quadratic_bezier(&mut self, qbs: QuadraticBezierSegment) { 152 | self.cubic_bezier(qbs.to_cubic()); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /lib/src/turtle/mod.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | 3 | use lyon_geom::{ 4 | euclid::{default::Transform2D, Angle}, 5 | point, vector, ArcFlags, CubicBezierSegment, Point, QuadraticBezierSegment, SvgArc, Vector, 6 | }; 7 | 8 | use crate::arc::Transformed; 9 | 10 | mod dpi; 11 | mod g_code; 12 | mod preprocess; 13 | pub use self::dpi::DpiConvertingTurtle; 14 | pub use self::g_code::GCodeTurtle; 15 | pub use self::preprocess::PreprocessTurtle; 16 | 17 | /// Abstraction for drawing paths based on [Turtle graphics](https://en.wikipedia.org/wiki/Turtle_graphics) 18 | pub trait Turtle: Debug { 19 | fn begin(&mut self); 20 | fn end(&mut self); 21 | fn comment(&mut self, comment: String); 22 | fn move_to(&mut self, to: Point); 23 | fn line_to(&mut self, to: Point); 24 | fn arc(&mut self, svg_arc: SvgArc); 25 | fn cubic_bezier(&mut self, cbs: CubicBezierSegment); 26 | fn quadratic_bezier(&mut self, qbs: QuadraticBezierSegment); 27 | } 28 | 29 | /// Wrapper for [Turtle] that handles transforms, position, offsets, etc. See https://www.w3.org/TR/SVG/paths.html 30 | #[derive(Debug)] 31 | pub struct Terrarium { 32 | pub turtle: T, 33 | current_position: Point, 34 | initial_position: Point, 35 | current_transform: Transform2D, 36 | pub transform_stack: Vec>, 37 | previous_quadratic_control: Option>, 38 | previous_cubic_control: Option>, 39 | } 40 | 41 | impl Terrarium { 42 | /// Create a turtle at the origin with no transform 43 | pub fn new(turtle: T) -> Self { 44 | Self { 45 | turtle, 46 | current_position: Point::zero(), 47 | initial_position: Point::zero(), 48 | current_transform: Transform2D::identity(), 49 | transform_stack: vec![], 50 | previous_quadratic_control: None, 51 | previous_cubic_control: None, 52 | } 53 | } 54 | 55 | /// Move the turtle to the given absolute/relative coordinates in the current transform 56 | /// https://www.w3.org/TR/SVG/paths.html#PathDataMovetoCommands 57 | pub fn move_to(&mut self, abs: bool, x: X, y: Y) 58 | where 59 | X: Into>, 60 | Y: Into>, 61 | { 62 | let inverse_transform = self 63 | .current_transform 64 | .inverse() 65 | .expect("transform is invertible"); 66 | let original_current_position = inverse_transform.transform_point(self.current_position); 67 | let x = x 68 | .into() 69 | .map(|x| { 70 | if abs { 71 | x 72 | } else { 73 | original_current_position.x + x 74 | } 75 | }) 76 | .unwrap_or(original_current_position.x); 77 | let y = y 78 | .into() 79 | .map(|y| { 80 | if abs { 81 | y 82 | } else { 83 | original_current_position.y + y 84 | } 85 | }) 86 | .unwrap_or(original_current_position.y); 87 | 88 | let to = self.current_transform.transform_point(point(x, y)); 89 | self.current_position = to; 90 | self.initial_position = to; 91 | self.previous_quadratic_control = None; 92 | self.previous_cubic_control = None; 93 | self.turtle.move_to(to); 94 | } 95 | 96 | /// Close an SVG path, cutting back to its initial position 97 | /// https://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand 98 | pub fn close(&mut self) { 99 | // See https://www.w3.org/TR/SVG/paths.html#Segment-CompletingClosePath 100 | // which could result in a G91 G1 X0 Y0 101 | if !(self.current_position - self.initial_position) 102 | .abs() 103 | .lower_than(vector(f64::EPSILON, f64::EPSILON)) 104 | .all() 105 | { 106 | self.turtle.line_to(self.initial_position); 107 | } 108 | self.current_position = self.initial_position; 109 | self.previous_quadratic_control = None; 110 | self.previous_cubic_control = None; 111 | } 112 | 113 | /// Draw a line from the current position in the current transform to the specified position 114 | /// https://www.w3.org/TR/SVG/paths.html#PathDataLinetoCommands 115 | pub fn line(&mut self, abs: bool, x: X, y: Y) 116 | where 117 | X: Into>, 118 | Y: Into>, 119 | { 120 | let inverse_transform = self 121 | .current_transform 122 | .inverse() 123 | .expect("transform is invertible"); 124 | let original_current_position = inverse_transform.transform_point(self.current_position); 125 | let x = x 126 | .into() 127 | .map(|x| { 128 | if abs { 129 | x 130 | } else { 131 | original_current_position.x + x 132 | } 133 | }) 134 | .unwrap_or(original_current_position.x); 135 | let y = y 136 | .into() 137 | .map(|y| { 138 | if abs { 139 | y 140 | } else { 141 | original_current_position.y + y 142 | } 143 | }) 144 | .unwrap_or(original_current_position.y); 145 | 146 | let to = self.current_transform.transform_point(point(x, y)); 147 | self.current_position = to; 148 | self.previous_quadratic_control = None; 149 | self.previous_cubic_control = None; 150 | 151 | self.turtle.line_to(to); 152 | } 153 | 154 | /// Draw a cubic curve from the current point to (x, y) with specified control points (x1, y1) and (x2, y2) 155 | /// https://www.w3.org/TR/SVG/paths.html#PathDataCubicBezierCommands 156 | pub fn cubic_bezier( 157 | &mut self, 158 | abs: bool, 159 | mut ctrl1: Point, 160 | mut ctrl2: Point, 161 | mut to: Point, 162 | ) { 163 | let from = self.current_position; 164 | if !abs { 165 | let inverse_transform = self.current_transform.inverse().unwrap(); 166 | let original_current_position = inverse_transform.transform_point(from); 167 | ctrl1 = original_current_position + ctrl1.to_vector(); 168 | ctrl2 = original_current_position + ctrl2.to_vector(); 169 | to = original_current_position + to.to_vector(); 170 | } 171 | ctrl1 = self.current_transform.transform_point(ctrl1); 172 | ctrl2 = self.current_transform.transform_point(ctrl2); 173 | to = self.current_transform.transform_point(to); 174 | 175 | let cbs = lyon_geom::CubicBezierSegment { 176 | from, 177 | ctrl1, 178 | ctrl2, 179 | to, 180 | }; 181 | 182 | self.current_position = cbs.to; 183 | 184 | // See https://www.w3.org/TR/SVG/paths.html#ReflectedControlPoints 185 | self.previous_cubic_control = Some(point( 186 | 2. * self.current_position.x - cbs.ctrl2.x, 187 | 2. * self.current_position.y - cbs.ctrl2.y, 188 | )); 189 | self.previous_quadratic_control = None; 190 | 191 | self.turtle.cubic_bezier(cbs); 192 | } 193 | 194 | /// Draw a shorthand/smooth cubic bezier segment, where the first control point was already given 195 | /// https://www.w3.org/TR/SVG/paths.html#PathDataCubicBezierCommands 196 | pub fn smooth_cubic_bezier(&mut self, abs: bool, mut ctrl2: Point, mut to: Point) { 197 | let from = self.current_position; 198 | let ctrl1 = self.previous_cubic_control.unwrap_or(self.current_position); 199 | if !abs { 200 | let inverse_transform = self 201 | .current_transform 202 | .inverse() 203 | .expect("transform is invertible"); 204 | let original_current_position = inverse_transform.transform_point(from); 205 | ctrl2 = original_current_position + ctrl2.to_vector(); 206 | to = original_current_position + to.to_vector(); 207 | } 208 | ctrl2 = self.current_transform.transform_point(ctrl2); 209 | to = self.current_transform.transform_point(to); 210 | 211 | let cbs = lyon_geom::CubicBezierSegment { 212 | from, 213 | ctrl1, 214 | ctrl2, 215 | to, 216 | }; 217 | 218 | self.current_position = cbs.to; 219 | 220 | // See https://www.w3.org/TR/SVG/paths.html#ReflectedControlPoints 221 | self.previous_cubic_control = Some(point( 222 | 2. * self.current_position.x - cbs.ctrl2.x, 223 | 2. * self.current_position.y - cbs.ctrl2.y, 224 | )); 225 | self.previous_quadratic_control = None; 226 | 227 | self.turtle.cubic_bezier(cbs); 228 | } 229 | 230 | /// Draw a shorthand/smooth cubic bezier segment, where the control point was already given 231 | /// https://www.w3.org/TR/SVG/paths.html#PathDataQuadraticBezierCommands 232 | pub fn smooth_quadratic_bezier(&mut self, abs: bool, mut to: Point) { 233 | let from = self.current_position; 234 | let ctrl = self 235 | .previous_quadratic_control 236 | .unwrap_or(self.current_position); 237 | if !abs { 238 | let inverse_transform = self 239 | .current_transform 240 | .inverse() 241 | .expect("transform is invertible"); 242 | let original_current_position = inverse_transform.transform_point(from); 243 | to = original_current_position + to.to_vector(); 244 | } 245 | to = self.current_transform.transform_point(to); 246 | 247 | let qbs = QuadraticBezierSegment { from, ctrl, to }; 248 | 249 | self.current_position = qbs.to; 250 | 251 | // See https://www.w3.org/TR/SVG/paths.html#ReflectedControlPoints 252 | self.previous_quadratic_control = Some(point( 253 | 2. * self.current_position.x - qbs.ctrl.x, 254 | 2. * self.current_position.y - qbs.ctrl.y, 255 | )); 256 | self.previous_cubic_control = None; 257 | 258 | self.turtle.quadratic_bezier(qbs); 259 | } 260 | 261 | /// Draw a quadratic bezier segment 262 | /// https://www.w3.org/TR/SVG/paths.html#PathDataQuadraticBezierCommands 263 | pub fn quadratic_bezier(&mut self, abs: bool, mut ctrl: Point, mut to: Point) { 264 | let from = self.current_position; 265 | if !abs { 266 | let inverse_transform = self 267 | .current_transform 268 | .inverse() 269 | .expect("transform is invertible"); 270 | let original_current_position = inverse_transform.transform_point(from); 271 | to = original_current_position + to.to_vector(); 272 | ctrl = original_current_position + ctrl.to_vector(); 273 | } 274 | ctrl = self.current_transform.transform_point(ctrl); 275 | to = self.current_transform.transform_point(to); 276 | 277 | let qbs = QuadraticBezierSegment { from, ctrl, to }; 278 | 279 | self.current_position = qbs.to; 280 | 281 | // See https://www.w3.org/TR/SVG/paths.html#ReflectedControlPoints 282 | self.previous_quadratic_control = Some(point( 283 | 2. * self.current_position.x - qbs.ctrl.x, 284 | 2. * self.current_position.y - qbs.ctrl.y, 285 | )); 286 | self.previous_cubic_control = None; 287 | 288 | self.turtle.quadratic_bezier(qbs); 289 | } 290 | 291 | /// Draw an elliptical arc segment 292 | /// https://www.w3.org/TR/SVG/paths.html#PathDataEllipticalArcCommands 293 | pub fn elliptical( 294 | &mut self, 295 | abs: bool, 296 | radii: Vector, 297 | x_rotation: Angle, 298 | flags: ArcFlags, 299 | mut to: Point, 300 | ) { 301 | let from = self 302 | .current_transform 303 | .inverse() 304 | .expect("transform is invertible") 305 | .transform_point(self.current_position); 306 | 307 | if !abs { 308 | to = from + to.to_vector() 309 | } 310 | let svg_arc = SvgArc { 311 | from, 312 | to, 313 | radii, 314 | x_rotation, 315 | flags, 316 | } 317 | .transformed(&self.current_transform); 318 | 319 | self.current_position = svg_arc.to; 320 | self.previous_quadratic_control = None; 321 | self.previous_cubic_control = None; 322 | 323 | self.turtle.arc(svg_arc); 324 | } 325 | 326 | /// Push a generic transform onto the stack 327 | /// Could be any valid CSS transform https://drafts.csswg.org/css-transforms-1/#typedef-transform-function 328 | /// https://www.w3.org/TR/SVG/coords.html#InterfaceSVGTransform 329 | pub fn push_transform(&mut self, trans: Transform2D) { 330 | self.transform_stack.push(self.current_transform); 331 | // https://stackoverflow.com/questions/18582935/the-applying-order-of-svg-transforms 332 | self.current_transform = trans.then(&self.current_transform); 333 | } 334 | 335 | /// Pop a generic transform off the stack, returning to the previous transform state 336 | /// This means that most recent transform went out of scope 337 | pub fn pop_transform(&mut self) { 338 | self.current_transform = self 339 | .transform_stack 340 | .pop() 341 | .expect("pop only called when transforms remain"); 342 | } 343 | 344 | /// Reset the position of the turtle to the origin in the current transform stack 345 | /// Used for starting a new path 346 | pub fn reset(&mut self) { 347 | self.current_position = self.current_transform.transform_point(Point::zero()); 348 | self.initial_position = self.current_position; 349 | self.previous_quadratic_control = None; 350 | self.previous_cubic_control = None; 351 | } 352 | } 353 | -------------------------------------------------------------------------------- /lib/src/turtle/preprocess.rs: -------------------------------------------------------------------------------- 1 | use lyon_geom::{Box2D, CubicBezierSegment, Point, QuadraticBezierSegment, SvgArc}; 2 | 3 | use super::Turtle; 4 | 5 | /// Generates a bounding box for all draw operations, used to properly apply [crate::ConversionConfig::origin] 6 | #[derive(Debug, Default)] 7 | pub struct PreprocessTurtle { 8 | pub bounding_box: Box2D, 9 | } 10 | 11 | impl Turtle for PreprocessTurtle { 12 | fn begin(&mut self) {} 13 | 14 | fn end(&mut self) {} 15 | 16 | fn comment(&mut self, _comment: String) {} 17 | 18 | fn move_to(&mut self, to: Point) { 19 | self.bounding_box = Box2D::from_points([self.bounding_box.min, self.bounding_box.max, to]); 20 | } 21 | 22 | fn line_to(&mut self, to: Point) { 23 | self.bounding_box = Box2D::from_points([self.bounding_box.min, self.bounding_box.max, to]); 24 | } 25 | 26 | fn arc(&mut self, svg_arc: SvgArc) { 27 | if svg_arc.is_straight_line() { 28 | self.line_to(svg_arc.to); 29 | } else { 30 | self.bounding_box = self.bounding_box.union(&svg_arc.to_arc().bounding_box()); 31 | } 32 | } 33 | 34 | fn cubic_bezier(&mut self, cbs: CubicBezierSegment) { 35 | self.bounding_box = self.bounding_box.union(&cbs.bounding_box()); 36 | } 37 | 38 | fn quadratic_bezier(&mut self, qbs: QuadraticBezierSegment) { 39 | self.bounding_box = self.bounding_box.union(&qbs.bounding_box()); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/tests/circular_interpolation.gcode: -------------------------------------------------------------------------------- 1 | G21 2 | G90;svg#svg8 > g#layer1 > path 3 | G0 X1 Y9.000000000000002 4 | G1 X1.108112220817491 Y8.887981529182511 F300 5 | G1 X1.2153787137015988 Y8.768996286298401 F300 6 | G2 X1.4239916039361313 Y8.51350839606387 R5.11981430218831 F300 7 | G2 X1.7938566201357353 Y7.956143379864265 R4.835393156461376 F300 8 | G2 X1.9415770991666748 Y7.667797900833326 R3.813463723120899 F300 9 | G1 X2.0031742836480873 Y7.524169466351913 F300 10 | G1 X2.0554684608622846 Y7.382031539137716 F300 11 | G1 X2.0976139028758833 Y7.242229847124118 F300 12 | G1 X2.128764881755499 Y7.105610118244502 F300 13 | G1 X2.148075669567749 Y6.973018080432251 F300 14 | G1 X2.1547005383792515 Y6.8452994616207485 F300 15 | G2 X2.1397549853513067 Y6.668858900493001 R1.0008154063555685 F300 16 | G2 X2.0931488160479113 Y6.50733213111376 R0.7841189700439164 F300 17 | G2 X2.0122277651397247 Y6.363373418812364 R0.6809578083609312 F300 18 | G2 X1.8943375672974065 Y6.239637028918155 R0.6907022975872513 F300 19 | G1 X1.8206995803605288 Y6.186181662789164 F300 20 | G1 X1.7368239571916155 Y6.138777226760473 F300 21 | G1 X1.6423789146244991 Y6.097755503998249 F300 22 | G1 X1.5370326694930114 Y6.06344827766866 F300 23 | G1 X1.4204534386309855 Y6.036187330937873 F300 24 | G1 X1.2923094388722531 Y6.016304446972055 F300 25 | G1 X1.1522688870506472 Y6.004131408937375 F300 26 | G1 X1 Y6 F300;svg#svg8 > g#layer1 > path 27 | G0 X1 Y5 28 | G3 X1.1025 Y4.9125 R0.81697048500483 F300 29 | G3 X1.2100000000000002 Y4.8500000000000005 R0.6432262761687367 F300 30 | G3 X1.4400000000000004 Y4.800000000000001 R0.5559102124679794 F300 31 | G3 X1.5625000000000004 Y4.8125 R0.6248177401751354 F300 32 | G3 X1.6900000000000004 Y4.8500000000000005 R0.7837286053113949 F300 33 | G3 X1.8225000000000007 Y4.9125 R1.0478526379133146 F300 34 | G1 X1.8906250000000004 Y4.953125 F300 35 | G1 X1.9600000000000004 Y5 F300 36 | G1 X2.0306250000000006 Y5.053125 F300 37 | G1 X2.1025000000000005 Y5.1125 F300 38 | G1 X2.2500000000000004 Y5.25 F300 39 | G1 X2.4025000000000007 Y5.4125 F300 40 | G1 X2.5600000000000005 Y5.6000000000000005 F300 41 | G1 X2.7225 Y5.8125 F300 42 | G1 X2.89 Y6.050000000000001 F300 43 | G1 X3.0625 Y6.3125 F300 44 | G1 X3.24 Y6.6 F300 45 | G1 X3.4225000000000003 Y6.9125 F300 46 | G1 X3.61 Y7.25 F300 47 | G1 X3.8025 Y7.612500000000001 F300 48 | G1 X4 Y8 F300;svg#svg8 > g#layer1 > path 49 | G0 X7 Y9.000000000000002 50 | G2 X4 Y7 R2 F300;svg#svg8 > g#layer1 > path 51 | G0 X1 Y4 52 | G3 X1.3978250318857068 Y3.9681566775364194 R14.876279225455013 F300 53 | G3 X1.8079854766165129 Y3.946104969409453 R16.0102497621791 F300 54 | G3 X2.6441213485505637 Y3.9326074767527732 R16.909229034035167 F300 55 | G3 X3.0621524555590525 Y3.9412866210908293 R16.90230762247016 F300 56 | G3 X3.4747627283416866 Y3.960173174008357 R16.288778739876506 F300 57 | G3 X3.8778904223049264 Y3.989077848065841 R15.268674536713663 F300 58 | G3 X4.267739055575385 Y4.02772502106658 R13.912776670406565 F300 59 | G3 X4.456063565462741 Y4.05053255623688 R12.7392686842309 F300 60 | G3 X4.639493613745778 Y4.07558915534093 R11.886079551107239 F300 61 | G3 X4.991272344944991 Y4.132404128397536 R10.514058093113949 F300 62 | G3 X5.158404879648202 Y4.163979556260946 R9.124219184346577 F300 63 | G3 X5.3196158341446305 Y4.1976687890429 R8.191555645389325 F300 64 | G3 X5.473047737159728 Y4.233066718061759 R7.337969963308995 F300 65 | G3 X5.619378607044066 Y4.270296443620541 R6.491816509049089 F300 66 | G3 X5.759434336469871 Y4.3096157242417785 R5.617604037184888 F300 67 | G3 X5.89156889005452 Y4.350612797575678 R4.8229014338692195 F300 68 | G3 X6.015159320696339 Y4.393078758403584 R4.1038173319457085 F300 69 | G3 X6.130250130628523 Y4.437012248818059 R3.443844814109491 F300 70 | G3 X6.236127567489381 Y4.482110645720402 R2.846139953128124 F300 71 | G3 X6.333048149496381 Y4.528450611510089 R2.315235289060916 F300 72 | G3 X6.420847578856361 Y4.575960379957448 R1.8524832610126942 F300 73 | G3 X6.499231905888225 Y4.624488391609512 R1.4581571425307318 F300 74 | G3 X6.5681780503855265 Y4.67404690935573 R1.1299709150746557 F300 75 | G3 X6.627304768088372 Y4.72439218532203 R0.8643192973818252 F300 76 | G3 X6.676477534957247 Y4.775412220263576 R0.6564208404086177 F300 77 | G3 X6.715565729915638 Y4.826974547513844 R0.5001673727276008 F300 78 | G3 X6.763123756205191 Y4.931181763899067 R0.3503864926825308 F300 79 | G3 X6.769570018062911 Y5.036030575377597 R0.2731563884225746 F300 80 | G3 X6.734853479826907 Y5.140538119747195 R0.31275892910822434 F300 81 | G3 X6.702124639930762 Y5.192377872306782 R0.42576792723437357 F300 82 | G3 X6.659260232570231 Y5.243740866117131 R0.5529909059535231 F300 83 | G3 X6.606369791353455 Y5.294504519810424 R0.7277546960808061 F300 84 | G3 X6.543572631504415 Y5.344557674098939 R0.9564889590926604 F300 85 | G3 X6.471071953703971 Y5.393746987200066 R1.2449378069368768 F300 86 | G3 X6.389003763438705 Y5.441987644846052 R1.5977988425822132 F300 87 | G3 X6.297869764869878 Y5.48901589929219 R2.0177297216232226 F300 88 | G3 X6.197643371825973 Y5.534873411825964 R2.505678742970561 F300 89 | G3 X6.088496912806489 Y5.579476569029177 R3.0612941421708517 F300 90 | G3 X5.9707490692227445 Y5.622690760106721 R3.6808378656331633 F300 91 | G3 X5.844081777846991 Y5.6646012298811375 R4.360700879834628 F300 92 | G3 X5.709324849702927 Y5.704903969125077 R5.102368555492607 F300 93 | G3 X5.567733136231686 Y5.743261945591492 R5.934854086199881 F300 94 | G3 X5.41881423644711 Y5.779844663803308 R6.7989406116672715 F300 95 | G3 X5.261537116730818 Y5.814858596836476 R7.631495938415551 F300 96 | G3 X5.097548826035376 Y5.847892235945213 R8.528687451066965 F300 97 | G3 X4.927684984927917 Y5.878789844401396 R9.47189931809548 F300 98 | G3 X4.751951025312619 Y5.907562547705369 R10.41331306147184 F300 99 | G3 X4.38611036368718 Y5.958272783112689 R11.758269557309466 F300 100 | G3 X4.196158506291358 Y5.980211912297403 R13.04696263833914 F300 101 | G3 X4.002046461219029 Y5.999807994941855 R13.83135270472203 F300;svg#svg8 > g#layer1 > path 102 | G0 X1 Y2 103 | G3 X2.707106781186548 Y1.292893218813453 R1 F300 104 | G3 X2 Y3 R1 F300;svg#svg8 > g#layer1 > path 105 | G0 X7 Y2 106 | G3 X7.1547623639494935 Y2.1091244378037 R1.306422682229289 F300 107 | G3 X7.282116680361382 Y2.2321077959060935 R1.0856892593757272 F300 108 | G3 X7.380081728935076 Y2.3667141376084353 R0.9044562895494174 F300 109 | G3 X7.446435873469992 Y2.5098930035038514 R0.7704193428708718 F300 110 | G3 X7.479017753360534 Y2.809177694882107 R0.680342766557012 F300 111 | G3 X7.4444183848622485 Y2.9583778658834605 R0.7026557460445476 F300 112 | G3 X7.376679929974098 Y3.1027417096732965 R0.7922831187698388 F300 113 | G3 X7.27738935116772 Y3.2389398933696345 R0.9367339275415363 F300 114 | G3 X7.148774650157436 Y3.3639502152420424 R1.1252717326202617 F300 115 | G3 X6.99367314829175 Y3.474987144669765 R1.3540462899657089 F300 116 | G3 X6.8156574074701295 Y3.569470086099792 R1.5910816869892634 F300 117 | G3 X6.619051181089787 Y3.6451637975031077 R1.8311381180572766 F300 118 | G3 X6.408070496157393 Y3.700498981631147 R2.029514752819084 F300 119 | G3 X5.961879008015643 Y3.7455746745355167 R2.2066717517537127 F300 120 | G3 X5.517743277946176 Y3.700492471605204 R2.1777193869978095 F300 121 | G3 X5.3090677611712405 Y3.645118426598611 R1.965944882417258 F300 122 | G3 X5.115776986833169 Y3.5694123856790956 R1.7512568735637628 F300 123 | G3 X4.9418087103131425 Y3.4748620651716124 R1.5091941602827261 F300 124 | G3 X4.791506394813446 Y3.3637428366487176 R1.2687625765931412 F300 125 | G3 X4.668454022834456 Y3.238783962605633 R1.0557113303482604 F300 126 | G3 X4.575183183400515 Y3.10265328924061 R0.880563055014622 F300 127 | G3 X4.51375179846847 Y2.9582561971686174 R0.7547083052302904 F300 128 | G3 X4.485684717992188 Y2.80902758022418 R0.6846718778046991 F300 129 | G3 X4.53138812342675 Y2.509739067236653 R0.6913082436962443 F300 130 | G3 X4.604045213168648 Y2.366527573981746 R0.8110589553537699 F300 131 | G3 X4.707904549977265 Y2.231952183082984 R0.9631939268963245 F300 132 | G3 X4.84075671570839 Y2.108903718054256 R1.1569244804764252 F300 133 | G3 X4.9995190119654165 Y2.0002834466817996 R1.390677252818168 F300 134 | G3 X5.221523434813826 Y1.8914584411737698 R1.654186000882392 F300 135 | G3 X5.468658098289328 Y1.8111179864415279 R1.9290219324681466 F300 136 | G3 X5.732969365528167 Y1.7618093869229279 R2.13248793378987 F300 137 | G3 X6.005616291542057 Y1.745193979288478 R2.236659915628876 F300 138 | G3 X6.27755904371876 Y1.7618268511523927 R2.2186459122849396 F300 139 | G3 X6.539734110392067 Y1.811157851163688 R2.082787785187799 F300 140 | G3 X6.783431474957998 Y1.8915494164168738 R1.8563026877494544 F300 141 | G3 X7.0005421915233885 Y2.0003279637277087 R1.5688254782735902 F300 142 | -------------------------------------------------------------------------------- /lib/tests/circular_interpolation.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 39 | 45 | 46 | 48 | 49 | 51 | image/svg+xml 52 | 54 | 55 | 56 | 57 | 58 | 62 | 65 | 68 | 71 | 74 | 77 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /lib/tests/shapes.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 36 | 38 | 42 | 51 | 61 | 68 | 76 | 82 | 87 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /lib/tests/smooth_curves.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/tests/square.gcode: -------------------------------------------------------------------------------- 1 | G21 2 | G90;svg#svg8 > g#layer1 > path#path838 3 | G0 X1 Y9 4 | G1 X9 Y9 F300 5 | G1 X9 Y1 F300 6 | G1 X1 Y1 F300 7 | G1 X1 Y9 F300;svg#svg8 > g#layer1 > path#path832 8 | G0 X8 Y2.5 9 | G1 X7.992016 Y2.4110041813117045 F300 10 | G1 X7.968318977024 Y2.324850533089038 F300 11 | G1 X7.929665719197762 Y2.24429045824164 F300 12 | G1 X7.877290656963223 Y2.171896723319837 F300 13 | G1 X7.812866440307907 Y2.109981294642092 F300 14 | G1 X7.738450521014919 Y2.060521503338657 F300 15 | G1 X7.656419445882797 Y2.0250968973045986 F300 16 | G1 X7.569392959326962 Y2.0048387967582206 F300 17 | G1 X7.5 Y2 F300 18 | G1 X7.4110041813117045 Y2.007984 F300 19 | G1 X7.324850533089037 Y2.031681022976 F300 20 | G1 X7.24429045824164 Y2.0703342808022387 F300 21 | G1 X7.171896723319837 Y2.1227093430367767 F300 22 | G1 X7.1099812946420915 Y2.187133559692093 F300 23 | G1 X7.060521503338657 Y2.261549478985082 F300 24 | G1 X7.025096897304599 Y2.3435805541172035 F300 25 | G1 X7.004838796758221 Y2.430607040673038 F300 26 | G1 X7 Y2.5 F300 27 | G1 X7.007984 Y2.588995818688296 F300 28 | G1 X7.0316810229760005 Y2.675149466910962 F300 29 | G1 X7.070334280802238 Y2.75570954175836 F300 30 | G1 X7.122709343036777 Y2.828103276680163 F300 31 | G1 X7.187133559692093 Y2.890018705357908 F300 32 | G1 X7.261549478985082 Y2.939478496661343 F300 33 | G1 X7.343580554117204 Y2.9749031026954014 F300 34 | G1 X7.4306070406730385 Y2.9951612032417794 F300 35 | G1 X7.5 Y3 F300 36 | G1 X7.5889958186882955 Y2.992016 F300 37 | G1 X7.675149466910963 Y2.968318977024 F300 38 | G1 X7.75570954175836 Y2.9296657191977613 F300 39 | G1 X7.828103276680163 Y2.8772906569632233 F300 40 | G1 X7.890018705357908 Y2.812866440307907 F300 41 | G1 X7.9394784966613425 Y2.738450521014918 F300 42 | G1 X7.974903102695401 Y2.6564194458827965 F300 43 | G1 X7.995161203241779 Y2.569392959326962 F300 44 | G1 X8 Y2.5 F300 45 | -------------------------------------------------------------------------------- /lib/tests/square.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 39 | 45 | 46 | 48 | 49 | 51 | image/svg+xml 52 | 54 | 55 | 56 | 57 | 58 | 62 | 66 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /lib/tests/square_dimensionless.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 17 | 37 | 43 | 44 | 46 | 47 | 49 | image/svg+xml 50 | 52 | 53 | 54 | 55 | 56 | 60 | 64 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /lib/tests/square_transformed.gcode: -------------------------------------------------------------------------------- 1 | G21 2 | G90;svg#svg8 > g#layer1 > path#path838 3 | G0 X1 Y0.9999999999999999 4 | G1 X1.0000000000000004 Y9 F300 5 | G1 X9 Y9 F300 6 | G1 X9 Y0.9999999999999994 F300 7 | G1 X1 Y0.9999999999999999 F300;svg#svg8 > g#layer1 > path#path832 8 | G0 X7.500000000000001 Y7.999999999999999 9 | G1 X7.588995818688296 Y7.992015999999999 F300 10 | G1 X7.675149466910963 Y7.968318977023999 F300 11 | G1 X7.75570954175836 Y7.92966571919776 F300 12 | G1 X7.828103276680164 Y7.877290656963222 F300 13 | G1 X7.8900187053579085 Y7.812866440307906 F300 14 | G1 X7.939478496661343 Y7.738450521014917 F300 15 | G1 X7.974903102695402 Y7.656419445882795 F300 16 | G1 X7.995161203241779 Y7.569392959326961 F300 17 | G1 X8 Y7.499999999999999 F300 18 | G1 X7.992016 Y7.411004181311704 F300 19 | G1 X7.968318977024 Y7.324850533089037 F300 20 | G1 X7.929665719197762 Y7.24429045824164 F300 21 | G1 X7.877290656963224 Y7.171896723319836 F300 22 | G1 X7.812866440307907 Y7.1099812946420915 F300 23 | G1 X7.738450521014919 Y7.060521503338657 F300 24 | G1 X7.656419445882797 Y7.025096897304598 F300 25 | G1 X7.569392959326962 Y7.00483879675822 F300 26 | G1 X7.5 Y6.999999999999999 F300 27 | G1 X7.411004181311704 Y7.007984 F300 28 | G1 X7.324850533089037 Y7.0316810229760005 F300 29 | G1 X7.24429045824164 Y7.070334280802239 F300 30 | G1 X7.171896723319836 Y7.122709343036777 F300 31 | G1 X7.1099812946420915 Y7.187133559692093 F300 32 | G1 X7.060521503338657 Y7.261549478985082 F300 33 | G1 X7.025096897304598 Y7.343580554117204 F300 34 | G1 X7.004838796758221 Y7.4306070406730385 F300 35 | G1 X7 Y7.500000000000001 F300 36 | G1 X7.007984000000001 Y7.588995818688296 F300 37 | G1 X7.031681022976001 Y7.6751494669109634 F300 38 | G1 X7.07033428080224 Y7.755709541758361 F300 39 | G1 X7.122709343036778 Y7.828103276680164 F300 40 | G1 X7.187133559692095 Y7.8900187053579085 F300 41 | G1 X7.261549478985083 Y7.939478496661343 F300 42 | G1 X7.343580554117205 Y7.974903102695402 F300 43 | G1 X7.430607040673039 Y7.995161203241779 F300 44 | G1 X7.500000000000002 Y8 F300 45 | -------------------------------------------------------------------------------- /lib/tests/square_transformed.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 39 | 45 | 46 | 48 | 49 | 51 | image/svg+xml 52 | 54 | 55 | 56 | 57 | 58 | 63 | 67 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /lib/tests/square_transformed_nested.gcode: -------------------------------------------------------------------------------- 1 | G21 2 | G90;svg#svg8 > g#layer1 > g > g > path#path838 3 | G0 X1 Y0.9999999999999999 4 | G1 X1.0000000000000004 Y9 F300 5 | G1 X9 Y9 F300 6 | G1 X9 Y0.9999999999999994 F300 7 | G1 X1 Y0.9999999999999999 F300;svg#svg8 > g#layer1 > g > g > path#path832 8 | G0 X7.500000000000001 Y7.999999999999999 9 | G1 X7.588995818688296 Y7.992015999999999 F300 10 | G1 X7.675149466910963 Y7.968318977023999 F300 11 | G1 X7.75570954175836 Y7.92966571919776 F300 12 | G1 X7.828103276680164 Y7.877290656963222 F300 13 | G1 X7.8900187053579085 Y7.812866440307906 F300 14 | G1 X7.939478496661343 Y7.738450521014917 F300 15 | G1 X7.974903102695402 Y7.656419445882795 F300 16 | G1 X7.995161203241779 Y7.569392959326961 F300 17 | G1 X8 Y7.499999999999999 F300 18 | G1 X7.992016 Y7.411004181311704 F300 19 | G1 X7.968318977024 Y7.324850533089037 F300 20 | G1 X7.929665719197762 Y7.24429045824164 F300 21 | G1 X7.877290656963224 Y7.171896723319836 F300 22 | G1 X7.812866440307907 Y7.1099812946420915 F300 23 | G1 X7.738450521014919 Y7.060521503338657 F300 24 | G1 X7.656419445882797 Y7.025096897304598 F300 25 | G1 X7.569392959326962 Y7.00483879675822 F300 26 | G1 X7.5 Y6.999999999999999 F300 27 | G1 X7.411004181311704 Y7.007984 F300 28 | G1 X7.324850533089037 Y7.0316810229760005 F300 29 | G1 X7.24429045824164 Y7.070334280802239 F300 30 | G1 X7.171896723319836 Y7.122709343036777 F300 31 | G1 X7.1099812946420915 Y7.187133559692093 F300 32 | G1 X7.060521503338657 Y7.261549478985082 F300 33 | G1 X7.025096897304598 Y7.343580554117204 F300 34 | G1 X7.004838796758221 Y7.4306070406730385 F300 35 | G1 X7 Y7.500000000000001 F300 36 | G1 X7.007984000000001 Y7.588995818688296 F300 37 | G1 X7.031681022976001 Y7.6751494669109634 F300 38 | G1 X7.07033428080224 Y7.755709541758361 F300 39 | G1 X7.122709343036778 Y7.828103276680164 F300 40 | G1 X7.187133559692095 Y7.8900187053579085 F300 41 | G1 X7.261549478985083 Y7.939478496661343 F300 42 | G1 X7.343580554117205 Y7.974903102695402 F300 43 | G1 X7.430607040673039 Y7.995161203241779 F300 44 | G1 X7.500000000000002 Y8 F300 45 | -------------------------------------------------------------------------------- /lib/tests/square_transformed_nested.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 39 | 45 | 46 | 48 | 49 | 51 | image/svg+xml 52 | 54 | 55 | 56 | 57 | 58 | 63 | 64 | 65 | 69 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /lib/tests/square_viewport.gcode: -------------------------------------------------------------------------------- 1 | G21 2 | G90;svg#svg8 > g#layer1 > path#path838 3 | G0 X1 Y9 4 | G1 X9 Y9 F300 5 | G1 X9 Y1 F300 6 | G1 X1 Y1 F300 7 | G1 X1 Y9 F300;svg#svg8 > g#layer1 > path#path832 8 | G0 X8 Y2.5 9 | G1 X7.992016 Y2.4110041813117045 F300 10 | G1 X7.968318977024 Y2.324850533089038 F300 11 | G1 X7.929665719197762 Y2.24429045824164 F300 12 | G1 X7.877290656963223 Y2.171896723319837 F300 13 | G1 X7.812866440307907 Y2.109981294642092 F300 14 | G1 X7.738450521014919 Y2.060521503338657 F300 15 | G1 X7.656419445882797 Y2.0250968973045986 F300 16 | G1 X7.569392959326962 Y2.0048387967582206 F300 17 | G1 X7.5 Y2 F300 18 | G1 X7.4110041813117045 Y2.007984 F300 19 | G1 X7.324850533089037 Y2.031681022976 F300 20 | G1 X7.24429045824164 Y2.0703342808022387 F300 21 | G1 X7.171896723319837 Y2.1227093430367767 F300 22 | G1 X7.1099812946420915 Y2.187133559692093 F300 23 | G1 X7.060521503338657 Y2.261549478985082 F300 24 | G1 X7.025096897304599 Y2.3435805541172035 F300 25 | G1 X7.004838796758221 Y2.430607040673038 F300 26 | G1 X7 Y2.5 F300 27 | G1 X7.007984 Y2.588995818688296 F300 28 | G1 X7.0316810229760005 Y2.675149466910962 F300 29 | G1 X7.070334280802238 Y2.75570954175836 F300 30 | G1 X7.122709343036777 Y2.828103276680163 F300 31 | G1 X7.187133559692093 Y2.890018705357908 F300 32 | G1 X7.261549478985082 Y2.939478496661343 F300 33 | G1 X7.343580554117204 Y2.9749031026954014 F300 34 | G1 X7.4306070406730385 Y2.9951612032417794 F300 35 | G1 X7.5 Y3 F300 36 | G1 X7.5889958186882955 Y2.992016 F300 37 | G1 X7.675149466910963 Y2.968318977024 F300 38 | G1 X7.75570954175836 Y2.9296657191977613 F300 39 | G1 X7.828103276680163 Y2.8772906569632233 F300 40 | G1 X7.890018705357908 Y2.812866440307907 F300 41 | G1 X7.9394784966613425 Y2.738450521014918 F300 42 | G1 X7.974903102695401 Y2.6564194458827965 F300 43 | G1 X7.995161203241779 Y2.569392959326962 F300 44 | G1 X8 Y2.5 F300 45 | -------------------------------------------------------------------------------- /lib/tests/square_viewport.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 40 | 46 | 47 | 49 | 50 | 52 | image/svg+xml 53 | 55 | 56 | 57 | 58 | 62 | 66 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /web/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "svg2gcode-web" 3 | version = "0.0.17" 4 | description = "Convert vector graphics to g-code for pen plotters, laser engravers, and other CNC machines" 5 | homepage = "https://sameer.github.io/svg2gcode/" 6 | authors.workspace = true 7 | edition.workspace = true 8 | repository.workspace = true 9 | license.workspace = true 10 | 11 | [dependencies] 12 | wasm-bindgen = "0.2" 13 | svg2gcode = { path = "../lib", version = "0.3.2", features = ["serde"] } 14 | roxmltree.workspace = true 15 | g-code.workspace = true 16 | codespan-reporting = "0.11" 17 | codespan = "0.11" 18 | serde = "1" 19 | paste = "1" 20 | log.workspace = true 21 | svgtypes.workspace = true 22 | serde_json.workspace = true 23 | thiserror = "1.0" 24 | zip = { version = "0.6", default-features = false } 25 | 26 | yew = { version = "0.21", features = ["csr"] } 27 | yewdux = "0.10" 28 | web-sys = { version = "0.3", features = [] } 29 | wasm-logger = "0.2" 30 | gloo-file = { version = "0.3", features = ["futures"] } 31 | gloo-timers = "0.3" 32 | base64 = "0.22" 33 | wasm-bindgen-futures = "0.4" 34 | js-sys = "0.3" 35 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | svg2gcode 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /web/src/forms/editors.rs: -------------------------------------------------------------------------------- 1 | use codespan_reporting::term::{emit, termcolor::NoColor, Config}; 2 | use g_code::parse::{into_diagnostic, snippet_parser}; 3 | use gloo_timers::callback::Timeout; 4 | use paste::paste; 5 | use web_sys::HtmlInputElement; 6 | use yew::prelude::*; 7 | use yewdux::functional::{use_store, use_store_value}; 8 | 9 | use crate::{ 10 | state::{AppState, FormState}, 11 | ui::{FormGroup, TextArea}, 12 | }; 13 | 14 | macro_rules! gcode_input { 15 | ($($name: ident { 16 | $label: literal, 17 | $desc: literal, 18 | $form_accessor: expr $(=> $form_idx: literal)?, 19 | $app_accessor: expr $(=> $app_idx: literal)?, 20 | })*) => { 21 | $( 22 | paste! { 23 | #[function_component([<$name Input>])] 24 | pub fn [<$name:snake:lower _input>]() -> Html { 25 | const VALIDATION_TIMEOUT: u32 = 350; 26 | let app_state = use_store_value::(); 27 | let (form_state, form_dispatch) = use_store::(); 28 | 29 | let timeout = use_state::, _>(|| None); 30 | let oninput = { 31 | let timeout = timeout.clone(); 32 | form_dispatch.reduce_mut_callback_with(move |state, event: InputEvent| { 33 | let value = event.target_unchecked_into::().value(); 34 | let res = Some(match snippet_parser(&value) { 35 | Ok(_) => Ok(value), 36 | Err(err) => { 37 | let mut buf = NoColor::new(vec![]); 38 | let config = Config::default(); 39 | emit( 40 | &mut buf, 41 | &config, 42 | &codespan_reporting::files::SimpleFile::new("", value), 43 | &into_diagnostic(&err), 44 | ) 45 | .unwrap(); 46 | Err(String::from_utf8_lossy(buf.get_ref().as_slice()).to_string()) 47 | } 48 | }).filter(|res| { 49 | !res.as_ref().ok().map_or(false, |value| value.is_empty()) 50 | }); 51 | 52 | let timeout_inner = timeout.clone(); 53 | timeout.set(Some(Timeout::new(VALIDATION_TIMEOUT, move || { 54 | timeout_inner.set(None); 55 | }))); 56 | state.$form_accessor $([$form_idx])? = res; 57 | }) 58 | }; 59 | html! { 60 | 61 | label=$label desc=$desc 62 | default={(app_state.$app_accessor $([$app_idx])?).clone()} 63 | parsed={(form_state.$form_accessor $([$form_idx])?).clone().filter(|_| timeout.is_none())} 64 | oninput={oninput} 65 | /> 66 | 67 | } 68 | } 69 | } 70 | )* 71 | }; 72 | } 73 | 74 | gcode_input! { 75 | ToolOnSequence { 76 | "Tool On Sequence", 77 | "G-Code for turning on the tool", 78 | tool_on_sequence, 79 | settings.machine.tool_on_sequence, 80 | } 81 | ToolOffSequence { 82 | "Tool Off Sequence", 83 | "G-Code for turning off the tool", 84 | tool_off_sequence, 85 | settings.machine.tool_off_sequence, 86 | } 87 | BeginSequence { 88 | "Program Begin Sequence", 89 | "G-Code for initializing the machine at the beginning of the program", 90 | begin_sequence, 91 | settings.machine.begin_sequence, 92 | } 93 | EndSequence { 94 | "Program End Sequence", 95 | "G-Code for stopping/idling the machine at the end of the program", 96 | end_sequence, 97 | settings.machine.end_sequence, 98 | } 99 | } 100 | 101 | // TODO: make a nice, syntax highlighting editor for g-code. 102 | // I started on this but it quickly got too complex. 103 | // pub struct GCodeEditor { 104 | // props: GCodeEditorProps, 105 | // dispatch: AppDispatch, 106 | // state: Rc, 107 | // validation_task: Option, 108 | // link: ComponentLink, 109 | // parsed: Option>, 110 | // node_ref: NodeRef, 111 | // } 112 | 113 | // pub enum InputMessage { 114 | // Validate(String), 115 | // State(Rc), 116 | // Change(InputData), 117 | // } 118 | 119 | // impl Component for GCodeEditor { 120 | // type Message = InputMessage; 121 | 122 | // type Properties = GCodeEditorProps; 123 | 124 | // fn create(props: Self::Properties, link: ComponentLink) -> Self { 125 | // Self { 126 | // props, 127 | // dispatch: Dispatch::bridge_state(link.callback(InputMessage::State)), 128 | // state: Default::default(), 129 | // validation_task: None, 130 | // link, 131 | // parsed: None, 132 | // node_ref: NodeRef::default(), 133 | // } 134 | // } 135 | 136 | // fn update(&mut self, msg: Self::Message) -> ShouldRender { 137 | // match msg { 138 | // InputMessage::State(state) => { 139 | // self.state = state; 140 | // true 141 | // } 142 | // InputMessage::Validate(value) => { 143 | // self.parsed = Some(snippet_parser(&value).map(|snippet| { 144 | // html! { 145 | // <> 146 | // { 147 | // for snippet.iter_emit_tokens().flat_map(|token| { 148 | // if let Token::Field(field) = &token { 149 | // vec![ 150 | // html! { 151 | // {field.letters.to_string()} 152 | // }, 153 | // { 154 | // let class = match &field.value { 155 | // Value::Rational(_) | Value::Integer(_) | Value::Float(_) => "hljs-number", 156 | // Value::String(_) => "hljs-string", 157 | // }; 158 | // html! { 159 | // {field.value.to_string()} 160 | // } 161 | // } 162 | // ] 163 | // } else if let Token::Newline{..} = &token { 164 | // vec![ 165 | // html! { 166 | // "\r\n" 167 | // } 168 | // ] 169 | // } 170 | // else { 171 | // let class = match &token { 172 | // Token::Comment{..} => "hljs-comment", 173 | // Token::Checksum(..) => "hljs-number", 174 | // Token::Whitespace(..) => "whitespace", 175 | // Token::Newline{..} => "newline", 176 | // Token::Percent => "hljs-keyword", 177 | // _ => unreachable!(), 178 | // }; 179 | // vec![html!{ 180 | // 181 | // { token.to_string() } 182 | // 183 | // }] 184 | // } 185 | // }) 186 | // } 187 | // 188 | // } 189 | // }).map_err(|err| { 190 | // let mut buf = Buffer::no_color(); 191 | // let config = Config::default(); 192 | // emit( 193 | // &mut buf, 194 | // &config, 195 | // &codespan_reporting::files::SimpleFile::new("", value), 196 | // &into_diagnostic(&err), 197 | // ) 198 | // .unwrap(); 199 | // String::from_utf8_lossy(buf.as_slice()).to_string() 200 | // })); 201 | // true 202 | // } 203 | // InputMessage::Change(InputData { value, .. }) => { 204 | // self.parsed = None; 205 | // self.validation_task = None; 206 | // self.validation_task = Some(TimeoutService::spawn( 207 | // self.props.validation_timeout, 208 | // self.link 209 | // .callback(move |_| InputMessage::Validate(value.clone())), 210 | // )); 211 | // true 212 | // } 213 | // } 214 | // } 215 | 216 | // fn change(&mut self, props: Self::Properties) -> ShouldRender { 217 | // self.props.neq_assign(props) 218 | // } 219 | 220 | // fn view(&self) -> Html { 221 | // let oninput = self.link.callback(|x: InputData| InputMessage::Change(x)); 222 | 223 | // html! { 224 | // <> 225 | //
226 | //