recursion limit and downscale
This commit is contained in:
6 files changed
+228
-31
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**
|
**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
|
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
|
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.
|
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.
|
**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.
|
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.
|
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
|
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 """
|
""" height that the z axis will use to draw """
|
||||||
zDraw = 0
|
zDraw = 0
|
||||||
|
|
||||||
""" feed rate """
|
""" drawing feed rate in mm/min """
|
||||||
feed_rate = 80000
|
feed_rate = 1600
|
||||||
|
|
||||||
""" decimal precision of gcode"""
|
""" decimal precision of gcode"""
|
||||||
precision = 2
|
precision = 2
|
||||||
|
|
||||||
|
|
||||||
""" scale gcode to fit bed size"""
|
""" shrink and shift out-of-bed drawings to fit, preserving aspect ratio"""
|
||||||
auto_scale = False
|
auto_scale = True
|
||||||
|
|
||||||
""" optimize path - slow for large files"""
|
""" optimize path - slow for large files"""
|
||||||
optimise = True
|
optimise = True
|
||||||
|
|||||||
+2
-5
@@ -19,10 +19,8 @@ def get_distance(a, b, sq=False):
|
|||||||
def get_total_distance(shapes):
|
def get_total_distance(shapes):
|
||||||
|
|
||||||
total_distance = 0
|
total_distance = 0
|
||||||
last = shapes.pop(0)[-1]
|
for previous, current in zip(shapes, shapes[1:]):
|
||||||
|
total_distance += get_distance(current[0], previous[-1], sq=True)
|
||||||
for i in shapes:
|
|
||||||
total_distance += get_distance(i[0], last, sq=True)
|
|
||||||
|
|
||||||
return total_distance
|
return total_distance
|
||||||
|
|
||||||
@@ -61,4 +59,3 @@ def optimise_path(shapes, sq=False):
|
|||||||
|
|
||||||
timer(t1, "optimizing ")
|
timer(t1, "optimizing ")
|
||||||
return new_order
|
return new_order
|
||||||
|
|
||||||
+42
-22
@@ -6,6 +6,7 @@ import shapes as shapes_pkg
|
|||||||
from shapes import point_generator
|
from shapes import point_generator
|
||||||
from config import *
|
from config import *
|
||||||
import re
|
import re
|
||||||
|
from math import isfinite
|
||||||
from datetime import datetime as dt
|
from datetime import datetime as dt
|
||||||
from optimise import optimise_path, get_total_distance
|
from optimise import optimise_path, get_total_distance
|
||||||
from utils import *
|
from utils import *
|
||||||
@@ -51,18 +52,6 @@ def get_shapes(path, auto_scale=True):
|
|||||||
width *= pointRatio
|
width *= pointRatio
|
||||||
height *= 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():
|
for elem in root.iter():
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -94,11 +83,6 @@ def get_shapes(path, auto_scale=True):
|
|||||||
|
|
||||||
y = -y + height
|
y = -y + height
|
||||||
|
|
||||||
if auto_scale:
|
|
||||||
|
|
||||||
x *= scale_x
|
|
||||||
y *= scale_y
|
|
||||||
|
|
||||||
if first:
|
if first:
|
||||||
#coords.append((x, -y + height))
|
#coords.append((x, -y + height))
|
||||||
coords.append((x, y))
|
coords.append((x, y))
|
||||||
@@ -112,7 +96,41 @@ def get_shapes(path, auto_scale=True):
|
|||||||
if first:
|
if first:
|
||||||
first = False
|
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 ")
|
timer(t1, "parsing gcode ")
|
||||||
|
|
||||||
@@ -168,21 +186,23 @@ def main(file_path, output):
|
|||||||
sys.setrecursionlimit(max(sys.getrecursionlimit(), 100000))
|
sys.setrecursionlimit(max(sys.getrecursionlimit(), 100000))
|
||||||
shapes = get_shapes(file_path, auto_scale)
|
shapes = get_shapes(file_path, auto_scale)
|
||||||
|
|
||||||
|
if not shapes:
|
||||||
|
raise ValueError(f"{file_path}: no drawable SVG shapes found")
|
||||||
|
|
||||||
if optimise:
|
if optimise:
|
||||||
|
|
||||||
pre_distance = get_total_distance(shapes)
|
pre_distance = get_total_distance(shapes)
|
||||||
|
|
||||||
print("unoptimised distance: ", get_total_distance(shapes))
|
|
||||||
|
|
||||||
new_order = optimise_path(shapes)
|
new_order = optimise_path(shapes)
|
||||||
|
|
||||||
post_distance = get_total_distance(new_order)
|
post_distance = get_total_distance(new_order)
|
||||||
|
|
||||||
print("unoptimised distance: ", pre_distance)
|
print("unoptimised distance: ", pre_distance)
|
||||||
print("optimised distance ", post_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)
|
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