You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

498 lines
17 KiB

use std::fmt::Debug;
use std::str::FromStr;
use g_code::emit::Token;
use log::{debug, warn};
use lyon_geom::{
euclid::{default::Transform2D, Angle, Transform3D},
point, vector, ArcFlags,
};
use roxmltree::{Document, Node};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use svgtypes::{
Length, LengthListParser, PathParser, PathSegment, TransformListParser, TransformListToken,
ViewBox,
};
use crate::{turtle::*, Machine};
#[cfg(feature = "serde")]
mod length_serde;
mod visit;
const SVG_TAG_NAME: &str = "svg";
const CLIP_PATH_TAG_NAME: &str = "clipPath";
const PATH_TAG_NAME: &str = "path";
/// High-level output configuration
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ConversionConfig {
/// Curve interpolation tolerance in millimeters
pub tolerance: f64,
/// Feedrate in millimeters / minute
pub feedrate: f64,
/// Dots per inch for pixels, picas, points, etc.
pub dpi: f64,
/// Set the origin point for this conversion
#[cfg_attr(feature = "serde", serde(default = "zero_origin"))]
pub origin: [Option<f64>; 2],
}
const fn zero_origin() -> [Option<f64>; 2] {
[Some(0.); 2]
}
impl Default for ConversionConfig {
fn default() -> Self {
Self {
tolerance: 0.002,
feedrate: 300.0,
dpi: 96.0,
origin: [Some(0.); 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],
}
#[derive(Debug)]
struct ConversionVisitor<'a, T: Turtle> {
terrarium: Terrarium<T>,
name_stack: Vec<String>,
config: &'a ConversionConfig,
options: ConversionOptions,
}
impl<'a, 'input: 'a> ConversionVisitor<'a, GCodeTurtle<'input>> {
fn begin(&mut self) {
// Part 1 of converting from SVG to g-code coordinates
self.terrarium.push_transform(Transform2D::scale(1., -1.));
self.terrarium.turtle.begin();
}
fn end(&mut self) {
self.terrarium.pop_transform();
self.terrarium.turtle.end();
}
}
impl<'a, 'input: 'a> ConversionVisitor<'a, PreprocessTurtle> {
fn begin(&mut self) {
// Part 1 of converting from SVG to g-code coordinates
self.terrarium.push_transform(Transform2D::scale(1., -1.));
}
fn end(&mut self) {
self.terrarium.pop_transform();
}
}
impl<'a, T: Turtle> visit::XmlVisitor for ConversionVisitor<'a, T> {
fn visit(&mut self, node: Node) {
if node.tag_name().name() == CLIP_PATH_TAG_NAME {
warn!("Clip paths are not supported: {:?}", node);
}
let mut transforms = vec![];
let view_box = node
.attribute("viewBox")
.map(ViewBox::from_str)
.transpose()
.expect("could not parse viewBox");
let scale_w = view_box.map(|view_box| view_box.w);
let scale_h = view_box.map(|view_box| view_box.h);
let dimensions = (
node.attribute("width")
.map(LengthListParser::from)
.and_then(|mut parser| parser.next())
.transpose()
.expect("could not parse width")
.map(|width| length_to_mm(width, self.config.dpi, scale_w)),
node.attribute("height")
.map(LengthListParser::from)
.and_then(|mut parser| parser.next())
.transpose()
.expect("could not parse height")
.map(|height| length_to_mm(height, self.config.dpi, scale_h)),
);
let aspect_ratio = match (view_box, dimensions) {
(_, (Some(ref width), Some(ref height))) => *width / *height,
(Some(ref view_box), _) => view_box.w / view_box.h,
(None, (None, _)) | (None, (_, None)) => 1.,
};
if let Some(ref view_box) = view_box {
let view_box_transform = Transform2D::translation(-view_box.x, -view_box.y)
.then_scale(1. / view_box.w, 1. / view_box.h);
if node.has_tag_name(SVG_TAG_NAME) {
// Part 2 of converting from SVG to g-code coordinates
transforms.push(view_box_transform.then_translate(vector(0., -1.)));
} else {
transforms.push(view_box_transform);
}
}
let dimensions_override = [
self.options.dimensions[0].map(|dim_x| length_to_mm(dim_x, self.config.dpi, scale_w)),
self.options.dimensions[1].map(|dim_y| length_to_mm(dim_y, self.config.dpi, scale_h)),
];
match (dimensions_override, dimensions) {
([Some(dim_x), Some(dim_y)], _) if node.has_tag_name(SVG_TAG_NAME) => {
transforms.push(Transform2D::scale(dim_x, dim_y));
}
([Some(dim_x), None], _) if node.has_tag_name(SVG_TAG_NAME) => {
transforms.push(Transform2D::scale(dim_x, dim_x / aspect_ratio));
}
([None, Some(dim_y)], _) if node.has_tag_name(SVG_TAG_NAME) => {
transforms.push(Transform2D::scale(aspect_ratio * dim_y, dim_y));
}
(_, (Some(width), Some(height))) => {
transforms.push(Transform2D::scale(width, height));
}
(_, (Some(width), None)) => {
transforms.push(Transform2D::scale(width, width / aspect_ratio));
}
(_, (None, Some(height))) => {
transforms.push(Transform2D::scale(aspect_ratio * height, height));
}
(_, (None, None)) => {
if let (Some(ViewBox { w, h, .. }), true) =
(view_box, node.has_tag_name(SVG_TAG_NAME))
{
transforms.push(Transform2D::scale(w, h));
warn!("This SVG does not have width and/or height attributes! Assuming viewBox units are in millimeters");
}
}
}
if let Some(transform) = node.attribute("transform") {
let parser = TransformListParser::from(transform);
transforms.extend(
parser
.map(|token| {
token.expect("could not parse a transform in a list of transforms")
})
.map(svg_transform_into_euclid_transform)
.collect::<Vec<_>>()
.iter()
.rev(),
)
}
self.terrarium.push_transform(
transforms
.iter()
.fold(Transform2D::identity(), |acc, t| acc.then(t)),
);
if node.tag_name().name() == PATH_TAG_NAME {
if let Some(d) = node.attribute("d") {
self.terrarium.reset();
let mut comment = String::new();
self.name_stack.iter().for_each(|name| {
comment += name;
comment += " > ";
});
comment += &node_name(&node);
self.terrarium.turtle.comment(comment);
apply_path(&mut self.terrarium, d);
} else {
warn!("There is a path node containing no actual path: {:?}", node);
}
}
if node.first_element_child().is_some() {
self.name_stack.push(node_name(&node));
} else {
// Pop transform since this is the only element that has it
self.terrarium.pop_transform();
let mut parent = Some(node);
while let Some(p) = parent {
if p.next_sibling_element().is_some()
|| p.is_root()
|| p.tag_name().name() == SVG_TAG_NAME
{
break;
}
// Pop the parent transform since this is the last child
self.terrarium.pop_transform();
self.name_stack.pop();
parent = p.parent_element();
}
}
}
}
pub fn svg2program<'a, 'input: 'a>(
doc: &'a Document,
config: &ConversionConfig,
options: ConversionOptions,
machine: Machine<'input>,
) -> Vec<Token<'input>> {
let mut origin_transform = Transform2D::identity();
loop {
let bounding_box = {
let mut visitor = ConversionVisitor {
terrarium: Terrarium::new(PreprocessTurtle::default()),
config,
options: options.clone(),
name_stack: vec![],
};
visitor.terrarium.push_transform(origin_transform);
visitor.begin();
visit::depth_first_visit(doc, &mut visitor);
visitor.end();
visitor.terrarium.pop_transform();
visitor.terrarium.turtle.bounding_box
};
if (!bounding_box.min.x.is_sign_negative() && !bounding_box.min.y.is_sign_negative())
|| bounding_box
.min
.to_vector()
.abs()
.lower_than(vector(std::f64::EPSILON, std::f64::EPSILON))
.all()
{
break;
}
origin_transform = origin_transform.then(&{
let mut transform = Transform2D::identity();
if let Some(origin_x) = config.origin[0] {
transform = transform.then_translate(vector(origin_x - bounding_box.min.x, 0.));
}
if let Some(origin_y) = config.origin[1] {
transform = transform.then_translate(vector(0., origin_y - bounding_box.min.y));
}
transform
});
}
let mut conversion_visitor = ConversionVisitor {
terrarium: Terrarium::new(GCodeTurtle {
machine,
tolerance: config.tolerance,
feedrate: config.feedrate,
program: vec![],
}),
config,
options,
name_stack: vec![],
};
conversion_visitor
.terrarium
.push_transform(origin_transform);
conversion_visitor.begin();
visit::depth_first_visit(doc, &mut conversion_visitor);
conversion_visitor.end();
conversion_visitor.terrarium.pop_transform();
conversion_visitor.terrarium.turtle.program
}
fn node_name(node: &Node) -> String {
let mut name = node.tag_name().name().to_string();
if let Some(id) = node.attribute("id") {
name += "#";
name += id;
}
name
}
fn apply_path<T: Turtle + Debug>(terrarium: &mut Terrarium<T>, path: &str) {
use PathSegment::*;
PathParser::from(path)
.map(|segment| segment.expect("could not parse path segment"))
.for_each(|segment| {
debug!("Drawing {:?}", &segment);
match segment {
MoveTo { abs, x, y } => terrarium.move_to(abs, x, y),
ClosePath { abs: _ } => {
// Ignore abs, should have identical effect: [9.3.4. The "closepath" command]("https://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand)
terrarium.close()
}
LineTo { abs, x, y } => terrarium.line(abs, x, y),
HorizontalLineTo { abs, x } => terrarium.line(abs, x, None),
VerticalLineTo { abs, y } => terrarium.line(abs, None, y),
CurveTo {
abs,
x1,
y1,
x2,
y2,
x,
y,
} => terrarium.cubic_bezier(abs, point(x1, y1), point(x2, y2), point(x, y)),
SmoothCurveTo { abs, x2, y2, x, y } => {
terrarium.smooth_cubic_bezier(abs, point(x2, y2), point(x, y))
}
Quadratic { abs, x1, y1, x, y } => {
terrarium.quadratic_bezier(abs, point(x1, y1), point(x, y))
}
SmoothQuadratic { abs, x, y } => {
terrarium.smooth_quadratic_bezier(abs, point(x, y))
}
EllipticalArc {
abs,
rx,
ry,
x_axis_rotation,
large_arc,
sweep,
x,
y,
} => terrarium.elliptical(
abs,
vector(rx, ry),
Angle::degrees(x_axis_rotation),
ArcFlags { large_arc, sweep },
point(x, y),
),
}
});
}
fn svg_transform_into_euclid_transform(svg_transform: TransformListToken) -> Transform2D<f64> {
use TransformListToken::*;
match svg_transform {
Matrix { a, b, c, d, e, f } => Transform2D::new(a, b, c, d, e, f),
Translate { tx, ty } => Transform2D::translation(tx, ty),
Scale { sx, sy } => Transform2D::scale(sx, sy),
Rotate { angle } => Transform2D::rotation(Angle::degrees(angle)),
// https://drafts.csswg.org/css-transforms/#SkewXDefined
SkewX { angle } => Transform3D::skew(Angle::degrees(angle), Angle::zero()).to_2d(),
// https://drafts.csswg.org/css-transforms/#SkewYDefined
SkewY { angle } => Transform3D::skew(Angle::zero(), Angle::degrees(angle)).to_2d(),
}
}
/// Convenience function for converting absolute lengths to millimeters
///
/// Absolute lengths are listed in [CSS 4 §6.2](https://www.w3.org/TR/css-values/#absolute-lengths).
/// Relative lengths in [CSS 4 §6.1](https://www.w3.org/TR/css-values/#relative-lengths) are not supported and will simply be interpreted as millimeters.
///
/// A default DPI of 96 is used as per [CSS 4 §7.4](https://www.w3.org/TR/css-values/#resolution), which you can adjust with --dpi.
/// Increasing DPI reduces the scale of an SVG.
fn length_to_mm(l: svgtypes::Length, dpi: f64, scale: Option<f64>) -> f64 {
const DEFAULT_SVG_DPI: f64 = 96.;
use svgtypes::LengthUnit::*;
use uom::si::f64::Length;
use uom::si::length::*;
let dpi_scaling = dpi / DEFAULT_SVG_DPI;
let length = match l.unit {
Cm => Length::new::<centimeter>(l.number),
Mm => Length::new::<millimeter>(l.number),
In => Length::new::<inch>(l.number),
Pc => Length::new::<pica_computer>(l.number) / dpi_scaling,
Pt => Length::new::<point_computer>(l.number) / dpi_scaling,
Px => Length::new::<inch>(l.number / dpi_scaling),
Em => {
warn!("Converting from em to millimeters assumes 1em = 16px");
Length::new::<inch>(16. * l.number / dpi_scaling)
}
Percent => {
if let Some(scale) = scale {
warn!("Converting from percent to millimeters assumes the viewBox is specified in millimeters");
Length::new::<millimeter>(l.number / 100. * scale)
} else {
warn!("Converting from percent to millimeters without a viewBox is not possible, treating as millimeters");
Length::new::<millimeter>(l.number)
}
}
other => {
warn!(
"Converting from '{:?}' to millimeters is not supported, treating as millimeters",
other
);
Length::new::<millimeter>(l.number)
}
};
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
);
}
}