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**
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
+4 -4
View File
@@ -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
View File
@@ -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
+42 -22
View File
@@ -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)
+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()