Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 70 additions & 24 deletions lib/src/converter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ use self::units::CSS_DEFAULT_DPI;
use crate::{
Machine, Turtle, tsp,
turtle::{
DpiConvertingTurtle, GCodeTurtle, PreprocessTurtle, StrokeCollectingTurtle, Terrarium,
DpiConvertingTurtle, GCodeTurtle, PreprocessTurtle, StrokeCollectingTurtle,
SvgPreviewTurtle, Terrarium,
},
};

Expand Down Expand Up @@ -180,30 +181,51 @@ pub fn svg2program<'a, 'input: 'a>(
conversion_visitor.begin();

if config.optimize_path_order {
// Collect strokes in machine space
let strokes = {
let mut collect_visitor = ConversionVisitor {
terrarium: Terrarium::new(DpiConvertingTurtle {
inner: StrokeCollectingTurtle::default(),
dpi: config.dpi,
}),
_config: config,
options,
name_stack: vec![],
viewport_dim_stack: vec![],
};
collect_visitor.terrarium.push_transform(origin_transform);
collect_visitor.begin();
visit::depth_first_visit(doc, &mut collect_visitor);
collect_visitor.end();
collect_visitor.terrarium.pop_transform();
collect_visitor.terrarium.turtle.inner.into_strokes()
};
let strokes = svg2strokes_optimized(doc, config, options, origin_transform);
let turtle = &mut conversion_visitor.terrarium.turtle;
for stroke in strokes {
turtle.move_to(stroke.start_point());
for cmd in stroke.commands() {
cmd.apply(turtle);
}
}
} else {
visit::depth_first_visit(doc, &mut conversion_visitor);
}

// Optimize order
let strokes = tsp::minimize_travel_time(strokes);
conversion_visitor.end();
conversion_visitor.terrarium.pop_transform();

conversion_visitor.terrarium.turtle.inner.program
}

