From 67be11792f1313f0735e53d19c3c4c927e5b2e5f Mon Sep 17 00:00:00 2001 From: Sameer Puri Date: Sun, 22 Mar 2026 09:31:32 -0700 Subject: [PATCH] Add toolpath preview on web, closes #66 --- lib/src/converter/mod.rs | 94 ++++++++++++++++------ lib/src/lib.rs | 2 +- lib/src/turtle/mod.rs | 4 +- lib/src/turtle/svg_preview.rs | 142 ++++++++++++++++++++++++++++++++++ web/Cargo.toml | 2 +- web/src/main.rs | 29 ++++++- web/src/util.rs | 14 ++++ web/style/main.scss | 19 +++++ 8 files changed, 277 insertions(+), 29 deletions(-) create mode 100644 lib/src/turtle/svg_preview.rs diff --git a/lib/src/converter/mod.rs b/lib/src/converter/mod.rs index cc83e71..f817fb4 100644 --- a/lib/src/converter/mod.rs +++ b/lib/src/converter/mod.rs @@ -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, }, }; @@ -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()); @@ -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, +) -> Vec { + 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 { diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 9679bb7..8f04f68 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -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; diff --git a/lib/src/turtle/mod.rs b/lib/src/turtle/mod.rs index 4a9788e..27eef41 100644 --- a/lib/src/turtle/mod.rs +++ b/lib/src/turtle/mod.rs @@ -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) diff --git a/lib/src/turtle/svg_preview.rs b/lib/src/turtle/svg_preview.rs new file mode 100644 index 0000000..021a10d --- /dev/null +++ b/lib/src/turtle/svg_preview.rs @@ -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>, + current_pos: Point, + current_tool_on_d: String, +} + +impl SvgPreviewTurtle { + fn add_box(&mut self, bb: Box2D) { + 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) { + 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, + "", + 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 => { + "\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!( + "\n\n{}{}\n\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) { + self.flush_tool_on(); + if to != self.current_pos { + writeln!( + self.rapid_paths, + "", + 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) { + 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) { + 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) { + 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) { + 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; + } +} diff --git a/web/Cargo.toml b/web/Cargo.toml index 629d748..cc264cd 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -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" diff --git a/web/src/main.rs b/web/src/main.rs index d85fe82..3c13972 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -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; @@ -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); }); @@ -236,7 +251,17 @@ fn app() -> Html { +
+
+

{"Original"}

+ {svg.filename.clone()} +
+
+
+

{"Toolpath Preview"}

+ toolpath preview +
+
)} footer={footer} /> diff --git a/web/src/util.rs b/web/src/util.rs index 9f1c01c..4fcfd12 100644 --- a/web/src/util.rs +++ b/web/src/util.rs @@ -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, content: impl AsRef<[u8]>) { let window = window().unwrap(); let document = window.document().unwrap(); diff --git a/web/style/main.scss b/web/style/main.scss index 975e2d8..42344df 100644 --- a/web/style/main.scss +++ b/web/style/main.scss @@ -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; + } + } + } } }