recursion limit and downscale

This commit is contained in:
kolkman committed 2026-11-06 08:12:29 +01:00
1 parent d74c8d57b4
commit c1ada9a60e
6 files changed
+228 -31

No files matched your search

+13
View File
@@ -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
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+45
View File
@@ -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()