diff --git a/README.md b/README.md index a186369..6cc8488 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ directories and Python command can be overridden, for example: **There is a brute force path optimisator algorithm. Very slow for large files, can be disabled in config.py** +Travel-distance measurements preserve all drawing shapes. Single-shape drawings +have zero inter-shape travel; SVGs with no drawable shapes are rejected. + Conversion raises Python's recursion limit to at least 100,000 as a temporary workaround for long SVG paths. Larger paths may still exceed this limit, and deep recursion can exhaust the stack on some Python versions and platforms. @@ -32,6 +35,16 @@ deep recursion can exhaust the stack on some Python versions and platforms. **Illustrator exports in points, not mm** regardless of your document settings. If units are set to points in the config file, we convert to mm on the fly. +With `auto_scale = True` (the default), drawings outside +`0 <= X <= bed_max_x` or `0 <= Y <= bed_max_y` are automatically fitted using +their actual coordinate bounds after unit conversion and SVG transformations. +Fitting preserves aspect ratio, never enlarges drawings, and shifts fitted +drawings to start at X0/Y0. Drawings already in range remain unchanged. +Set `auto_scale = False` to reject out-of-range drawings instead. The error +reports the offending coordinate and bed limits; no output is created or +overwritten on a size-check failure. +These are work-coordinate limits, not a check of controller offsets or Z travel. + There are various settings you can change in config.py. Most notably, you must set an upper and lower position for the z axis. The `preamble` is emitted after the header and before the feed rate and drawing diff --git a/config.py b/config.py index 581ebd4..5d640b7 100644 --- a/config.py +++ b/config.py @@ -33,15 +33,15 @@ zTravel = 2 """ height that the z axis will use to draw """ zDraw = 0 -""" feed rate """ -feed_rate = 80000 +""" drawing feed rate in mm/min """ +feed_rate = 1600 """ decimal precision of gcode""" precision = 2 -""" scale gcode to fit bed size""" -auto_scale = False +""" shrink and shift out-of-bed drawings to fit, preserving aspect ratio""" +auto_scale = True """ optimize path - slow for large files""" optimise = True diff --git a/optimise.py b/optimise.py index cdb829e..4382f9c 100644 --- a/optimise.py +++ b/optimise.py @@ -19,10 +19,8 @@ def get_distance(a, b, sq=False): def get_total_distance(shapes): total_distance = 0 - last = shapes.pop(0)[-1] - - for i in shapes: - total_distance += get_distance(i[0], last, sq=True) + for previous, current in zip(shapes, shapes[1:]): + total_distance += get_distance(current[0], previous[-1], sq=True) return total_distance @@ -61,4 +59,3 @@ def optimise_path(shapes, sq=False): timer(t1, "optimizing ") return new_order - diff --git a/svg2gcode.py b/svg2gcode.py index fdb543a..2cae5c8 100755 --- a/svg2gcode.py +++ b/svg2gcode.py @@ -6,6 +6,7 @@ import shapes as shapes_pkg from shapes import point_generator from config import * import re +from math import isfinite from datetime import datetime as dt from optimise import optimise_path, get_total_distance from utils import * @@ -51,18 +52,6 @@ def get_shapes(path, auto_scale=True): width *= pointRatio height *= pointRatio - if auto_scale: - print("\nauto scaling") - - scale_x = bed_max_x / max(width, height) - scale_y = bed_max_y / max(width, height) - scale_x = min(scale_x, scale_y) - scale_y = scale_x - - print("width / height ", width, height) - print("scale factor ", scale_x, "\n") - - for elem in root.iter(): try: @@ -94,11 +83,6 @@ def get_shapes(path, auto_scale=True): y = -y + height - if auto_scale: - - x *= scale_x - y *= scale_y - if first: #coords.append((x, -y + height)) coords.append((x, y)) @@ -112,7 +96,41 @@ def get_shapes(path, auto_scale=True): if first: first = False - shapes.append(coords) + if coords: + shapes.append(coords) + + if not (isfinite(bed_max_x) and isfinite(bed_max_y) + and bed_max_x > 0 and bed_max_y > 0): + raise ValueError("Bed dimensions must be finite and greater than zero") + + points = [point for shape in shapes for point in shape] + if any(not (isfinite(x) and isfinite(y)) for x, y in points): + raise ValueError(f"{path}: drawing coordinates must be finite") + + if auto_scale and points: + min_x = min(x for x, y in points) + max_x = max(x for x, y in points) + min_y = min(y for x, y in points) + max_y = max(y for x, y in points) + if min_x < 0 or max_x > bed_max_x or min_y < 0 or max_y > bed_max_y: + span_x = max_x - min_x + span_y = max_y - min_y + scale = min(1.0, bed_max_x / span_x if span_x else 1.0, + bed_max_y / span_y if span_y else 1.0) + print("auto fitting: scale", scale, "origin", (min_x, min_y)) + # Bound floating-point roundoff at the fitted upper edges. + shapes = [[(min(bed_max_x, (x - min_x) * scale), + min(bed_max_y, (y - min_y) * scale)) + for x, y in shape] for shape in shapes] + + for shape in shapes: + for x, y in shape: + if not (0 <= x <= bed_max_x and 0 <= y <= bed_max_y): + raise ValueError( + f"{path}: drawing coordinate X{x:.6f} Y{y:.6f} mm " + f"is outside bed limits X[0, {bed_max_x}] " + f"Y[0, {bed_max_y}] mm" + ) timer(t1, "parsing gcode ") @@ -168,21 +186,23 @@ def main(file_path, output): sys.setrecursionlimit(max(sys.getrecursionlimit(), 100000)) shapes = get_shapes(file_path, auto_scale) - + if not shapes: + raise ValueError(f"{file_path}: no drawable SVG shapes found") if optimise: pre_distance = get_total_distance(shapes) - print("unoptimised distance: ", get_total_distance(shapes)) - new_order = optimise_path(shapes) post_distance = get_total_distance(new_order) print("unoptimised distance: ", pre_distance) print("optimised distance ", post_distance) - print("factor: ", post_distance / pre_distance) + if pre_distance: + print("factor: ", post_distance / pre_distance) + else: + print("factor: not applicable (no travel between shapes)") commands = shapes_2_gcode(new_order) diff --git a/test_bounds.py b/test_bounds.py new file mode 100644 index 0000000..b2ca443 --- /dev/null +++ b/test_bounds.py @@ -0,0 +1,122 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import svg2gcode + + +class DrawingBoundsTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.source = Path(self.directory.name) / "drawing.svg" + self.output = Path(self.directory.name) / "drawing.gcode" + for name, value in ( + ("bed_max_x", 200), + ("bed_max_y", 280), + ("units", "mm"), + ("auto_scale", False), + ): + patcher = patch.object(svg2gcode, name, value) + patcher.start() + self.addCleanup(patcher.stop) + + def write_line(self, x1, y1, x2, y2, width=200, height=280, transform=""): + self.source.write_text( + f'' + f'' + ) + + def test_exact_boundaries_are_allowed(self): + self.write_line(0, 0, 200, 280) + shapes = svg2gcode.get_shapes(self.source, False) + self.assertIn((0, 280), shapes[0]) + self.assertIn((200, 0), shapes[0]) + + def test_each_boundary_is_enforced(self): + for x, y in ((-0.0001, 100), (200.0001, 100), + (100, -0.0001), (100, 280.0001)): + with self.subTest(x=x, y=y): + self.write_line(10, 10, x, y) + with self.assertRaisesRegex(ValueError, "outside bed limits"): + svg2gcode.get_shapes(self.source, False) + + def test_transformed_coordinates_are_checked(self): + self.write_line(10, 10, 20, 20, transform="translate(200, 0)") + with self.assertRaisesRegex(ValueError, "outside bed limits"): + svg2gcode.get_shapes(self.source, False) + + def test_points_are_converted_before_checking(self): + self.write_line(0, 0, 500, 500, width=500, height=500) + with patch.object(svg2gcode, "units", "points"): + shapes = svg2gcode.get_shapes(self.source, False) + self.assertAlmostEqual(shapes[0][-1][0], 500 * 0.352778) + + def test_auto_scaling_is_applied_before_checking(self): + self.write_line(0, 0, 400, 560, width=400, height=560) + shapes = svg2gcode.get_shapes(self.source, True) + self.assertTrue(all(0 <= x <= 200 and 0 <= y <= 280 + for shape in shapes for x, y in shape)) + self.assertEqual(shapes[0], [(0, 280), (200, 0)]) + + def test_fitting_preserves_in_bounds_coordinates(self): + self.write_line(10, 10, 20, 20) + self.assertEqual(svg2gcode.get_shapes(self.source, True), + svg2gcode.get_shapes(self.source, False)) + + def test_negative_coordinates_are_shifted_and_scaled(self): + self.write_line(-100, -100, 300, 300, height=200) + shapes = svg2gcode.get_shapes(self.source, True) + self.assertEqual(shapes[0], [(0, 200), (200, 0)]) + + def test_horizontal_vertical_and_single_point_drawings(self): + for coords, expected in ( + ((-100, 100, 300, 100), [(0, 0), (200, 0)]), + ((100, -100, 100, 300), [(0, 280), (0, 0)]), + ((300, 100, 300, 100), [(0, 0)]), + ): + with self.subTest(coords=coords): + self.write_line(*coords) + self.assertEqual(svg2gcode.get_shapes(self.source, True)[0], + expected) + + def test_fitting_uses_drawing_not_canvas_dimensions(self): + self.write_line(10, 300, 20, 350, width=500, height=500) + self.assertEqual(svg2gcode.get_shapes(self.source, True)[0], + [(10, 200), (20, 150)]) + + def test_fitting_transformed_coordinates(self): + self.write_line(10, 10, 20, 20, transform="translate(200, 0)") + shapes = svg2gcode.get_shapes(self.source, True) + self.assertTrue(all(0 <= x <= 200 and 0 <= y <= 280 + for shape in shapes for x, y in shape)) + + def test_invalid_bed_dimensions_are_rejected(self): + self.write_line(10, 10, 20, 20) + for dimension in (0, -1, float("inf")): + with patch.object(svg2gcode, "bed_max_x", dimension): + with self.assertRaisesRegex(ValueError, "Bed dimensions"): + svg2gcode.get_shapes(self.source, True) + + def test_canvas_size_alone_does_not_reject_in_bounds_drawing(self): + self.write_line(10, 300, 20, 350, width=500, height=500) + self.assertTrue(svg2gcode.get_shapes(self.source, False)) + + def test_failure_does_not_create_or_overwrite_output(self): + self.write_line(10, 10, 201, 20) + with patch.object(svg2gcode, "optimise_path") as optimise_path: + with self.assertRaisesRegex(ValueError, "X201.000000"): + svg2gcode.main(self.source, self.output) + self.assertFalse(self.output.exists()) + self.output.write_text("existing output") + with self.assertRaises(ValueError): + svg2gcode.main(self.source, self.output) + self.assertEqual(self.output.read_text(), "existing output") + optimise_path.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/test_optimise.py b/test_optimise.py new file mode 100644 index 0000000..3ae48c7 --- /dev/null +++ b/test_optimise.py @@ -0,0 +1,45 @@ +import copy +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import svg2gcode +from optimise import get_total_distance + + +class OptimisationTests(unittest.TestCase): + def test_distance_does_not_remove_shapes(self): + shapes = [[(0, 0), (1, 0)], [(4, 0), (5, 0)], [(5, 4)]] + original = copy.deepcopy(shapes) + self.assertEqual(get_total_distance(shapes), 7) + self.assertEqual(get_total_distance(shapes), 7) + self.assertEqual(shapes, original) + + def test_empty_and_single_shape_distance(self): + self.assertEqual(get_total_distance([]), 0) + self.assertEqual(get_total_distance([[(0, 0), (1, 1)]]), 0) + + def test_single_shape_conversion_retains_all_moves(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "drawing.gcode" + shapes = [[(10, 20), (11, 21), (12, 22)]] + with patch.object(svg2gcode, "get_shapes", return_value=shapes), \ + patch.object(svg2gcode, "optimise", True): + svg2gcode.main("drawing.svg", output) + text = output.read_text() + for x, y in ((10, 20), (11, 21), (12, 22)): + self.assertIn(f"G1 X{x:.3f} Y{y:.3f}", text) + self.assertTrue(text.endswith("G0 Z2\nG0 X0 Y0\n")) + + def test_no_shapes_is_an_explicit_error_without_output(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "drawing.gcode" + with patch.object(svg2gcode, "get_shapes", return_value=[]): + with self.assertRaisesRegex(ValueError, "no drawable SVG shapes"): + svg2gcode.main("empty.svg", output) + self.assertFalse(output.exists()) + + +if __name__ == "__main__": + unittest.main()