diff --git a/Cargo.lock b/Cargo.lock index 28eae46..3fe9d69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1061,6 +1061,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -1150,6 +1159,36 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "regex" version = "1.12.3" @@ -1199,6 +1238,12 @@ dependencies = [ "serde", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustversion" version = "1.0.22" @@ -1333,8 +1378,10 @@ dependencies = [ "lyon_geom", "paste", "pretty_assertions", + "rand", "roxmltree", "rust_decimal", + "rustc-hash", "serde", "serde_json", "serde_repr", @@ -1748,6 +1795,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zip" version = "0.6.6" diff --git a/cli/src/main.rs b/cli/src/main.rs index 1025953..81f4cc0 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -92,6 +92,9 @@ struct Opt { /// /// Useful to print the label of layer on SVG generated by Inkscape extra_attribute_name: Option, + #[arg(long)] + /// Reorder paths to minimize travel time + optimize_path_order: bool, } fn main() -> io::Result<()> { @@ -168,6 +171,7 @@ fn main() -> io::Result<()> { } settings.conversion.extra_attribute_name = opt.extra_attribute_name; + settings.conversion.optimize_path_order = opt.optimize_path_order; if let Version::Unknown(ref unknown) = settings.version { error!( diff --git a/lib/Cargo.toml b/lib/Cargo.toml index e182533..020df44 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -12,6 +12,8 @@ serde = ["dep:serde", "dep:serde_repr", "g-code/serde"] [dependencies] g-code.workspace = true +rand = "0.8" +rustc-hash = "1" rust_decimal = { version = "1", default-features = false } lyon_geom = "=1.0.6" euclid = "0.22" diff --git a/lib/src/converter/mod.rs b/lib/src/converter/mod.rs index c52ae34..cc83e71 100644 --- a/lib/src/converter/mod.rs +++ b/lib/src/converter/mod.rs @@ -12,7 +12,12 @@ use uom::si::{ }; use self::units::CSS_DEFAULT_DPI; -use crate::{Machine, turtle::*}; +use crate::{ + Machine, Turtle, tsp, + turtle::{ + DpiConvertingTurtle, GCodeTurtle, PreprocessTurtle, StrokeCollectingTurtle, Terrarium, + }, +}; #[cfg(feature = "serde")] mod length_serde; @@ -36,6 +41,9 @@ pub struct ConversionConfig { pub origin: [Option; 2], /// Set extra attribute to add when printing node name pub extra_attribute_name: Option, + /// Reorder paths to minimize travel time + #[cfg_attr(feature = "serde", serde(default))] + pub optimize_path_order: bool, } const fn zero_origin() -> [Option; 2] { @@ -50,6 +58,7 @@ impl Default for ConversionConfig { dpi: 96.0, origin: zero_origin(), extra_attribute_name: None, + optimize_path_order: false, } } } @@ -160,7 +169,7 @@ pub fn svg2program<'a, 'input: 'a>( dpi: config.dpi, }), _config: config, - options, + options: options.clone(), name_stack: vec![], viewport_dim_stack: vec![], }; @@ -169,7 +178,43 @@ pub fn svg2program<'a, 'input: 'a>( .terrarium .push_transform(origin_transform); conversion_visitor.begin(); - visit::depth_first_visit(doc, &mut conversion_visitor); + + 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() + }; + + // Optimize order + let strokes = tsp::minimize_travel_time(strokes); + + // Replay reordered strokes into the g-code turtle + 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); + } + conversion_visitor.end(); conversion_visitor.terrarium.pop_transform(); diff --git a/lib/src/lib.rs b/lib/src/lib.rs index a5e135d..9679bb7 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -7,6 +7,8 @@ mod machine; /// Operations that are easier to implement while/after G-Code is generated, or would /// otherwise over-complicate SVG conversion mod postprocess; +/// Reorders strokes to minimize pen-up travel using TSP heuristics +mod tsp; /// Provides an interface for drawing lines in G-Code /// This concept is referred to as [Turtle graphics](https://en.wikipedia.org/wiki/Turtle_graphics). mod turtle; diff --git a/lib/src/tsp.rs b/lib/src/tsp.rs new file mode 100644 index 0000000..06943de --- /dev/null +++ b/lib/src/tsp.rs @@ -0,0 +1,352 @@ +//! Solves the TSP on a graph where each vertex is a tool-on stroke and an edge is tool-off move. +//! +//! Vertices have start/end points and are reversible. The triangle inequality still holds (AFAICT) because it's geometric. + +use std::collections::VecDeque; + +use log::debug; +use lyon_geom::Point; +use rand::{Rng, distributions::Standard, prelude::Distribution, thread_rng}; +use rustc_hash::FxHashSet as HashSet; + +use crate::turtle::Stroke; + +fn dist(a: Point, b: Point) -> f64 { + ((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt() +} + +/// Reorder (and optionally reverse) strokes to minimise total tool-off travel distance. +/// +/// Uses [nearest_neighbor_greedy] for the initial ordering, then refines +/// with tabu search using the Relocate, 2-Opt, and LinkSwap operators. +/// +/// Based off of the code in raster2svg. +/// +/// +/// +pub fn minimize_travel_time(strokes: Vec) -> Vec { + if strokes.len() <= 1 { + return strokes; + } + let path = nearest_neighbor_greedy(strokes); + local_improvement_with_tabu_search(&path) +} + +/// Greedy nearest-neighbour ordering with flips. +/// +/// Repeatedly chooses the [Stroke] or [Stroke::reversed] closest to the current point until none remain. +fn nearest_neighbor_greedy(mut remaining: Vec) -> Vec { + let mut result = Vec::with_capacity(remaining.len()); + // TODO: this assumption may be incorrect? depends on the GCode begin sequence, which this can't account for. + let mut pos = Point::zero(); + + while !remaining.is_empty() { + let mut best_idx = 0; + let mut best_distance = f64::MAX; + let mut best_is_reversed = false; + + for (i, stroke) in remaining.iter().enumerate() { + let normal_distance = dist(pos, stroke.start_point()); + let reversed_distance = dist(pos, stroke.end_point()); + if normal_distance < best_distance { + best_distance = normal_distance; + best_idx = i; + best_is_reversed = false; + } + if reversed_distance < best_distance { + best_distance = reversed_distance; + best_idx = i; + best_is_reversed = true; + } + } + + let mut stroke = remaining.swap_remove(best_idx); + if best_is_reversed { + stroke.reversed(); + } + pos = stroke.end_point(); + result.push(stroke); + } + + result +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum Operator { + /// Move a vertex to a different position. + Relocate, + /// Reverse a sub-sequence of vertices between two edges. + TwoOpt, + /// Change the beginning and/or end of the path by swapping an edge. + /// + /// In the words of the paper: + /// > Link swap is a special case of 3–opt and relocate operator, but as the size of the neighborhood is linear, + /// > it is a faster operation than both 3–opt and relocate operator. + LinkSwap, +} + +impl Operator { + const NUM_OPERATORS: usize = 3; +} + +impl Distribution for Standard { + /// Based on productivity results in the paper, link swap is given a chance of 50% + /// while relocate and 2-opt have 25% each. + fn sample(&self, rng: &mut R) -> Operator { + match rng.gen_range(0..=Operator::NUM_OPERATORS) { + 0 => Operator::Relocate, + 1 => Operator::TwoOpt, + 2 | 3 => Operator::LinkSwap, + _ => unreachable!(), + } + } +} + +/// `current_distances[i]` = tool-off distance from stroke `i`'s end to stroke `i+1`'s start. +/// Length is `n-1` for `n` strokes. +fn stroke_distances(path: &[Stroke]) -> Vec { + path.windows(2) + .map(|w| dist(w[0].end_point(), w[1].start_point())) + .collect() +} + +/// Reverse a series of strokes in-place and also calls [Stroke::reversed] on each [Stroke] to preserve the path. +fn reverse_and_flip(strokes: &mut [Stroke]) { + strokes.reverse(); + for s in strokes.iter_mut() { + s.reversed(); + } +} + +/// Local improvement of an open-loop TSP solution using Relocate, 2-Opt, and LinkSwap. +/// Tabu search is used to avoid getting stuck early in local minima. +/// +/// Ported from raster2svg's implementation of: +/// +/// +/// Differences from the point-based version in raster2svg: +/// - Distances use stroke endpoints (`end_point()` → `start_point`) rather than single vertices. +/// - TwoOpt and LinkSwap reversals also flip each stroke in the reversed range. +/// - Relocate tries both the normal and reversed orientation of the moved stroke. +/// - Distances are `f64` Euclidean rather than squared integers. +fn local_improvement_with_tabu_search(path: &[Stroke]) -> Vec { + let mut best = path.to_owned(); + let mut best_sum: f64 = stroke_distances(&best).iter().sum(); + + let mut current = best.clone(); + let mut current_distances = stroke_distances(¤t); + let mut current_sum = best_sum; + + const ITERATIONS: usize = 20000; + let mut rng = thread_rng(); + + /// 10% of the past moves are considered tabu. + const TABU_FRACTION: f64 = 0.1; + let tabu_capacity = (current.len() as f64 * TABU_FRACTION) as usize; + let mut tabu: VecDeque = VecDeque::with_capacity(tabu_capacity); + let mut tabu_set: HashSet = HashSet::default(); + tabu_set.reserve(tabu_capacity); + + let mut stuck_operators: HashSet = HashSet::default(); + + for idx in 0..ITERATIONS { + if stuck_operators.len() == Operator::NUM_OPERATORS { + if tabu.is_empty() { + debug!("TSP: stuck after {idx} iterations, no more local improvements"); + break; + } else { + // Try to unstick by clearing tabu. + tabu.clear(); + tabu_set.clear(); + stuck_operators.clear(); + } + } + + let operator: Operator = rng.r#gen(); + + match operator { + // O(n^2): move stroke i to between j and j+1, trying both orientations. + Operator::Relocate => { + let best_move = (1..current.len().saturating_sub(1)) + .filter(|&i| !tabu_set.contains(&i)) + .flat_map(|i| { + // Improvement from removing stroke i from between i-1 and i+1. + let unlink_improvement = (current_distances[i - 1] + current_distances[i]) + - dist(current[i - 1].end_point(), current[i + 1].start_point()); + + (0..i.saturating_sub(1)) + .chain(i.saturating_add(1)..current.len().saturating_sub(1)) + .map(move |j| (i, j, unlink_improvement)) + }) + .map(|(i, j, unlink_improvement)| { + let positive_diff = current_distances[j] + unlink_improvement; + + // Cost of inserting stroke i between j and j+1 (normal orientation). + let neg_normal = dist(current[j].end_point(), current[i].start_point()) + + dist(current[i].end_point(), current[j + 1].start_point()); + + // Cost of inserting stroke i reversed between j and j+1. + let neg_reversed = dist(current[j].end_point(), current[i].end_point()) + + dist(current[i].start_point(), current[j + 1].start_point()); + + if neg_normal <= neg_reversed { + (i, j, false, positive_diff - neg_normal) + } else { + (i, j, true, positive_diff - neg_reversed) + } + }) + .max_by(|a, b| a.3.partial_cmp(&b.3).unwrap_or(std::cmp::Ordering::Equal)); + + if let Some((i, j, reversed, diff)) = best_move { + if diff <= 0.0 { + stuck_operators.insert(operator); + continue; + } else { + stuck_operators.clear(); + } + let mut stroke = current.remove(i); + if reversed { + stroke.reversed(); + } + let insert_at = if j < i { j + 1 } else { j }; + current.insert(insert_at, stroke); + tabu.push_back(insert_at); + tabu_set.insert(insert_at); + } else { + stuck_operators.insert(operator); + continue; + } + } + + // O(n^2): reverse the sub-sequence between two non-adjacent edges. + // Each stroke in the reversed range is also reversed. + Operator::TwoOpt => { + let best_move = (0..current.len().saturating_sub(1)) + .map(|i| (i, i + 1)) + .flat_map(|(i, j)| { + (j.saturating_add(2)..current.len()) + .map(move |other_j| ((i, j), (other_j - 1, other_j))) + }) + .filter(|(this, other)| { + !tabu_set.contains(&this.1) && !tabu_set.contains(&other.0) + }) + .map(|(this, other)| { + // Lose edge this.0→this.1 and other.0→other.1. + // Gain edge this.0→other.0 and this.1→other.1 + // (after reversing [this.1..=other.0] and flipping each stroke). + let diff = (current_distances[this.0] + current_distances[other.0]) + - (dist(current[this.0].end_point(), current[other.0].end_point()) + + dist( + current[this.1].start_point(), + current[other.1].start_point(), + )); + (this, other, diff) + }) + .max_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal)); + + if let Some((this, other, diff)) = best_move { + if diff <= 0.0 { + stuck_operators.insert(operator); + continue; + } else { + stuck_operators.clear(); + } + tabu.extend([this.1, other.0]); + tabu_set.extend([this.1, other.0]); + reverse_and_flip(&mut current[this.1..=other.0]); + } else { + stuck_operators.insert(operator); + continue; + } + } + + // O(n): for each interior edge, try replacing it with an edge to/from an endpoint. + Operator::LinkSwap => { + let first_start = current.first().unwrap().start_point(); + let last_end = current.last().unwrap().end_point(); + + let best_move = (2..current.len().saturating_sub(1)) + .map(|j| (j - 1, j)) + .filter(|(i, j)| !tabu_set.contains(i) && !tabu_set.contains(j)) + .map(|(i, j)| { + let from = current[i].end_point(); + let to = current[j].start_point(); + + // Three candidate replacements for edge from→to, as in raster2svg. + // Option index encodes which endpoint(s) change: + // 0 = [from, last_end]: suffix [j..] reversed + // 1 = [first_start, to]: prefix [..=i] reversed + // 2 = [first_start, last_end]: both + let candidates = [ + (0usize, dist(from, last_end)), + (1usize, dist(first_start, to)), + (2usize, dist(first_start, last_end)), + ]; + let (opt, best_new_dist) = candidates + .into_iter() + .min_by(|a, b| { + a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal) + }) + .unwrap(); + (i, j, current_distances[i] - best_new_dist, opt) + }) + .max_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal)); + + if let Some((i, j, diff, opt)) = best_move { + if diff <= 0.0 { + stuck_operators.insert(operator); + continue; + } else { + stuck_operators.clear(); + } + + // Apply prefix reversal (options 1 and 2). + if opt != 0 { + tabu.push_back(i); + tabu_set.insert(i); + reverse_and_flip(&mut current[..=i]); + } + // Apply suffix reversal (options 0 and 2). + if opt != 1 { + tabu.push_back(j); + tabu_set.insert(j); + reverse_and_flip(&mut current[j..]); + } + } else { + stuck_operators.insert(operator); + continue; + } + } + } + + let prev_sum = current_sum; + current_distances = stroke_distances(¤t); + current_sum = current_distances.iter().sum::(); + + debug_assert!( + prev_sum > current_sum - f64::EPSILON, + "operator={operator:?} prev={prev_sum} current={current_sum}" + ); + + if current_sum < best_sum { + best = current.clone(); + best_sum = current_sum; + } + + debug!( + "TSP iteration {}/{} (best: {:.3}, tabu: {}/{}, strokes: {})", + idx, + ITERATIONS, + best_sum, + tabu.len(), + tabu_capacity, + current.len(), + ); + + while tabu.len() > tabu_capacity { + tabu_set.remove(&tabu.pop_front().unwrap()); + } + } + + best +} diff --git a/lib/src/turtle/collect.rs b/lib/src/turtle/collect.rs new file mode 100644 index 0000000..3876e1b --- /dev/null +++ b/lib/src/turtle/collect.rs @@ -0,0 +1,77 @@ +use lyon_geom::{CubicBezierSegment, Point, QuadraticBezierSegment, SvgArc}; + +use super::{ + Turtle, + elements::{DrawCommand, Stroke}, +}; + +/// Collects drawing commands into [Stroke]s for pre-flattening operations. +#[derive(Debug, Default)] +pub struct StrokeCollectingTurtle { + strokes: Vec, + pending: Vec, + stroke_start: Point, + current_pos: Point, +} + +impl StrokeCollectingTurtle { + fn flush(&mut self) { + let has_geometry = self + .pending + .iter() + .any(|c| !matches!(c, DrawCommand::Comment(_))); + if has_geometry { + self.strokes.push(Stroke { + start_point: self.stroke_start, + commands: std::mem::take(&mut self.pending), + }); + } else { + self.pending.clear(); + } + } + + pub fn into_strokes(self) -> Vec { + self.strokes + } +} + +impl Turtle for StrokeCollectingTurtle { + fn begin(&mut self) {} + + fn end(&mut self) { + self.flush(); + } + + fn comment(&mut self, comment: String) { + self.pending.push(DrawCommand::Comment(comment)); + } + + fn move_to(&mut self, to: Point) { + self.flush(); + self.stroke_start = to; + self.current_pos = to; + } + + fn line_to(&mut self, to: Point) { + self.pending.push(DrawCommand::LineTo { + from: self.current_pos, + to, + }); + self.current_pos = to; + } + + fn arc(&mut self, svg_arc: SvgArc) { + self.pending.push(DrawCommand::Arc(svg_arc)); + self.current_pos = svg_arc.to; + } + + fn cubic_bezier(&mut self, cbs: CubicBezierSegment) { + self.pending.push(DrawCommand::CubicBezier(cbs)); + self.current_pos = cbs.to; + } + + fn quadratic_bezier(&mut self, qbs: QuadraticBezierSegment) { + self.pending.push(DrawCommand::QuadraticBezier(qbs)); + self.current_pos = qbs.to; + } +} diff --git a/lib/src/turtle/elements.rs b/lib/src/turtle/elements.rs new file mode 100644 index 0000000..263ac97 --- /dev/null +++ b/lib/src/turtle/elements.rs @@ -0,0 +1,91 @@ +//! Atomic units operated on by a turtle. + +use std::mem::swap; + +use lyon_geom::{CubicBezierSegment, Point, QuadraticBezierSegment, SvgArc}; + +use crate::Turtle; + +/// Atomic unit of a [Stroke]. +#[derive(Debug, Clone)] +pub enum DrawCommand { + LineTo { from: Point, to: Point }, + Arc(SvgArc), + CubicBezier(CubicBezierSegment), + QuadraticBezier(QuadraticBezierSegment), + Comment(String), +} + +impl DrawCommand { + pub fn apply(&self, turtle: &mut impl Turtle) { + match self { + Self::LineTo { to, .. } => turtle.line_to(*to), + Self::Arc(arc) => turtle.arc(*arc), + Self::CubicBezier(cbs) => turtle.cubic_bezier(*cbs), + Self::QuadraticBezier(qbs) => turtle.quadratic_bezier(*qbs), + Self::Comment(s) => turtle.comment(s.clone()), + } + } + + fn end_point(&self) -> Option> { + match self { + Self::LineTo { to, .. } => Some(*to), + Self::Arc(arc) => Some(arc.to), + Self::CubicBezier(cbs) => Some(cbs.to), + Self::QuadraticBezier(qbs) => Some(qbs.to), + Self::Comment(_) => None, + } + } + + fn reverse(&mut self) { + match self { + Self::LineTo { from, to } => { + swap(from, to); + } + Self::Arc(arc) => { + swap(&mut arc.to, &mut arc.from); + arc.flags.sweep = !arc.flags.sweep; + } + Self::CubicBezier(cbs) => { + swap(&mut cbs.from, &mut cbs.to); + swap(&mut cbs.ctrl1, &mut cbs.ctrl2); + } + Self::QuadraticBezier(qbs) => { + swap(&mut qbs.from, &mut qbs.to); + } + Self::Comment(_) => {} + } + } +} + +/// A continuous tool-on sequence with a known start_point. +#[derive(Debug, Clone)] +pub struct Stroke { + pub(super) start_point: Point, + pub(super) commands: Vec, +} + +impl Stroke { + pub fn end_point(&self) -> Point { + self.commands + .iter() + .rev() + .find_map(DrawCommand::end_point) + .unwrap_or(self.start_point) + } + + /// Reverses the stroke so it runs from [Self::end_point] to [Self::start_point]. + pub fn reversed(&mut self) { + self.start_point = self.end_point(); + self.commands.reverse(); + self.commands.iter_mut().for_each(|c| c.reverse()); + } + + pub fn start_point(&self) -> Point { + self.start_point + } + + pub fn commands(&self) -> impl Iterator { + self.commands.iter() + } +} diff --git a/lib/src/turtle/mod.rs b/lib/src/turtle/mod.rs index 79a3155..4a9788e 100644 --- a/lib/src/turtle/mod.rs +++ b/lib/src/turtle/mod.rs @@ -8,10 +8,15 @@ use lyon_geom::{ use crate::arc::Transformed; +mod collect; mod dpi; +mod elements; mod g_code; mod preprocess; -pub use self::{dpi::DpiConvertingTurtle, g_code::GCodeTurtle, preprocess::PreprocessTurtle}; +pub use self::{ + collect::StrokeCollectingTurtle, dpi::DpiConvertingTurtle, elements::Stroke, + g_code::GCodeTurtle, preprocess::PreprocessTurtle, +}; /// Abstraction for drawing paths based on [Turtle graphics](https://en.wikipedia.org/wiki/Turtle_graphics) pub trait Turtle: Debug { diff --git a/web/src/forms/mod.rs b/web/src/forms/mod.rs index 5c688c7..461c1e8 100644 --- a/web/src/forms/mod.rs +++ b/web/src/forms/mod.rs @@ -70,6 +70,11 @@ pub fn settings_form() -> Html { event.target_unchecked_into::().checked(); }); + let on_optimize_path_order_change = + form_dispatch.reduce_mut_callback_with(|form, event: Event| { + form.optimize_path_order = event.target_unchecked_into::().checked(); + }); + let on_checksums_change = form_dispatch.reduce_mut_callback_with(|form, event: Event| { form.checksums = event.target_unchecked_into::().checked(); }); @@ -142,6 +147,16 @@ pub fn settings_form() -> Html { /> +
+ + + +
diff --git a/web/src/state.rs b/web/src/state.rs index 2dd5747..8a7115f 100644 --- a/web/src/state.rs +++ b/web/src/state.rs @@ -15,6 +15,7 @@ pub struct FormState { pub feedrate: Result, pub origin: [Option>; 2], pub circular_interpolation: bool, + pub optimize_path_order: bool, pub dpi: Result, pub tool_on_sequence: Option>, pub tool_off_sequence: Option>, @@ -54,6 +55,7 @@ impl TryInto for &FormState { self.origin[1].clone().transpose()?, ], extra_attribute_name: None, + optimize_path_order: self.optimize_path_order, }, machine: MachineConfig { supported_functionality: SupportedFunctionality { @@ -99,6 +101,7 @@ impl From<&Settings> for FormState { .machine .supported_functionality .circular_interpolation, + optimize_path_order: settings.conversion.optimize_path_order, origin: [ settings.conversion.origin[0].map(Ok), settings.conversion.origin[1].map(Ok),