feat: support settings import/export, closes #22

master
Sameer Puri 4 years ago
parent acdc33ab71
commit 7019264735

15
Cargo.lock generated

@ -228,15 +228,16 @@ checksum = "af51b1b4a7fdff033703db39de8802c673eb91855f2e0d47dcf3bf2c0ef01f99"
[[package]] [[package]]
name = "g-code" name = "g-code"
version = "0.3.1" version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2403e3a8d80208fa702f2bc4588d8ea28ed456c7e552891eea6e7e9c68582167" checksum = "8220033ab3971720c5ec54c4b7d36b35b87e3e58d53785152ffbdfd72d8e4c80"
dependencies = [ dependencies = [
"codespan", "codespan",
"codespan-reporting", "codespan-reporting",
"paste", "paste",
"peg", "peg",
"rust_decimal", "rust_decimal",
"serde",
] ]
[[package]] [[package]]
@ -632,7 +633,7 @@ dependencies = [
[[package]] [[package]]
name = "svg2gcode" name = "svg2gcode"
version = "0.0.5" version = "0.0.6"
dependencies = [ dependencies = [
"cairo-rs", "cairo-rs",
"euclid", "euclid",
@ -641,19 +642,22 @@ dependencies = [
"lyon_geom", "lyon_geom",
"paste", "paste",
"roxmltree", "roxmltree",
"serde",
"serde_json",
"svgtypes", "svgtypes",
"uom", "uom",
] ]
[[package]] [[package]]
name = "svg2gcode-cli" name = "svg2gcode-cli"
version = "0.0.1" version = "0.0.2"
dependencies = [ dependencies = [
"codespan-reporting", "codespan-reporting",
"env_logger", "env_logger",
"g-code", "g-code",
"log", "log",
"roxmltree", "roxmltree",
"serde_json",
"structopt", "structopt",
"svg2gcode", "svg2gcode",
"svgtypes", "svgtypes",
@ -661,7 +665,7 @@ dependencies = [
[[package]] [[package]]
name = "svg2gcode-web" name = "svg2gcode-web"
version = "0.0.1" version = "0.0.2"
dependencies = [ dependencies = [
"base64", "base64",
"codespan", "codespan",
@ -674,6 +678,7 @@ dependencies = [
"paste", "paste",
"roxmltree", "roxmltree",
"serde", "serde",
"serde_json",
"svg2gcode", "svg2gcode",
"svgtypes", "svgtypes",
"wasm-bindgen", "wasm-bindgen",

@ -1,6 +1,6 @@
[package] [package]
name = "svg2gcode-cli" name = "svg2gcode-cli"
version = "0.0.1" version = "0.0.2"
authors = ["Sameer Puri <crates@purisa.me>"] authors = ["Sameer Puri <crates@purisa.me>"]
edition = "2018" edition = "2018"
description = "Command line interface for svg2gcode" description = "Command line interface for svg2gcode"
@ -8,7 +8,7 @@ repository = "https://github.com/sameer/svg2gcode"
license = "MIT" license = "MIT"
[dependencies] [dependencies]
svg2gcode = { path = "../lib" } svg2gcode = { path = "../lib", features = ["serde"]}
env_logger = { version = "0", default-features = false, features = ["atty", "termcolor", "humantime"] } env_logger = { version = "0", default-features = false, features = ["atty", "termcolor", "humantime"] }
log = "0" log = "0"
g-code = "0" g-code = "0"
@ -16,6 +16,7 @@ codespan-reporting = "0.11"
structopt = "0.3" structopt = "0.3"
roxmltree = "0" roxmltree = "0"
svgtypes = "0" svgtypes = "0"
serde_json = "1"
[[bin]] [[bin]]
name = "svg2gcode" name = "svg2gcode"

@ -6,29 +6,29 @@ use log::info;
use std::{ use std::{
env, env,
fs::File, fs::File,
io::{self, Read}, io::{self, Read, Write},
path::PathBuf, path::PathBuf,
}; };
use structopt::StructOpt; use structopt::StructOpt;
use svgtypes::LengthListParser; use svgtypes::LengthListParser;
use svg2gcode::{ use svg2gcode::{
set_origin, svg2program, ConversionOptions, Machine, SupportedFunctionality, Turtle, set_origin, svg2program, ConversionOptions, Machine, Settings, SupportedFunctionality, Turtle,
}; };
#[derive(Debug, StructOpt)] #[derive(Debug, StructOpt)]
#[structopt(name = "svg2gcode", author, about)] #[structopt(name = "svg2gcode", author, about)]
struct Opt { struct Opt {
/// Curve interpolation tolerance (mm) /// Curve interpolation tolerance (mm)
#[structopt(long, default_value = "0.002")] #[structopt(long)]
tolerance: f64, tolerance: Option<f64>,
/// Machine feed rate (mm/min) /// Machine feed rate (mm/min)
#[structopt(long, default_value = "300")] #[structopt(long)]
feedrate: f64, feedrate: Option<f64>,
/// Dots per Inch (DPI) /// Dots per Inch (DPI)
/// Used for scaling visual units (pixels, points, picas, etc.) /// Used for scaling visual units (pixels, points, picas, etc.)
#[structopt(long, default_value = "96")] #[structopt(long)]
dpi: f64, dpi: Option<f64>,
#[structopt(alias = "tool_on_sequence", long = "on")] #[structopt(alias = "tool_on_sequence", long = "on")]
/// G-Code for turning on the tool /// G-Code for turning on the tool
tool_on_sequence: Option<String>, tool_on_sequence: Option<String>,
@ -46,9 +46,17 @@ struct Opt {
/// Output file path (overwrites old files), else writes to stdout /// Output file path (overwrites old files), else writes to stdout
#[structopt(short, long)] #[structopt(short, long)]
out: Option<PathBuf>, out: Option<PathBuf>,
/// Provide settings from a JSON file. Overrides command-line arguments.
#[structopt(long)]
settings: Option<PathBuf>,
/// Export current settings to a JSON file instead of converting.
///
/// Use `-` to export to standard out.
#[structopt(long)]
export: Option<PathBuf>,
/// Coordinates for the bottom left corner of the machine /// Coordinates for the bottom left corner of the machine
#[structopt(long, default_value = "0,0")] #[structopt(long)]
origin: String, origin: Option<String>,
/// Override the width and height of the SVG (i.e. 210mm,297mm) /// Override the width and height of the SVG (i.e. 210mm,297mm)
/// ///
/// Useful when the SVG does not specify these (see https://github.com/sameer/svg2gcode/pull/16) /// Useful when the SVG does not specify these (see https://github.com/sameer/svg2gcode/pull/16)
@ -60,7 +68,7 @@ struct Opt {
/// ///
/// Please check if your machine supports G2/G3 commands before enabling this. /// Please check if your machine supports G2/G3 commands before enabling this.
#[structopt(long)] #[structopt(long)]
circular_interpolation: bool, circular_interpolation: Option<bool>,
} }
fn main() -> io::Result<()> { fn main() -> io::Result<()> {
@ -71,6 +79,95 @@ fn main() -> io::Result<()> {
let opt = Opt::from_args(); let opt = Opt::from_args();
let settings = {
let mut settings = if let Some(path) = opt.settings {
serde_json::from_reader(File::open(path)?)?
} else {
Settings::default()
};
{
let conversion = &mut settings.conversion;
conversion.dpi = opt.dpi.unwrap_or(conversion.dpi);
conversion.feedrate = opt.feedrate.unwrap_or(conversion.feedrate);
conversion.tolerance = opt.dpi.unwrap_or(conversion.tolerance);
}
{
let machine = &mut settings.machine;
machine.supported_functionality = SupportedFunctionality {
circular_interpolation: opt
.circular_interpolation
.unwrap_or(machine.supported_functionality.circular_interpolation),
};
if let Some(sequence) = opt.tool_on_sequence {
machine.tool_on_sequence.insert(sequence);
}
if let Some(sequence) = opt.tool_off_sequence {
machine.tool_off_sequence.insert(sequence);
}
if let Some(sequence) = opt.begin_sequence {
machine.begin_sequence.insert(sequence);
}
if let Some(sequence) = opt.end_sequence {
machine.end_sequence.insert(sequence);
}
}
{
let postprocess = &mut settings.postprocess;
if let Some(origin) = opt.origin {
for (i, dimension_origin) in origin
.split(',')
.map(|point| {
if point.is_empty() {
Default::default()
} else {
point.parse().expect("could not parse coordinate")
}
})
.take(2)
.enumerate()
{
postprocess.origin[i] = dimension_origin;
}
}
}
settings
};
if let Some(export_path) = opt.export {
let mut config_json_bytes = serde_json::to_vec_pretty(&settings)?;
if export_path.to_string_lossy() == "-" {
return io::stdout().write_all(&mut config_json_bytes);
} else {
return File::create(export_path)?.write_all(&mut config_json_bytes);
}
}
let options = {
let mut dimensions = [None, None];
if let Some(dimensions_str) = opt.dimensions {
dimensions_str
.split(',')
.map(|dimension_str| {
if dimension_str.is_empty() {
None
} else {
LengthListParser::from(dimension_str)
.next()
.transpose()
.expect("could not parse dimension")
}
})
.take(2)
.enumerate()
.for_each(|(i, dimension_origin)| {
dimensions[i] = dimension_origin;
});
}
ConversionOptions { dimensions }
};
let input = match opt.file { let input = match opt.file {
Some(filename) => { Some(filename) => {
let mut f = File::open(filename)?; let mut f = File::open(filename)?;
@ -87,58 +184,38 @@ fn main() -> io::Result<()> {
} }
}; };
let mut dimensions = [None, None];
if let Some(dimensions_str) = opt.dimensions {
dimensions_str
.split(',')
.map(|dimension_str| {
if dimension_str.is_empty() {
None
} else {
LengthListParser::from(dimension_str)
.next()
.transpose()
.expect("could not parse dimension")
}
})
.take(2)
.enumerate()
.for_each(|(i, dimension_origin)| {
dimensions[i] = dimension_origin;
});
}
let options = ConversionOptions {
tolerance: opt.tolerance,
feedrate: opt.feedrate,
dpi: opt.dpi,
dimensions,
};
let snippets = [ let snippets = [
opt.tool_on_sequence settings
.machine
.tool_on_sequence
.as_deref()
.map(snippet_parser)
.transpose(),
settings
.machine
.tool_off_sequence
.as_deref() .as_deref()
.map(snippet_parser) .map(snippet_parser)
.transpose(), .transpose(),
opt.tool_off_sequence settings
.machine
.begin_sequence
.as_deref() .as_deref()
.map(snippet_parser) .map(snippet_parser)
.transpose(), .transpose(),
opt.begin_sequence settings
.machine
.end_sequence
.as_deref() .as_deref()
.map(snippet_parser) .map(snippet_parser)
.transpose(), .transpose(),
opt.end_sequence.as_deref().map(snippet_parser).transpose(),
]; ];
let machine = if let [Ok(tool_on_action), Ok(tool_off_action), Ok(program_begin_sequence), Ok(program_end_sequence)] = let machine = if let [Ok(tool_on_action), Ok(tool_off_action), Ok(program_begin_sequence), Ok(program_end_sequence)] =
snippets snippets
{ {
Machine::new( Machine::new(
SupportedFunctionality { settings.machine.supported_functionality,
circular_interpolation: opt.circular_interpolation,
},
tool_on_action, tool_on_action,
tool_off_action, tool_off_action,
program_begin_sequence, program_begin_sequence,
@ -153,10 +230,10 @@ fn main() -> io::Result<()> {
let config = codespan_reporting::term::Config::default(); let config = codespan_reporting::term::Config::default();
for (i, (filename, gcode)) in [ for (i, (filename, gcode)) in [
("tool_on_sequence", &opt.tool_on_sequence), ("tool_on_sequence", &settings.machine.tool_on_sequence),
("tool_off_sequence", &opt.tool_off_sequence), ("tool_off_sequence", &settings.machine.tool_off_sequence),
("begin_sequence", &opt.begin_sequence), ("begin_sequence", &settings.machine.begin_sequence),
("end_sequence", &opt.end_sequence), ("end_sequence", &settings.machine.end_sequence),
] ]
.iter() .iter()
.enumerate() .enumerate()
@ -177,26 +254,9 @@ fn main() -> io::Result<()> {
let document = roxmltree::Document::parse(&input).unwrap(); let document = roxmltree::Document::parse(&input).unwrap();
let mut turtle = Turtle::new(machine); let mut turtle = Turtle::new(machine);
let mut program = svg2program(&document, options, &mut turtle); let mut program = svg2program(&document, &settings.conversion, options, &mut turtle);
let mut origin = [0., 0.]; set_origin(&mut program, settings.postprocess.origin);
for (i, dimension_origin) in opt
.origin
.split(',')
.map(|point| {
if point.is_empty() {
Default::default()
} else {
point.parse().expect("could not parse coordinate")
}
})
.take(2)
.enumerate()
{
origin[i] = dimension_origin;
}
set_origin(&mut program, origin);
if let Some(out_path) = opt.out { if let Some(out_path) = opt.out {
format_gcode_io(&program, FormatOptions::default(), File::create(out_path)?) format_gcode_io(&program, FormatOptions::default(), File::create(out_path)?)

@ -1,6 +1,6 @@
[package] [package]
name = "svg2gcode" name = "svg2gcode"
version = "0.0.5" version = "0.0.6"
authors = ["Sameer Puri <crates@purisa.me>"] authors = ["Sameer Puri <crates@purisa.me>"]
edition = "2018" edition = "2018"
description = "Convert paths in SVG files to GCode for a pen plotter, laser engraver, or other machine." description = "Convert paths in SVG files to GCode for a pen plotter, laser engraver, or other machine."
@ -8,7 +8,7 @@ repository = "https://github.com/sameer/svg2gcode"
license = "MIT" license = "MIT"
[dependencies] [dependencies]
g-code = "0.3.1" g-code = { version = "0.3.3", features = ["serde"] }
lyon_geom = ">= 0.17.5" lyon_geom = ">= 0.17.5"
euclid = "0.22" euclid = "0.22"
log = "0.4" log = "0.4"
@ -17,5 +17,12 @@ roxmltree = "0.14"
svgtypes = "0.6" svgtypes = "0.6"
paste = "1.0" paste = "1.0"
[dependencies.serde]
default-features = false
optional = true
version = "1"
features = ["derive"]
[dev-dependencies] [dev-dependencies]
cairo-rs = { version = "0.14", default-features = false, features = ["svg", "v1_16"] } cairo-rs = { version = "0.14", default-features = false, features = ["svg", "v1_16"] }
serde_json = "1"

@ -8,43 +8,136 @@ use lyon_geom::{
point, vector, ArcFlags, point, vector, ArcFlags,
}; };
use roxmltree::{Document, Node}; use roxmltree::{Document, Node};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use svgtypes::{ use svgtypes::{
Length, LengthListParser, LengthUnit, PathParser, PathSegment, TransformListParser, Length, LengthListParser, PathParser, PathSegment, TransformListParser, TransformListToken,
TransformListToken, ViewBox, ViewBox,
}; };
use crate::turtle::*; use crate::turtle::*;
const SVG_TAG_NAME: &str = "svg"; const SVG_TAG_NAME: &str = "svg";
/// High-level output options /// High-level output configuration
#[derive(Debug)] #[derive(Debug, Clone, PartialEq)]
pub struct ConversionOptions { #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ConversionConfig {
/// Curve interpolation tolerance in millimeters /// Curve interpolation tolerance in millimeters
pub tolerance: f64, pub tolerance: f64,
/// Feedrate in millimeters / minute /// Feedrate in millimeters / minute
pub feedrate: f64, pub feedrate: f64,
/// Dots per inch for pixels, picas, points, etc. /// Dots per inch for pixels, picas, points, etc.
pub dpi: f64, pub dpi: f64,
/// Width and height override
///
/// Useful when an SVG does not have a set width and height or you want to override it.
pub dimensions: [Option<Length>; 2],
} }
impl Default for ConversionOptions { impl Default for ConversionConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
tolerance: 0.002, tolerance: 0.002,
feedrate: 300.0, feedrate: 300.0,
dpi: 96.0, dpi: 96.0,
dimensions: [None; 2],
} }
} }
} }
/// Options are specific to this conversion.
///
/// This is separate from [ConversionConfig] to support bulk processing in the web interface.
#[derive(Debug, Clone, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ConversionOptions {
/// Width and height override
///
/// Useful when an SVG does not have a set width and height or you want to override it.
#[cfg_attr(feature = "serde", serde(with = "length_serde"))]
pub dimensions: [Option<Length>; 2],
}
#[cfg(feature = "serde")]
mod length_serde {
use serde::{
de::{SeqAccess, Visitor},
ser::SerializeSeq,
Deserialize, Deserializer, Serialize, Serializer,
};
use svgtypes::{Length, LengthUnit};
pub fn serialize<S>(length: &[Option<Length>; 2], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut seq = serializer.serialize_seq(Some(2))?;
for i in 0..2 {
let length_def = length[i].clone().map(|length| LengthDef {
number: length.number,
unit: length.unit,
});
seq.serialize_element(&length_def)?;
}
seq.end()
}
struct OptionalLengthArrayVisitor;
impl<'de> Visitor<'de> for OptionalLengthArrayVisitor {
type Value = [Option<Length>; 2];
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "SVG dimension array")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
let x = seq.next_element::<Option<LengthDef>>()?.flatten();
let y = seq.next_element::<Option<LengthDef>>()?.flatten();
Ok([
x.map(|length_def| Length {
number: length_def.number,
unit: length_def.unit,
}),
y.map(|length_def| Length {
number: length_def.number,
unit: length_def.unit,
}),
])
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<[Option<Length>; 2], D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_seq(OptionalLengthArrayVisitor)
}
#[derive(Serialize, Deserialize)]
struct LengthDef {
number: f64,
#[serde(with = "LengthUnitDef")]
unit: LengthUnit,
}
#[derive(Serialize, Deserialize)]
#[serde(remote = "LengthUnit")]
enum LengthUnitDef {
None,
Em,
Ex,
Px,
In,
Cm,
Mm,
Pt,
Pc,
Percent,
}
}
pub fn svg2program<'input>( pub fn svg2program<'input>(
doc: &Document, doc: &Document,
config: &ConversionConfig,
options: ConversionOptions, options: ConversionOptions,
turtle: &'input mut Turtle<'input>, turtle: &'input mut Turtle<'input>,
) -> Vec<Token<'input>> { ) -> Vec<Token<'input>> {
@ -110,13 +203,13 @@ pub fn svg2program<'input>(
.and_then(|mut parser| parser.next()) .and_then(|mut parser| parser.next())
.transpose() .transpose()
.expect("could not parse width") .expect("could not parse width")
.map(|width| length_to_mm(width, options.dpi, scale_w)), .map(|width| length_to_mm(width, config.dpi, scale_w)),
node.attribute("height") node.attribute("height")
.map(LengthListParser::from) .map(LengthListParser::from)
.and_then(|mut parser| parser.next()) .and_then(|mut parser| parser.next())
.transpose() .transpose()
.expect("could not parse height") .expect("could not parse height")
.map(|height| length_to_mm(height, options.dpi, scale_h)), .map(|height| length_to_mm(height, config.dpi, scale_h)),
); );
let aspect_ratio = match (view_box, dimensions) { let aspect_ratio = match (view_box, dimensions) {
(_, (Some(ref width), Some(ref height))) => *width / *height, (_, (Some(ref width), Some(ref height))) => *width / *height,
@ -136,8 +229,8 @@ pub fn svg2program<'input>(
} }
let dimensions_override = [ let dimensions_override = [
options.dimensions[0].map(|dim_x| length_to_mm(dim_x, options.dpi, scale_w)), options.dimensions[0].map(|dim_x| length_to_mm(dim_x, config.dpi, scale_w)),
options.dimensions[1].map(|dim_y| length_to_mm(dim_y, options.dpi, scale_h)), options.dimensions[1].map(|dim_y| length_to_mm(dim_y, config.dpi, scale_h)),
]; ];
match (dimensions_override, dimensions) { match (dimensions_override, dimensions) {
@ -203,7 +296,7 @@ pub fn svg2program<'input>(
is_inline: false, is_inline: false,
inner: Cow::Owned(comment), inner: Cow::Owned(comment),
}); });
program.extend(apply_path(turtle, &options, d)); program.extend(apply_path(turtle, config, d));
} else { } else {
warn!("There is a path node containing no actual path: {:?}", node); warn!("There is a path node containing no actual path: {:?}", node);
} }
@ -238,7 +331,7 @@ fn node_name(node: &Node) -> String {
fn apply_path<'input>( fn apply_path<'input>(
turtle: &'_ mut Turtle<'input>, turtle: &'_ mut Turtle<'input>,
options: &ConversionOptions, config: &ConversionConfig,
path: &str, path: &str,
) -> Vec<Token<'input>> { ) -> Vec<Token<'input>> {
use PathSegment::*; use PathSegment::*;
@ -250,11 +343,11 @@ fn apply_path<'input>(
MoveTo { abs, x, y } => turtle.move_to(abs, x, y), MoveTo { abs, x, y } => turtle.move_to(abs, x, y),
ClosePath { abs: _ } => { ClosePath { abs: _ } => {
// Ignore abs, should have identical effect: [9.3.4. The "closepath" command]("https://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand) // Ignore abs, should have identical effect: [9.3.4. The "closepath" command]("https://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand)
turtle.close(options.feedrate) turtle.close(config.feedrate)
} }
LineTo { abs, x, y } => turtle.line(abs, x, y, options.feedrate), LineTo { abs, x, y } => turtle.line(abs, x, y, config.feedrate),
HorizontalLineTo { abs, x } => turtle.line(abs, x, None, options.feedrate), HorizontalLineTo { abs, x } => turtle.line(abs, x, None, config.feedrate),
VerticalLineTo { abs, y } => turtle.line(abs, None, y, options.feedrate), VerticalLineTo { abs, y } => turtle.line(abs, None, y, config.feedrate),
CurveTo { CurveTo {
abs, abs,
x1, x1,
@ -268,28 +361,28 @@ fn apply_path<'input>(
point(x1, y1), point(x1, y1),
point(x2, y2), point(x2, y2),
point(x, y), point(x, y),
options.tolerance, config.tolerance,
options.feedrate, config.feedrate,
), ),
SmoothCurveTo { abs, x2, y2, x, y } => turtle.smooth_cubic_bezier( SmoothCurveTo { abs, x2, y2, x, y } => turtle.smooth_cubic_bezier(
abs, abs,
point(x2, y2), point(x2, y2),
point(x, y), point(x, y),
options.tolerance, config.tolerance,
options.feedrate, config.feedrate,
), ),
Quadratic { abs, x1, y1, x, y } => turtle.quadratic_bezier( Quadratic { abs, x1, y1, x, y } => turtle.quadratic_bezier(
abs, abs,
point(x1, y1), point(x1, y1),
point(x, y), point(x, y),
options.tolerance, config.tolerance,
options.feedrate, config.feedrate,
), ),
SmoothQuadratic { abs, x, y } => turtle.smooth_quadratic_bezier( SmoothQuadratic { abs, x, y } => turtle.smooth_quadratic_bezier(
abs, abs,
point(x, y), point(x, y),
options.tolerance, config.tolerance,
options.feedrate, config.feedrate,
), ),
EllipticalArc { EllipticalArc {
abs, abs,
@ -306,8 +399,8 @@ fn apply_path<'input>(
Angle::degrees(x_axis_rotation), Angle::degrees(x_axis_rotation),
ArcFlags { large_arc, sweep }, ArcFlags { large_arc, sweep },
point(x, y), point(x, y),
options.feedrate, config.feedrate,
options.tolerance, config.tolerance,
), ),
} }
}) })
@ -373,3 +466,67 @@ fn length_to_mm(l: svgtypes::Length, dpi: f64, scale: Option<f64>) -> f64 {
length.get::<millimeter>() length.get::<millimeter>()
} }
#[cfg(test)]
mod test {
use super::*;
#[cfg(feature = "serde")]
use svgtypes::LengthUnit;
#[test]
#[cfg(feature = "serde")]
fn serde_conversion_options_is_correct() {
let default_struct = ConversionOptions::default();
let default_json = "{\"dimensions\":[null,null]}";
assert_eq!(
serde_json::to_string(&default_struct).unwrap(),
default_json
);
assert_eq!(
serde_json::from_str::<ConversionOptions>(default_json).unwrap(),
default_struct
);
}
#[test]
#[cfg(feature = "serde")]
fn serde_conversion_options_with_single_dimension_is_correct() {
let mut r#struct = ConversionOptions::default();
r#struct.dimensions[0] = Some(Length {
number: 4.,
unit: LengthUnit::Mm,
});
let json = "{\"dimensions\":[{\"number\":4.0,\"unit\":\"Mm\"},null]}";
assert_eq!(serde_json::to_string(&r#struct).unwrap(), json);
assert_eq!(
serde_json::from_str::<ConversionOptions>(json).unwrap(),
r#struct
);
}
#[test]
#[cfg(feature = "serde")]
fn serde_conversion_options_with_both_dimensions_is_correct() {
let mut r#struct = ConversionOptions::default();
r#struct.dimensions = [
Some(Length {
number: 4.,
unit: LengthUnit::Mm,
}),
Some(Length {
number: 10.5,
unit: LengthUnit::In,
}),
];
let json =
"{\"dimensions\":[{\"number\":4.0,\"unit\":\"Mm\"},{\"number\":10.5,\"unit\":\"In\"}]}";
assert_eq!(serde_json::to_string(&r#struct).unwrap(), json);
assert_eq!(
serde_json::from_str::<ConversionOptions>(json).unwrap(),
r#struct
);
}
}

@ -1,3 +1,6 @@
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
/// Approximate [Bézier curves](https://en.wikipedia.org/wiki/B%C3%A9zier_curve) with [Circular arcs](https://en.wikipedia.org/wiki/Circular_arc) /// Approximate [Bézier curves](https://en.wikipedia.org/wiki/B%C3%A9zier_curve) with [Circular arcs](https://en.wikipedia.org/wiki/Circular_arc)
mod arc; mod arc;
/// Converts an SVG to G-Code in an internal representation /// Converts an SVG to G-Code in an internal representation
@ -11,12 +14,19 @@ mod postprocess;
/// This concept is referred to as [Turtle graphics](https://en.wikipedia.org/wiki/Turtle_graphics). /// This concept is referred to as [Turtle graphics](https://en.wikipedia.org/wiki/Turtle_graphics).
mod turtle; mod turtle;
pub use converter::{svg2program, ConversionOptions}; pub use converter::{svg2program, ConversionConfig, ConversionOptions};
pub use machine::Machine; pub use machine::{Machine, MachineConfig, SupportedFunctionality};
pub use machine::SupportedFunctionality; pub use postprocess::{set_origin, PostprocessConfig};
pub use postprocess::set_origin;
pub use turtle::Turtle; pub use turtle::Turtle;
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Settings {
pub conversion: ConversionConfig,
pub machine: MachineConfig,
pub postprocess: PostprocessConfig,
}
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
@ -28,10 +38,8 @@ mod test {
circular_interpolation: bool, circular_interpolation: bool,
dimensions: [Option<Length>; 2], dimensions: [Option<Length>; 2],
) -> String { ) -> String {
let options = ConversionOptions { let config = ConversionConfig::default();
dimensions, let options = ConversionOptions { dimensions };
..Default::default()
};
let document = roxmltree::Document::parse(input).unwrap(); let document = roxmltree::Document::parse(input).unwrap();
let mut turtle = Turtle::new(Machine::new( let mut turtle = Turtle::new(Machine::new(
@ -43,7 +51,7 @@ mod test {
None, None,
None, None,
)); ));
let mut program = converter::svg2program(&document, options, &mut turtle); let mut program = converter::svg2program(&document, &config, options, &mut turtle);
postprocess::set_origin(&mut program, [0., 0.]); postprocess::set_origin(&mut program, [0., 0.]);
let mut acc = String::new(); let mut acc = String::new();

@ -1,4 +1,6 @@
use g_code::{command, emit::Token, parse::ast::Snippet}; use g_code::{command, emit::Token, parse::ast::Snippet};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
/// Whether the tool is active (i.e. cutting) /// Whether the tool is active (i.e. cutting)
#[derive(Copy, Clone, PartialEq, Eq, Debug)] #[derive(Copy, Clone, PartialEq, Eq, Debug)]
@ -41,13 +43,24 @@ pub struct Machine<'input> {
supported_functionality: SupportedFunctionality, supported_functionality: SupportedFunctionality,
tool_state: Option<Tool>, tool_state: Option<Tool>,
distance_mode: Option<Distance>, distance_mode: Option<Distance>,
tool_on_action: Vec<Token<'input>>, tool_on_sequence: Vec<Token<'input>>,
tool_off_action: Vec<Token<'input>>, tool_off_sequence: Vec<Token<'input>>,
program_begin_sequence: Vec<Token<'input>>, program_begin_sequence: Vec<Token<'input>>,
program_end_sequence: Vec<Token<'input>>, program_end_sequence: Vec<Token<'input>>,
} }
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MachineConfig {
pub supported_functionality: SupportedFunctionality,
pub tool_on_sequence: Option<String>,
pub tool_off_sequence: Option<String>,
pub begin_sequence: Option<String>,
pub end_sequence: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SupportedFunctionality { pub struct SupportedFunctionality {
/// Indicates support for G2/G3 circular interpolation. /// Indicates support for G2/G3 circular interpolation.
/// ///
@ -58,17 +71,17 @@ pub struct SupportedFunctionality {
impl<'input> Machine<'input> { impl<'input> Machine<'input> {
pub fn new( pub fn new(
supported_functionality: SupportedFunctionality, supported_functionality: SupportedFunctionality,
tool_on_action: Option<Snippet<'input>>, tool_on_sequence: Option<Snippet<'input>>,
tool_off_action: Option<Snippet<'input>>, tool_off_sequence: Option<Snippet<'input>>,
program_begin_sequence: Option<Snippet<'input>>, program_begin_sequence: Option<Snippet<'input>>,
program_end_sequence: Option<Snippet<'input>>, program_end_sequence: Option<Snippet<'input>>,
) -> Self { ) -> Self {
Self { Self {
supported_functionality, supported_functionality,
tool_on_action: tool_on_action tool_on_sequence: tool_on_sequence
.map(|s| s.iter_emit_tokens().collect()) .map(|s| s.iter_emit_tokens().collect())
.unwrap_or_default(), .unwrap_or_default(),
tool_off_action: tool_off_action tool_off_sequence: tool_off_sequence
.map(|s| s.iter_emit_tokens().collect()) .map(|s| s.iter_emit_tokens().collect())
.unwrap_or_default(), .unwrap_or_default(),
program_begin_sequence: program_begin_sequence program_begin_sequence: program_begin_sequence
@ -89,7 +102,7 @@ impl<'input> Machine<'input> {
pub fn tool_on(&mut self) -> Vec<Token<'input>> { pub fn tool_on(&mut self) -> Vec<Token<'input>> {
if self.tool_state == Some(Tool::Off) || self.tool_state == None { if self.tool_state == Some(Tool::Off) || self.tool_state == None {
self.tool_state = Some(Tool::On); self.tool_state = Some(Tool::On);
self.tool_on_action.clone() self.tool_on_sequence.clone()
} else { } else {
vec![] vec![]
} }
@ -99,7 +112,7 @@ impl<'input> Machine<'input> {
pub fn tool_off(&mut self) -> Vec<Token<'input>> { pub fn tool_off(&mut self) -> Vec<Token<'input>> {
if self.tool_state == Some(Tool::On) || self.tool_state == None { if self.tool_state == Some(Tool::On) || self.tool_state == None {
self.tool_state = Some(Tool::Off); self.tool_state = Some(Tool::Off);
self.tool_off_action.clone() self.tool_off_sequence.clone()
} else { } else {
vec![] vec![]
} }

@ -1,3 +1,6 @@
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use euclid::default::Box2D; use euclid::default::Box2D;
use g_code::emit::{ use g_code::emit::{
command::{ABSOLUTE_DISTANCE_MODE_FIELD, RELATIVE_DISTANCE_MODE_FIELD}, command::{ABSOLUTE_DISTANCE_MODE_FIELD, RELATIVE_DISTANCE_MODE_FIELD},
@ -7,6 +10,12 @@ use lyon_geom::{point, vector, Point};
type F64Point = Point<f64>; type F64Point = Point<f64>;
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Default, Clone, PartialEq)]
pub struct PostprocessConfig {
pub origin: [f64; 2],
}
/// Moves all the commands so that they are beyond a specified position /// Moves all the commands so that they are beyond a specified position
pub fn set_origin(tokens: &mut [Token<'_>], origin: [f64; 2]) { pub fn set_origin(tokens: &mut [Token<'_>], origin: [f64; 2]) {
let offset = let offset =

@ -1,6 +1,6 @@
[package] [package]
name = "svg2gcode-web" name = "svg2gcode-web"
version = "0.0.1" version = "0.0.2"
authors = ["Sameer Puri <crates@purisa.me>"] authors = ["Sameer Puri <crates@purisa.me>"]
edition = "2018" edition = "2018"
description = "Convert vector graphics to g-code for pen plotters, laser engravers, and other CNC machines" description = "Convert vector graphics to g-code for pen plotters, laser engravers, and other CNC machines"
@ -10,7 +10,7 @@ license = "MIT"
[dependencies] [dependencies]
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
svg2gcode = { path = "../lib" } svg2gcode = { path = "../lib", features = ["serde"] }
roxmltree = "0" roxmltree = "0"
g-code = "0" g-code = "0"
codespan-reporting = "0.11" codespan-reporting = "0.11"
@ -19,6 +19,7 @@ serde = "1"
paste = "1" paste = "1"
log = "0" log = "0"
svgtypes = "0" svgtypes = "0"
serde_json = "1"
yew = { git = "https://github.com/yewstack/yew.git" } yew = { git = "https://github.com/yewstack/yew.git" }
yewdux-functional = { git = "https://github.com/intendednull/yewdux.git" } yewdux-functional = { git = "https://github.com/intendednull/yewdux.git" }

@ -1,11 +1,12 @@
use codespan_reporting::term::{emit, termcolor::NoColor, Config}; use codespan_reporting::term::{emit, termcolor::NoColor, Config};
use g_code::parse::{into_diagnostic, snippet_parser}; use g_code::parse::{into_diagnostic, snippet_parser};
use gloo_file::futures::read_as_text; use gloo_file::futures::{read_as_bytes, read_as_text};
use gloo_timers::callback::Timeout; use gloo_timers::callback::Timeout;
use js_sys::TypeError; use js_sys::TypeError;
use paste::paste; use paste::paste;
use roxmltree::Document; use roxmltree::Document;
use std::num::ParseFloatError; use std::{convert::TryInto, num::ParseFloatError, path::Path};
use svg2gcode::Settings;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture; use wasm_bindgen_futures::JsFuture;
use web_sys::{window, FileList, HtmlElement, Response}; use web_sys::{window, FileList, HtmlElement, Response};
@ -171,7 +172,8 @@ macro_rules! gcode_input {
($($name: ident { ($($name: ident {
$label: literal, $label: literal,
$desc: literal, $desc: literal,
$accessor: expr $(=> $idx: literal)?, $form_accessor: expr $(=> $form_idx: literal)?,
$app_accessor: expr $(=> $app_idx: literal)?,
})*) => { })*) => {
$( $(
paste! { paste! {
@ -207,14 +209,14 @@ macro_rules! gcode_input {
timeout.set(Some(Timeout::new(VALIDATION_TIMEOUT, move || { timeout.set(Some(Timeout::new(VALIDATION_TIMEOUT, move || {
timeout_inner.set(None); timeout_inner.set(None);
}))); })));
state.$accessor $([$idx])? = res; state.$form_accessor $([$form_idx])? = res;
}) })
}; };
html! { html! {
<FormGroup success={form.state().map(|state| (state.$accessor $([$idx])?).as_ref().map(Result::is_ok)).flatten()}> <FormGroup success={form.state().map(|state| (state.$form_accessor $([$form_idx])?).as_ref().map(Result::is_ok)).flatten()}>
<TextArea<String, String> label=$label desc=$desc <TextArea<String, String> label=$label desc=$desc
default={app.state().map(|state| (state.$accessor $([$idx])?).clone()).unwrap_or_else(|| AppState::default().$accessor $([$idx])?)} default={app.state().map(|state| (state.$app_accessor $([$app_idx])?).clone()).unwrap_or_else(|| AppState::default().$app_accessor $([$app_idx])?)}
parsed={form.state().and_then(|state| (state.$accessor $([$idx])?).clone()).filter(|_| timeout.is_none())} parsed={form.state().and_then(|state| (state.$form_accessor $([$form_idx])?).clone()).filter(|_| timeout.is_none())}
oninput={oninput} oninput={oninput}
/> />
</FormGroup> </FormGroup>
@ -230,21 +232,25 @@ gcode_input! {
"Tool On Sequence", "Tool On Sequence",
"G-Code for turning on the tool", "G-Code for turning on the tool",
tool_on_sequence, tool_on_sequence,
settings.machine.tool_on_sequence,
} }
ToolOffSequence { ToolOffSequence {
"Tool Off Sequence", "Tool Off Sequence",
"G-Code for turning off the tool", "G-Code for turning off the tool",
tool_off_sequence, tool_off_sequence,
settings.machine.tool_off_sequence,
} }
BeginSequence { BeginSequence {
"Program Begin Sequence", "Program Begin Sequence",
"G-Code for initializing the machine at the beginning of the program", "G-Code for initializing the machine at the beginning of the program",
begin_sequence, begin_sequence,
settings.machine.begin_sequence,
} }
EndSequence { EndSequence {
"Program End Sequence", "Program End Sequence",
"G-Code for stopping/idling the machine at the end of the program", "G-Code for stopping/idling the machine at the end of the program",
end_sequence, end_sequence,
settings.machine.end_sequence,
} }
} }
@ -252,7 +258,8 @@ macro_rules! form_input {
($($name: ident { ($($name: ident {
$label: literal, $label: literal,
$desc: literal, $desc: literal,
$accessor: expr $(=> $idx: literal)?, $form_accessor: expr $(=> $form_idx: literal)?,
$app_accessor: expr $(=> $app_idx: literal)?,
})*) => { })*) => {
$( $(
paste! { paste! {
@ -260,12 +267,12 @@ macro_rules! form_input {
fn [<$name:snake:lower _input>]() -> Html { fn [<$name:snake:lower _input>]() -> Html {
let app = use_store::<AppStore>(); let app = use_store::<AppStore>();
let form = use_store::<BasicStore<FormState>>(); let form = use_store::<BasicStore<FormState>>();
let oninput = form.dispatch().input(|state, value| state.$accessor $([$idx])? = value.parse::<f64>()); let oninput = form.dispatch().input(|state, value| state.$form_accessor $([$form_idx])? = value.parse::<f64>());
html! { html! {
<FormGroup success={form.state().map(|state| (state.$accessor $([$idx])?).is_ok())}> <FormGroup success={form.state().map(|state| (state.$form_accessor $([$form_idx])?).is_ok())}>
<Input<f64, ParseFloatError> label=$label desc=$desc <Input<f64, ParseFloatError> label=$label desc=$desc
default={app.state().map(|state| state.$accessor $([$idx])?).unwrap_or_else(|| AppState::default().$accessor $([$idx])?)} default={app.state().map(|state| state.$app_accessor $([$app_idx])?).unwrap_or_else(|| AppState::default().$app_accessor $([$app_idx])?)}
parsed={form.state().map(|state| (state.$accessor $([$idx])?).clone())} parsed={form.state().map(|state| (state.$form_accessor $([$form_idx])?).clone())}
oninput={oninput} oninput={oninput}
/> />
</FormGroup> </FormGroup>
@ -281,26 +288,31 @@ form_input! {
"Tolerance", "Tolerance",
"Curve interpolation tolerance (mm)", "Curve interpolation tolerance (mm)",
tolerance, tolerance,
settings.conversion.tolerance,
} }
Feedrate { Feedrate {
"Feedrate", "Feedrate",
"Machine feedrate (mm/min)", "Machine feedrate (mm/min)",
feedrate, feedrate,
settings.conversion.feedrate,
} }
Dpi { Dpi {
"Dots per Inch", "Dots per Inch",
"Used for scaling visual units (pixels, points, picas, etc.)", "Used for scaling visual units (pixels, points, picas, etc.)",
dpi, dpi,
settings.conversion.dpi,
} }
OriginX { OriginX {
"Origin X", "Origin X",
"X-axis coordinate for the bottom left corner of the machine", "X-axis coordinate for the bottom left corner of the machine",
origin => 0, origin => 0,
settings.postprocess.origin => 0,
} }
OriginY { OriginY {
"Origin Y", "Origin Y",
"Y-axis coordinate for the bottom left corner of the machine", "Y-axis coordinate for the bottom left corner of the machine",
origin => 1, origin => 1,
settings.postprocess.origin => 1,
} }
} }
@ -357,18 +369,7 @@ pub fn settings_form() -> Html {
let close_ref = close_ref.clone(); let close_ref = close_ref.clone();
app.dispatch().reduce_callback(move |app| { app.dispatch().reduce_callback(move |app| {
if let (false, Some(form)) = (disabled, form.state()) { if let (false, Some(form)) = (disabled, form.state()) {
app.tolerance = *form.tolerance.as_ref().unwrap(); app.settings = form.as_ref().try_into().unwrap();
app.feedrate = *form.feedrate.as_ref().unwrap();
app.origin = [
*form.origin[0].as_ref().unwrap(),
*form.origin[1].as_ref().unwrap(),
];
app.circular_interpolation = form.circular_interpolation;
app.dpi = *form.dpi.as_ref().unwrap();
app.tool_on_sequence = form.tool_on_sequence.clone().and_then(Result::ok);
app.tool_off_sequence = form.tool_off_sequence.clone().and_then(Result::ok);
app.begin_sequence = form.begin_sequence.clone().and_then(Result::ok);
app.end_sequence = form.end_sequence.clone().and_then(Result::ok);
// TODO: this is a poor man's crutch for closing the Modal. // TODO: this is a poor man's crutch for closing the Modal.
// There is probably a better way. // There is probably a better way.
@ -384,7 +385,17 @@ pub fn settings_form() -> Html {
id="settings" id="settings"
header={ header={
html!( html!(
<h5>{ "Settings" }</h5> <>
<h2>{ "Settings" }</h2>
<p>{"Persisted using "}
// Opening new tabs is usually bad.
// But if we don't, the user is at risk of losing the settings they've entered so far.
<a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage" target="_blank">
{"local storage"}
</a>
{"."}
</p>
</>
) )
} }
body={html!( body={html!(
@ -411,15 +422,6 @@ pub fn settings_form() -> Html {
footer={ footer={
html!( html!(
<> <>
<p>
{"These settings are persisted using local storage. Learn more "}
// Opening new tabs is usually bad.
// But if we don't, the user is at risk of losing the settings they've entered so far.
<a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage" target="_blank">
{"on MDN"}
</a>
{"."}
</p>
<Button <Button
style={ButtonStyle::Primary} style={ButtonStyle::Primary}
disabled={disabled} disabled={disabled}
@ -436,8 +438,120 @@ pub fn settings_form() -> Html {
</> </>
) )
} }
> />
</Modal> }
}
#[function_component(ImportExportModal)]
pub fn import_export_modal() -> Html {
let app = use_store::<AppStore>();
let import_state = use_state(|| Option::<Result<Settings, String>>::None);
let export_onclick =
app.dispatch()
.reduce_callback(|app| match serde_json::to_vec_pretty(&app.settings) {
Ok(settings_json_bytes) => {
let filename = format!("svg2gcode_settings");
let filepath = Path::new(&filename).with_extension("json");
crate::util::prompt_download(&filepath, &settings_json_bytes);
}
Err(serde_json_err) => {}
});
let settings_upload_onchange = {
let import_state = import_state.clone();
app.dispatch()
.future_callback_with(move |app, file_list: FileList| {
let import_state = import_state.clone();
async move {
let file = file_list.item(0).unwrap();
let filename = file.name();
let res = read_as_bytes(&gloo_file::File::from(file))
.await
.map_err(|err| format!("Error reading {}: {}", &filename, err))
.and_then(|bytes| {
serde_json::from_slice::<Settings>(&bytes)
.map_err(|err| format!("Error parsing {}: {}", &filename, err))
});
match res {
Ok(settings) => {
import_state.set(Some(Ok(settings)));
}
Err(err) => {
import_state.set(Some(Err(err)));
}
}
}
})
};
let import_save_onclick = {
let import_state = import_state.clone();
app.dispatch().reduce_callback(move |app| {
if let Some(Ok(ref settings)) = *import_state {
app.settings = settings.clone();
import_state.set(None);
}
})
};
let close_ref = NodeRef::default();
html! {
<Modal
id="import_export"
header={html!(
<>
<h2>{"Import/Export Settings"}</h2>
<p>{"Uses JSON, compatible with the "}<a href="https://github.com/sameer/svg2gcode/releases">{"command line interface"}</a>{"."}</p>
</>
)}
body={
html!(
<>
<h3>{"Import"}</h3>
<FormGroup success={import_state.as_ref().map(Result::is_ok)}>
<FileUpload<Settings, String>
label="Select settings JSON file"
accept=".json"
multiple={false}
onchange={settings_upload_onchange}
parsed={(*import_state).clone()}
button={html_nested!(
<Button
style={ButtonStyle::Primary}
disabled={import_state.is_none()}
title="Save"
onclick={import_save_onclick}
/>
)}
/>
</FormGroup>
<h3>{"Export"}</h3>
<Button
style={ButtonStyle::Primary}
disabled={false}
title="Export"
icon={html_nested!(<Icon name={IconName::Download}/>)}
onclick={export_onclick}
/>
</>
)
}
footer={
html!(
<HyperlinkButton
ref={close_ref}
style={ButtonStyle::Default}
title="Close"
href="#close"
/>
)
}
/>
} }
} }
@ -527,7 +641,7 @@ pub fn svg_input() -> Html {
url_input_parsed.set(None); url_input_parsed.set(None);
let res = JsFuture::from(window().unwrap().fetch_with_str(&request_url)) let res = JsFuture::from(window().unwrap().fetch_with_str(&request_url))
.await .await
.map(|res| res.dyn_into::<Response>().unwrap()); .map(JsCast::unchecked_into::<Response>);
url_add_loading.set(false); url_add_loading.set(false);
match res { match res {
Ok(res) => { Ok(res) => {

@ -6,21 +6,19 @@ use g_code::{
}; };
use log::Level; use log::Level;
use roxmltree::Document; use roxmltree::Document;
use svg2gcode::{ use svg2gcode::{set_origin, svg2program, ConversionOptions, Machine, Turtle};
set_origin, svg2program, ConversionOptions, Machine, SupportedFunctionality, Turtle, use yew::prelude::*;
};
use wasm_bindgen::JsCast;
use web_sys::HtmlElement;
use yew::{prelude::*, utils::window};
use yewdux::prelude::{Dispatch, Dispatcher}; use yewdux::prelude::{Dispatch, Dispatcher};
mod inputs; mod inputs;
mod spectre; mod spectre;
mod state; mod state;
mod util;
use inputs::*; use inputs::*;
use spectre::*; use spectre::*;
use state::*; use state::*;
use util::*;
struct App { struct App {
app_dispatch: Dispatch<AppStore>, app_dispatch: Dispatch<AppStore>,
@ -73,34 +71,38 @@ impl Component for App {
self.link.send_future(async move { self.link.send_future(async move {
for svg in app_state.svgs.iter() { for svg in app_state.svgs.iter() {
let options = ConversionOptions { let options = ConversionOptions {
tolerance: app_state.tolerance, dimensions: svg.dimensions,
feedrate: app_state.feedrate,
dpi: app_state.dpi,
dimensions: [None; 2],
}; };
let machine = Machine::new( let machine = Machine::new(
SupportedFunctionality { app_state.settings.machine.supported_functionality.clone(),
circular_interpolation: app_state.circular_interpolation,
},
app_state app_state
.settings
.machine
.tool_on_sequence .tool_on_sequence
.as_deref() .as_deref()
.map(snippet_parser) .map(snippet_parser)
.transpose() .transpose()
.unwrap(), .unwrap(),
app_state app_state
.settings
.machine
.tool_off_sequence .tool_off_sequence
.as_deref() .as_deref()
.map(snippet_parser) .map(snippet_parser)
.transpose() .transpose()
.unwrap(), .unwrap(),
app_state app_state
.settings
.machine
.begin_sequence .begin_sequence
.as_deref() .as_deref()
.map(snippet_parser) .map(snippet_parser)
.transpose() .transpose()
.unwrap(), .unwrap(),
app_state app_state
.settings
.machine
.end_sequence .end_sequence
.as_deref() .as_deref()
.map(snippet_parser) .map(snippet_parser)
@ -110,30 +112,23 @@ impl Component for App {
let document = Document::parse(svg.content.as_str()).unwrap(); let document = Document::parse(svg.content.as_str()).unwrap();
let mut turtle = Turtle::new(machine); let mut turtle = Turtle::new(machine);
let mut program = svg2program(&document, options, &mut turtle); let mut program = svg2program(
&document,
&app_state.settings.conversion,
options,
&mut turtle,
);
set_origin(&mut program, app_state.origin); set_origin(&mut program, app_state.settings.postprocess.origin);
let gcode_base64 = { let gcode = {
let mut acc = String::new(); let mut acc = String::new();
format_gcode_fmt(&program, FormatOptions::default(), &mut acc).unwrap(); format_gcode_fmt(&program, FormatOptions::default(), &mut acc).unwrap();
base64::encode(acc.as_bytes()) acc
}; };
let window = window();
let document = window.document().unwrap();
let hyperlink = document.create_element("a").unwrap();
let filepath = Path::new(svg.filename.as_str()).with_extension("gcode"); let filepath = Path::new(svg.filename.as_str()).with_extension("gcode");
let filename = filepath.to_str().unwrap(); prompt_download(filepath, &gcode.as_bytes());
hyperlink
.set_attribute(
"href",
&format!("data:text/plain;base64,{}", gcode_base64),
)
.unwrap();
hyperlink.set_attribute("download", filename).unwrap();
hyperlink.unchecked_into::<HtmlElement>().click();
} }
AppMsg::Done AppMsg::Done
@ -159,7 +154,7 @@ impl Component for App {
// Having separate stores is somewhat of an anti-pattern in Redux, // Having separate stores is somewhat of an anti-pattern in Redux,
// but there's no easy way to do hydration after the app state is // but there's no easy way to do hydration after the app state is
// restored from local storage. // restored from local storage.
let hydrated_form_state = FormState::from(self.app_state.as_ref()); let hydrated_form_state = FormState::from(&self.app_state.settings);
let settings_hydrate_onclick = self.form_dispatch.reduce_callback_once(move |form| { let settings_hydrate_onclick = self.form_dispatch.reduce_callback_once(move |form| {
*form = hydrated_form_state; *form = hydrated_form_state;
}); });
@ -226,8 +221,15 @@ impl Component for App {
onclick={settings_hydrate_onclick} onclick={settings_hydrate_onclick}
href="#settings" href="#settings"
/> />
<HyperlinkButton
title="Import/Export"
style={ButtonStyle::Default}
icon={IconName::Copy}
href="#import_export"
/>
</ButtonGroup> </ButtonGroup>
<SettingsForm /> <SettingsForm/>
<ImportExportModal/>
</div> </div>
<div class={classes!("text-right", "column")}> <div class={classes!("text-right", "column")}>
<p> <p>

@ -96,23 +96,23 @@ where
<label class="form-label" for={id.clone()}> <label class="form-label" for={id.clone()}>
{ props.label } { props.label }
</label> </label>
<div class={classes!(if success || error { Some("has-icon-right") } else { None })}> <div class={classes!(if props.button.is_some() { Some("input-group") } else { None })}>
<div class={classes!(if props.button.is_some() { Some("input-group") } else { None })}> <div class={classes!(if props.button.is_some() { Some("input-group") } else { None }, if success || error { Some("has-icon-right") } else { None })}>
<input id={id} class="form-input" type={props.r#type.to_string()} ref={(*node_ref).clone()} <input id={id} class="form-input" type={props.r#type.to_string()} ref={(*node_ref).clone()}
oninput={props.oninput.clone()} placeholder={ props.placeholder.as_ref().map(ToString::to_string) } oninput={props.oninput.clone()} placeholder={ props.placeholder.as_ref().map(ToString::to_string) }
/> />
{ props.button.clone().map(Html::from).unwrap_or_default() } {
</div> if let Some(parsed) = props.parsed.as_ref() {
{ match parsed {
if let Some(parsed) = props.parsed.as_ref() { Ok(_) => html!(<Icon form=true name={IconName::Check}/>),
match parsed { Err(_) => html!(<Icon form=true name={IconName::Cross}/>)
Ok(_) => html!(<Icon form=true name={IconName::Check}/>), }
Err(_) => html!(<Icon form=true name={IconName::Cross}/>) } else {
html!()
} }
} else {
html!()
} }
} </div>
{ props.button.clone().map(Html::from).unwrap_or_default() }
</div> </div>
{ {
if let Some(Err(ref err)) = props.parsed.as_ref() { if let Some(Err(ref err)) = props.parsed.as_ref() {
@ -171,6 +171,8 @@ where
pub parsed: Option<Result<T, E>>, pub parsed: Option<Result<T, E>>,
#[prop_or_default] #[prop_or_default]
pub onchange: Callback<FileList>, pub onchange: Callback<FileList>,
#[prop_or_default]
pub button: Option<VChild<Button>>,
} }
#[function_component(FileUpload)] #[function_component(FileUpload)]
@ -187,25 +189,28 @@ where
<label class="form-label" for={id.clone()}> <label class="form-label" for={id.clone()}>
{ props.label } { props.label }
</label> </label>
<div class={classes!(if success || error { Some("has-icon-right") } else { None })}> <div class={classes!(if props.button.is_some() { Some("input-group") } else { None })}>
<input id={id} class="form-input" type="file" accept={props.accept} multiple={props.multiple} <div class={classes!(if props.button.is_some() { Some("input-group") } else { None }, if success || error { Some("has-icon-right") } else { None })}>
onchange={props.onchange.clone().reform(|x: ChangeData| { <input id={id} class="form-input" type="file" accept={props.accept} multiple={props.multiple}
match x { onchange={props.onchange.clone().reform(|x: ChangeData| {
ChangeData::Files(file_list) => file_list, match x {
_ => unreachable!() ChangeData::Files(file_list) => file_list,
} _ => unreachable!()
})} }
/> })}
{ />
if let Some(parsed) = props.parsed.as_ref() { {
match parsed { if let Some(parsed) = props.parsed.as_ref() {
Ok(_) => html!(<Icon form=true name={IconName::Check}/>), match parsed {
Err(_) => html!(<Icon form=true name={IconName::Cross}/>) Ok(_) => html!(<Icon form=true name={IconName::Check}/>),
Err(_) => html!(<Icon form=true name={IconName::Cross}/>)
}
} else {
html!()
} }
} else {
html!()
} }
} </div>
{ props.button.clone().map(Html::from).unwrap_or_default() }
</div> </div>
{ {
if let Some(Err(ref err)) = props.parsed.as_ref() { if let Some(Err(ref err)) = props.parsed.as_ref() {

@ -1,5 +1,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::num::ParseFloatError; use std::{convert::TryInto, num::ParseFloatError};
use svg2gcode::{
Settings, ConversionConfig, MachineConfig, PostprocessConfig, SupportedFunctionality,
};
use svgtypes::Length; use svgtypes::Length;
use yewdux::prelude::{BasicStore, Persistent, PersistentStore}; use yewdux::prelude::{BasicStore, Persistent, PersistentStore};
@ -19,22 +22,75 @@ pub struct FormState {
impl Default for FormState { impl Default for FormState {
fn default() -> Self { fn default() -> Self {
let app_state = AppState::default(); let app_state = AppState::default();
Self::from(&app_state) Self::from(&app_state.settings)
} }
} }
impl From<&AppState> for FormState { impl<'a> TryInto<Settings> for &'a FormState {
fn from(app_state: &AppState) -> Self { type Error = ParseFloatError;
fn try_into(self) -> Result<Settings, Self::Error> {
Ok(Settings {
conversion: ConversionConfig {
tolerance: self.tolerance.clone()?,
feedrate: self.feedrate.clone()?,
dpi: self.dpi.clone()?,
},
machine: MachineConfig {
supported_functionality: SupportedFunctionality {
circular_interpolation: self.circular_interpolation,
},
tool_on_sequence: self.tool_on_sequence.clone().and_then(Result::ok),
tool_off_sequence: self.tool_off_sequence.clone().and_then(Result::ok),
begin_sequence: self.begin_sequence.clone().and_then(Result::ok),
end_sequence: self.end_sequence.clone().and_then(Result::ok),
},
postprocess: PostprocessConfig {
origin: [self.origin[0].clone()?, self.origin[1].clone()?],
},
})
}
}
impl From<&Settings> for FormState {
fn from(settings: &Settings) -> Self {
Self { Self {
tolerance: Ok(app_state.tolerance), tolerance: Ok(settings.conversion.tolerance),
feedrate: Ok(app_state.feedrate), feedrate: Ok(settings.conversion.feedrate),
circular_interpolation: app_state.circular_interpolation, circular_interpolation:
origin: [Ok(app_state.origin[0]), Ok(app_state.origin[1])], settings
dpi: Ok(app_state.dpi), .machine
tool_on_sequence: app_state.tool_on_sequence.clone().map(Result::Ok), .supported_functionality
tool_off_sequence: app_state.tool_off_sequence.clone().map(Result::Ok), .circular_interpolation,
begin_sequence: app_state.begin_sequence.clone().map(Result::Ok), origin: [
end_sequence: app_state.end_sequence.clone().map(Result::Ok), Ok(settings.postprocess.origin[0]),
Ok(settings.postprocess.origin[1]),
],
dpi: Ok(settings.conversion.dpi),
tool_on_sequence:
settings
.machine
.tool_on_sequence
.clone()
.map(Result::Ok),
tool_off_sequence:
settings
.machine
.tool_off_sequence
.clone()
.map(Result::Ok),
begin_sequence:
settings
.machine
.begin_sequence
.clone()
.map(Result::Ok),
end_sequence:
settings
.machine
.end_sequence
.clone()
.map(Result::Ok),
} }
} }
} }
@ -44,15 +100,7 @@ pub type AppStore = PersistentStore<AppState>;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppState { pub struct AppState {
pub first_visit: bool, pub first_visit: bool,
pub tolerance: f64, pub settings: Settings,
pub feedrate: f64,
pub origin: [f64; 2],
pub circular_interpolation: bool,
pub dpi: f64,
pub tool_on_sequence: Option<String>,
pub tool_off_sequence: Option<String>,
pub begin_sequence: Option<String>,
pub end_sequence: Option<String>,
#[serde(skip)] #[serde(skip)]
pub svgs: Vec<Svg>, pub svgs: Vec<Svg>,
} }
@ -68,15 +116,7 @@ impl Default for AppState {
fn default() -> Self { fn default() -> Self {
Self { Self {
first_visit: true, first_visit: true,
tolerance: 0.002, settings: Settings::default(),
feedrate: 300.,
origin: [0., 0.],
circular_interpolation: false,
dpi: 96.,
tool_on_sequence: None,
tool_off_sequence: None,
begin_sequence: None,
end_sequence: None,
svgs: vec![], svgs: vec![],
} }
} }

@ -0,0 +1,17 @@
use std::path::Path;
use wasm_bindgen::JsCast;
use web_sys::{HtmlElement, window};
pub fn prompt_download(path: impl AsRef<Path>, content: impl AsRef<[u8]>) {
let window = window().unwrap();
let document = window.document().unwrap();
let hyperlink = document.create_element("a").unwrap();
let content_base64 = base64::encode(content);
hyperlink
.set_attribute("href", &format!("data:text/plain;base64,{}", content_base64))
.unwrap();
hyperlink
.set_attribute("download", &path.as_ref().display().to_string())
.unwrap();
hyperlink.unchecked_into::<HtmlElement>().click();
}

@ -11,3 +11,12 @@ div.card-container {
margin-top: 1em; margin-top: 1em;
margin-bottom: 1em; margin-bottom: 1em;
} }
div.modal-container {
max-height: 90vh !important;
}
div.input-group,
div.has-icon-right {
flex: auto;
}

Loading…
Cancel
Save