recursion limit and downscale
This commit is contained in:
6 files changed
+226
-29
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-5
@@ -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
|
||||
|
||||
+40
-20
@@ -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,8 +96,42 @@ def get_shapes(path, auto_scale=True):
|
||||
if first:
|
||||
first = False
|
||||
|
||||
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 ")
|
||||
|
||||
return shapes
|
||||
@@ -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)
|
||||
if pre_distance:
|
||||
print("factor: ", post_distance / pre_distance)
|
||||
else:
|
||||
print("factor: not applicable (no travel between shapes)")
|
||||
|
||||
commands = shapes_2_gcode(new_order)
|
||||
|
||||
|
||||
+122
@@ -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'<svg xmlns="http://www.w3.org/2000/svg" '
|
||||
f'width="{width}" height="{height}">'
|
||||
f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" '
|
||||
f'transform="{transform}"/></svg>'
|
||||
)
|
||||
|
||||
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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user