From 35e288624ed6ca14ab5a75e639fffb1b389a8945 Mon Sep 17 00:00:00 2001 From: Sameer Puri Date: Tue, 10 Mar 2026 23:06:03 -0700 Subject: [PATCH 1/2] Experimental path reordering, closes #13 --- Cargo.lock | 67 +++++++ cli/src/main.rs | 4 + lib/Cargo.toml | 2 + lib/src/converter/mod.rs | 53 +++++- lib/src/lib.rs | 2 + lib/src/tsp.rs | 356 ++++++++++++++++++++++++++++++++++++++ lib/src/turtle/collect.rs | 160 +++++++++++++++++ lib/src/turtle/mod.rs | 6 +- web/src/forms/mod.rs | 15 ++ web/src/state.rs | 3 + 10 files changed, 664 insertions(+), 4 deletions(-) create mode 100644 lib/src/tsp.rs create mode 100644 lib/src/turtle/collect.rs 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..2fed424 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 minimise pen-up travel using a TSP heuristic + optimize: 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; 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..ea25460 100644 --- a/lib/src/converter/mod.rs +++ b/lib/src/converter/mod.rs @@ -12,7 +12,10 @@ use uom::si::{ }; use self::units::CSS_DEFAULT_DPI; -use crate::{Machine, turtle::*}; +use crate::{ + Machine, tsp, + turtle::{collect::DrawingCommand, *}, +}; #[cfg(feature = "serde")] mod length_serde; @@ -36,6 +39,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 minimise pen-up travel using a TSP heuristic + #[cfg_attr(feature = "serde", serde(default))] + pub optimize_path_order: bool, } const fn zero_origin() -> [Option; 2] { @@ -50,6 +56,7 @@ impl Default for ConversionConfig { dpi: 96.0, origin: zero_origin(), extra_attribute_name: None, + optimize_path_order: false, } } } @@ -160,7 +167,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 +176,47 @@ 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 all transformed strokes for TSP optimization + 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.strokes; + + // Optimize ordering + let strokes = tsp::optimize_path_order(strokes); + + // Replay optimized strokes into the GCode turtle + let turtle = &mut conversion_visitor.terrarium.turtle; + for stroke in strokes { + turtle.move_to(stroke.start_point); + for cmd in stroke.commands { + match cmd { + DrawingCommand::LineTo(to) => turtle.line_to(to), + DrawingCommand::Arc(arc) => turtle.arc(arc), + DrawingCommand::CubicBezier(cbs) => turtle.cubic_bezier(cbs), + DrawingCommand::QuadraticBezier(qbs) => turtle.quadratic_bezier(qbs), + DrawingCommand::Comment(s) => turtle.comment(s), + } + } + } + } 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..791d587 --- /dev/null +++ b/lib/src/tsp.rs @@ -0,0 +1,356 @@ +use std::collections::VecDeque; + +use log::info; +use lyon_geom::Point; +use rand::{Rng, distributions::Standard, prelude::Distribution, thread_rng}; +use rustc_hash::FxHashSet as HashSet; + +use crate::turtle::collect::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 pen-up travel distance. +/// +/// Uses a greedy nearest-neighbour heuristic for the initial ordering, then refines +/// with tabu search using the Relocate, 2-Opt, and LinkSwap operators. +/// +/// +pub fn optimize_path_order(strokes: Vec) -> Vec { + if strokes.len() <= 1 { + return strokes; + } + let path = nearest_neighbor_greedy(strokes); + local_improvement_with_tabu_search(&path) +} + +/// Greedy nearest-neighbour with per-stroke flip. +/// +/// At each step picks the unvisited stroke whose nearest endpoint is closest +/// to the current pen position, reversing it if that gives a shorter approach. +fn nearest_neighbor_greedy(mut remaining: Vec) -> Vec { + let mut result = Vec::with_capacity(remaining.len()); + let mut pos = Point::zero(); + + while !remaining.is_empty() { + let mut best_idx = 0; + let mut best_dist = f64::MAX; + let mut best_flip = false; + + for (i, stroke) in remaining.iter().enumerate() { + let d_fwd = dist(pos, stroke.start_point); + let d_rev = dist(pos, stroke.end_point()); + if d_fwd < best_dist { + best_dist = d_fwd; + best_idx = i; + best_flip = false; + } + if d_rev < best_dist { + best_dist = d_rev; + best_idx = i; + best_flip = true; + } + } + + let stroke = remaining.swap_remove(best_idx); + let stroke = if best_flip { stroke.reversed() } else { stroke }; + pos = stroke.end_point(); + result.push(stroke); + } + + result +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum Operator { + /// Move a stroke to a different position in the path + Relocate, + /// Reverse a sub-sequence of strokes 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..=3) { + 0 => Operator::Relocate, + 1 => Operator::TwoOpt, + 2 | 3 => Operator::LinkSwap, + _ => unreachable!(), + } + } +} + +/// `current_distances[i]` = pen-up 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 slice in-place and also call `.reversed()` on each element so that +/// directed strokes connect correctly after the order change. +fn reverse_and_flip(strokes: &mut [Stroke]) { + strokes.reverse(); + for s in strokes.iter_mut() { + // Replace in-place without cloning the whole stroke twice. + let owned = std::mem::replace( + s, + Stroke { + start_point: Point::zero(), + commands: vec![], + }, + ); + *s = owned.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 flipped 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() { + info!("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)) + .into_iter() + .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)) + .into_iter() + .chain( + (i.saturating_add(1)..current.len().saturating_sub(1)).into_iter(), + ) + .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_flipped = dist(current[j].end_point(), current[i].end_point()) + + dist(current[i].start_point, current[j + 1].start_point); + + if neg_normal <= neg_flipped { + (i, j, false, positive_diff - neg_normal) + } else { + (i, j, true, positive_diff - neg_flipped) + } + }) + .max_by(|a, b| a.3.partial_cmp(&b.3).unwrap_or(std::cmp::Ordering::Equal)); + + if let Some((i, j, flipped, diff)) = best_move { + if diff <= 0.0 { + stuck_operators.insert(operator); + continue; + } else { + stuck_operators.clear(); + } + let mut stroke = current.remove(i); + if flipped { + stroke = 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 flipped so connections stay valid. + Operator::TwoOpt => { + let best_move = (0..current.len().saturating_sub(1)) + .into_iter() + .map(|i| (i, i + 1)) + .flat_map(|(i, j)| { + (j.saturating_add(2)..current.len()) + .into_iter() + .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)) + .into_iter() + .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+flipped + // 1 = [first_start, to]: prefix [..=i] reversed+flipped + // 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; + } + + info!( + "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..0a7330e --- /dev/null +++ b/lib/src/turtle/collect.rs @@ -0,0 +1,160 @@ +use lyon_geom::{ArcFlags, CubicBezierSegment, Point, QuadraticBezierSegment, SvgArc}; + +use super::Turtle; + +/// A single drawing command within a stroke +#[derive(Debug, Clone)] +pub enum DrawingCommand { + LineTo(Point), + Arc(SvgArc), + CubicBezier(CubicBezierSegment), + QuadraticBezier(QuadraticBezierSegment), + Comment(String), +} + +impl DrawingCommand { + fn end_point(&self) -> Option> { + match self { + DrawingCommand::LineTo(to) => Some(*to), + DrawingCommand::Arc(arc) => Some(arc.to), + DrawingCommand::CubicBezier(cbs) => Some(cbs.to), + DrawingCommand::QuadraticBezier(qbs) => Some(qbs.to), + DrawingCommand::Comment(_) => None, + } + } +} + +/// A continuous pen-down sequence with a known start point +#[derive(Debug, Clone)] +pub struct Stroke { + pub start_point: Point, + pub commands: Vec, +} + +impl Stroke { + pub fn end_point(&self) -> Point { + self.commands + .iter() + .rev() + .find_map(DrawingCommand::end_point) + .unwrap_or(self.start_point) + } + + /// Reverse the stroke so it runs from end_point to start_point. + pub fn reversed(self) -> Self { + let new_start = self.end_point(); + + // Collect the geometric position before each command so we can reconstruct + // LineTo endpoints when reversed (a LineTo's 'from' becomes the new 'to'). + let mut positions: Vec> = Vec::with_capacity(self.commands.len() + 1); + positions.push(self.start_point); + for cmd in &self.commands { + positions.push(cmd.end_point().unwrap_or(*positions.last().unwrap())); + } + + // Reverse: each command i's new 'to' is positions[i] (the old 'from'). + let reversed_cmds = self + .commands + .into_iter() + .enumerate() + .rev() + .map(|(i, cmd)| match cmd { + DrawingCommand::LineTo(_) => DrawingCommand::LineTo(positions[i]), + DrawingCommand::Arc(arc) => DrawingCommand::Arc(SvgArc { + from: arc.to, + to: arc.from, + flags: ArcFlags { + sweep: !arc.flags.sweep, + ..arc.flags + }, + ..arc + }), + DrawingCommand::CubicBezier(cbs) => { + DrawingCommand::CubicBezier(CubicBezierSegment { + from: cbs.to, + ctrl1: cbs.ctrl2, + ctrl2: cbs.ctrl1, + to: cbs.from, + }) + } + DrawingCommand::QuadraticBezier(qbs) => { + DrawingCommand::QuadraticBezier(QuadraticBezierSegment { + from: qbs.to, + ctrl: qbs.ctrl, + to: qbs.from, + }) + } + DrawingCommand::Comment(s) => DrawingCommand::Comment(s), + }) + .collect(); + + Stroke { + start_point: new_start, + commands: reversed_cmds, + } + } +} + +/// A [Turtle] that collects drawing commands into [Stroke]s instead of emitting G-code. +#[derive(Debug, Default)] +pub struct StrokeCollectingTurtle { + pub 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, DrawingCommand::Comment(_))); + if has_geometry { + self.strokes.push(Stroke { + start_point: self.stroke_start, + commands: std::mem::take(&mut self.pending), + }); + } else { + self.pending.clear(); + } + } +} + +impl Turtle for StrokeCollectingTurtle { + fn begin(&mut self) {} + + fn end(&mut self) { + self.flush(); + } + + fn comment(&mut self, comment: String) { + self.pending.push(DrawingCommand::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(DrawingCommand::LineTo(to)); + self.current_pos = to; + } + + fn arc(&mut self, svg_arc: SvgArc) { + self.pending.push(DrawingCommand::Arc(svg_arc)); + self.current_pos = svg_arc.to; + } + + fn cubic_bezier(&mut self, cbs: CubicBezierSegment) { + self.pending.push(DrawingCommand::CubicBezier(cbs)); + self.current_pos = cbs.to; + } + + fn quadratic_bezier(&mut self, qbs: QuadraticBezierSegment) { + self.pending.push(DrawingCommand::QuadraticBezier(qbs)); + self.current_pos = qbs.to; + } +} diff --git a/lib/src/turtle/mod.rs b/lib/src/turtle/mod.rs index 79a3155..5bbe72c 100644 --- a/lib/src/turtle/mod.rs +++ b/lib/src/turtle/mod.rs @@ -8,10 +8,14 @@ use lyon_geom::{ use crate::arc::Transformed; +pub mod collect; mod dpi; mod g_code; mod preprocess; -pub use self::{dpi::DpiConvertingTurtle, g_code::GCodeTurtle, preprocess::PreprocessTurtle}; +pub use self::{ + collect::StrokeCollectingTurtle, dpi::DpiConvertingTurtle, 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..a3e7e22 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), From c6fd2a6045c0acc5b647ed5d3df86f22484b9e4a Mon Sep 17 00:00:00 2001 From: Sameer Puri Date: Tue, 10 Mar 2026 23:11:23 -0700 Subject: [PATCH 2/2] Fix clippy --- lib/src/tsp.rs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/lib/src/tsp.rs b/lib/src/tsp.rs index 791d587..48bcaa1 100644 --- a/lib/src/tsp.rs +++ b/lib/src/tsp.rs @@ -168,7 +168,6 @@ fn local_improvement_with_tabu_search(path: &[Stroke]) -> Vec { // 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)) - .into_iter() .filter(|&i| !tabu_set.contains(&i)) .flat_map(|i| { // Improvement from removing stroke i from between i-1 and i+1. @@ -176,10 +175,7 @@ fn local_improvement_with_tabu_search(path: &[Stroke]) -> Vec { - dist(current[i - 1].end_point(), current[i + 1].start_point); (0..i.saturating_sub(1)) - .into_iter() - .chain( - (i.saturating_add(1)..current.len().saturating_sub(1)).into_iter(), - ) + .chain(i.saturating_add(1)..current.len().saturating_sub(1)) .map(move |j| (i, j, unlink_improvement)) }) .map(|(i, j, unlink_improvement)| { @@ -226,11 +222,9 @@ fn local_improvement_with_tabu_search(path: &[Stroke]) -> Vec { // Each stroke in the reversed range is also flipped so connections stay valid. Operator::TwoOpt => { let best_move = (0..current.len().saturating_sub(1)) - .into_iter() .map(|i| (i, i + 1)) .flat_map(|(i, j)| { (j.saturating_add(2)..current.len()) - .into_iter() .map(move |other_j| ((i, j), (other_j - 1, other_j))) }) .filter(|(this, other)| { @@ -269,7 +263,6 @@ fn local_improvement_with_tabu_search(path: &[Stroke]) -> Vec { let last_end = current.last().unwrap().end_point(); let best_move = (2..current.len().saturating_sub(1)) - .into_iter() .map(|j| (j - 1, j)) .filter(|(i, j)| !tabu_set.contains(i) && !tabu_set.contains(j)) .map(|(i, j)| {