// Replay reordered strokes into the g-code turtle
/// Converts an SVG [`Document`] into a preview SVG showing expected toolpath moves.
///
/// - red: tool-on moves (G1/G2/G3)
/// - green: rapid tool-off moves (G0)
pub fn svg2preview(
doc: &Document,
config: &ConversionConfig,
options: ConversionOptions,
) -> String {
let mut conversion_visitor = ConversionVisitor {
terrarium: Terrarium::new(DpiConvertingTurtle {
inner: SvgPreviewTurtle::default(),
dpi: config.dpi,
}),
_config: config,
options: options.clone(),
name_stack: vec![],
viewport_dim_stack: vec![],
};

conversion_visitor
.terrarium
.push_transform(Transform2D::identity());
conversion_visitor.begin();

if config.optimize_path_order {
let strokes = svg2strokes_optimized(doc, config, options, Transform2D::identity());
let turtle = &mut conversion_visitor.terrarium.turtle;
for stroke in strokes {
turtle.move_to(stroke.start_point());
Expand All @@ -217,8 +239,32 @@ pub fn svg2program<'a, 'input: 'a>(

conversion_visitor.end();
conversion_visitor.terrarium.pop_transform();
conversion_visitor.terrarium.turtle.inner.into_preview()
}

conversion_visitor.terrarium.turtle.inner.program
fn svg2strokes_optimized(
doc: &Document,
config: &ConversionConfig,
options: ConversionOptions,
origin_transform: Transform2D<f64>,
) -> Vec<crate::turtle::Stroke> {
let mut collect_visitor = ConversionVisitor {
terrarium: Terrarium::new(DpiConvertingTurtle {
inner: StrokeCollectingTurtle::default(),
dpi: config.dpi,
}),
_config: config,
options,
name_stack: vec![],
viewport_dim_stack: vec![],
};
collect_visitor.terrarium.push_transform(origin_transform);
collect_visitor.begin();
visit::depth_first_visit(doc, &mut collect_visitor);
collect_visitor.end();
collect_visitor.terrarium.pop_transform();
let strokes = collect_visitor.terrarium.turtle.inner.into_strokes();
tsp::minimize_travel_time(strokes)
}

fn node_name(node: &Node, attr_to_print: &Option<String>) -> String {
Expand Down
2 changes: 1 addition & 1 deletion lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ mod tsp;
/// This concept is referred to as [Turtle graphics](https://en.wikipedia.org/wiki/Turtle_graphics).
mod turtle;

pub use converter::{ConversionConfig, ConversionOptions, svg2program};
pub use converter::{ConversionConfig, ConversionOptions, svg2preview, svg2program};
pub use machine::{Machine, MachineConfig, SupportedFunctionality};
pub use postprocess::PostprocessConfig;
pub use turtle::Turtle;
Expand Down
4 changes: 3 additions & 1 deletion lib/src/turtle/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ mod dpi;
mod elements;
mod g_code;
mod preprocess;
mod svg_preview;

pub use self::{
collect::StrokeCollectingTurtle, dpi::DpiConvertingTurtle, elements::Stroke,
g_code::GCodeTurtle, preprocess::PreprocessTurtle,
g_code::GCodeTurtle, preprocess::PreprocessTurtle, svg_preview::SvgPreviewTurtle,
};

/// Abstraction for drawing paths based on [Turtle graphics](https://en.wikipedia.org/wiki/Turtle_graphics)
Expand Down
142 changes: 142 additions & 0 deletions lib/src/turtle/svg_preview.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
use std::fmt::Write;

use lyon_geom::{Box2D, CubicBezierSegment, Point, QuadraticBezierSegment, SvgArc};

use super::Turtle;

/// Builds an SVG preview of the toolpath:
/// - Red solid: tool-on moves (line_to, arc, cubic_bezier, quadratic_bezier)
/// - Green dashed: rapid/tool-off moves (move_to, when position changes)
///
/// Coordinates arrive in GCode space (Y-flipped, mm). The viewBox is derived
/// from the accumulated bounding box, so the image is self-consistent.
#[derive(Debug, Default)]
pub struct SvgPreviewTurtle {
tool_on_paths: String,
rapid_paths: String,
bounding_box: Option<Box2D<f64>>,
current_pos: Point<f64>,
current_tool_on_d: String,
}

impl SvgPreviewTurtle {
fn add_box(&mut self, bb: Box2D<f64>) {
self.bounding_box = Some(
self.bounding_box
// Box2D::union discards empty boxes
.map(|existing| Box2D::from_points([existing.min, existing.max, bb.min, bb.max]))
.unwrap_or(bb),
);
}

fn add_point(&mut self, p: Point<f64>) {
self.add_box(Box2D { min: p, max: p });
}

fn flush_tool_on(&mut self) {
if !self.current_tool_on_d.is_empty() {
writeln!(
self.tool_on_paths,
"<path d=\"{}\" stroke=\"red\" fill=\"none\" stroke-width=\"1\" vector-effect=\"non-scaling-stroke\"/>",
self.current_tool_on_d
)
.unwrap();
self.current_tool_on_d.clear();
}
}

pub fn into_preview(mut self) -> String {
self.flush_tool_on();
const PADDING: f64 = 2.0;
match self.bounding_box {
None => {
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 1 1\"></svg>\n".to_string()
}
Some(bb) => {
let vb_x = bb.min.x - PADDING;
let vb_y = bb.min.y - PADDING;
let vb_w = (bb.max.x - bb.min.x + 2.0 * PADDING).max(1.0);
let vb_h = (bb.max.y - bb.min.y + 2.0 * PADDING).max(1.0);
let flip_ty = -(bb.min.y + bb.max.y);
format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{vb_x} {vb_y} {vb_w} {vb_h}\">\n<g transform=\"scale(1,-1) translate(0,{flip_ty})\">\n{}{}</g>\n</svg>\n",
self.rapid_paths, self.tool_on_paths,
)
}
}
}
}

impl Turtle for SvgPreviewTurtle {
fn begin(&mut self) {}

fn end(&mut self) {
self.flush_tool_on();
}

fn comment(&mut self, _: String) {}

fn move_to(&mut self, to: Point<f64>) {
self.flush_tool_on();
if to != self.current_pos {
writeln!(
self.rapid_paths,
"<path d=\"M {},{} L {},{}\" stroke=\"green\" fill=\"none\" stroke-width=\"1\" stroke-dasharray=\"4 3\" vector-effect=\"non-scaling-stroke\"/>",
self.current_pos.x, self.current_pos.y, to.x, to.y,
)
.unwrap();
self.add_point(to);
}
self.current_pos = to;
write!(self.current_tool_on_d, "M {},{} ", to.x, to.y).unwrap();
}

fn line_to(&mut self, to: Point<f64>) {
write!(self.current_tool_on_d, "L {},{} ", to.x, to.y).unwrap();
self.add_point(to);
self.current_pos = to;
}

fn arc(&mut self, svg_arc: SvgArc<f64>) {
if svg_arc.is_straight_line() {
self.line_to(svg_arc.to);
return;
}
write!(
self.current_tool_on_d,
"A {},{} {} {} {} {},{} ",
svg_arc.radii.x,
svg_arc.radii.y,
svg_arc.x_rotation.to_degrees(),
if svg_arc.flags.large_arc { 1 } else { 0 },
if svg_arc.flags.sweep { 1 } else { 0 },
svg_arc.to.x,
svg_arc.to.y,
)
.unwrap();
self.add_box(svg_arc.to_arc().bounding_box());
self.current_pos = svg_arc.to;
}

fn cubic_bezier(&mut self, cbs: CubicBezierSegment<f64>) {
write!(
self.current_tool_on_d,
"C {},{} {},{} {},{} ",
cbs.ctrl1.x, cbs.ctrl1.y, cbs.ctrl2.x, cbs.ctrl2.y, cbs.to.x, cbs.to.y,
)
.unwrap();
self.add_box(cbs.bounding_box());
self.current_pos = cbs.to;
}

fn quadratic_bezier(&mut self, qbs: QuadraticBezierSegment<f64>) {
write!(
self.current_tool_on_d,
"Q {},{} {},{} ",
qbs.ctrl.x, qbs.ctrl.y, qbs.to.x, qbs.to.y,
)
.unwrap();
self.add_box(qbs.bounding_box());
self.current_pos = qbs.to;
}
}
2 changes: 1 addition & 1 deletion web/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ zip = { version = "0.6", default-features = false }

yew = { version = "0.21", features = ["csr"] }
yewdux = "0.11"
web-sys = { version = "0.3", features = [] }
web-sys = { version = "0.3", features = ["Blob", "BlobPropertyBag", "Url", "Window"] }
wasm-logger = "0.2"
gloo-file = { version = "0.3", features = ["futures"] }
gloo-timers = "0.3"
Expand Down
29 changes: 27 additions & 2 deletions web/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use g_code::{
use js_sys::Date;
use log::Level;
use roxmltree::{Document, ParsingOptions};
use svg2gcode::{ConversionOptions, Machine, svg2program};
use svg2gcode::{ConversionOptions, Machine, svg2preview, svg2program};
use yew::prelude::*;

mod forms;
Expand Down Expand Up @@ -216,6 +216,21 @@ fn app() -> Html {
{
for app_store.svgs.iter().enumerate().map(|(i, svg)| {
let svg_base64 = base64::engine::general_purpose::STANDARD_NO_PAD.encode(svg.content.as_bytes());
let preview_svg = Document::parse_with_options(
svg.content.as_str(),
ParsingOptions { allow_dtd: true, ..Default::default() },
)
.ok()
.map(|doc| {
let options = ConversionOptions { dimensions: svg.dimensions };
svg2preview(&doc, &app_store.settings.conversion, options)
})
.unwrap_or_default();
let preview_svg_base64 = base64::engine::general_purpose::STANDARD_NO_PAD.encode(preview_svg.as_bytes());
let open_preview = {
let bytes = preview_svg.into_bytes();
Callback::from(move |_: MouseEvent| open_svg_in_new_tab(&bytes))
};
let remove_svg_onclick = app_dispatch.reduce_mut_callback(move |app| {
app.svgs.remove(i);
});
Expand All @@ -236,7 +251,17 @@ fn app() -> Html {
<Card
title={svg.filename.clone()}
img={html_nested!(
<img class="img-responsive" src={format!("data:image/svg+xml;base64,{}", svg_base64)} alt={svg.filename.clone()} />
<div class={classes!("columns", "preview-columns")}>
<div class="column col-5">
<p class="text-center"><small>{"Original"}</small></p>
<img class="img-responsive" src={format!("data:image/svg+xml;base64,{}", svg_base64)} alt={svg.filename.clone()} />
</div>
<div class="divider-vert"></div>
<div class="column col-5">
<p class="text-center"><small>{"Toolpath Preview"}</small></p>
<img class="img-responsive" style="cursor:pointer" src={format!("data:image/svg+xml;base64,{}", preview_svg_base64)} alt="toolpath preview" onclick={open_preview} />
</div>
</div>
)}
footer={footer}
/>
Expand Down
14 changes: 14 additions & 0 deletions web/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ use base64::Engine;
use wasm_bindgen::JsCast;
use web_sys::{HtmlElement, window};

pub fn open_svg_in_new_tab(svg_bytes: &[u8]) {
let array = js_sys::Uint8Array::from(svg_bytes);
let parts = js_sys::Array::new();
parts.push(&array);
let props = web_sys::BlobPropertyBag::new();
props.set_type("image/svg+xml");
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &props).unwrap();
let url = web_sys::Url::create_object_url_with_blob(&blob).unwrap();
web_sys::window()
.unwrap()
.open_with_url_and_target(&url, "_blank")
.unwrap();
}

pub fn prompt_download(path: impl AsRef<Path>, content: impl AsRef<[u8]>) {
let window = window().unwrap();
let document = window.document().unwrap();
Expand Down
19 changes: 19 additions & 0 deletions web/style/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ div.card {
img {
max-height: 30em;
}
.preview-columns {
align-items: center;
img {
display: block;
height: 20em;
object-fit: contain;
max-height: unset;
width: 100%;
}
.divider-vert {
flex: none;
align-self: stretch;
&::before {
top: 0;
bottom: 0;
height: auto;
}
}
}
}
}

Expand Down