From 3adeac9d7bc6ddc1b10edcce31b77a7b4a701f8c Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 28 Aug 2025 15:37:08 +0100 Subject: [PATCH 1/4] Add functions to import and draw paths from an SVG. --- boxes/svg_import.py | 61 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 boxes/svg_import.py diff --git a/boxes/svg_import.py b/boxes/svg_import.py new file mode 100644 index 000000000..bb03a2960 --- /dev/null +++ b/boxes/svg_import.py @@ -0,0 +1,61 @@ +"""Allow paths to be imported from an SVG file into boxes.py. + +This is intended to make it easier to add custom cut-outs and engraving. +Currently, only a single path can be imported. This is generally sufficient +to import shapes drawn in OpenSCAD. +""" + +import svg.path +from lxml import etree +from boxes.drawing import Context +import numpy as np + +def draw_path_on_ctx(ctx: Context, path: svg.path.Path): + """Draw an SVG path into the current context. + + This transforms the points according to the current context, but otherwise + should just add the commands from the path into the output. + """ + for seg in path: + if isinstance(seg, svg.path.Move): + ctx.move_to(seg.end.real, seg.end.imag) + if isinstance(seg, svg.path.Close) or isinstance(seg, svg.path.Line): + ctx.line_to(seg.end.real, seg.end.imag) + elif isinstance(seg, svg.path.CubicBezier): + ctx.curve_to( + seg.control1.real, + seg.control1.imag, + seg.control2.real, + seg.control2.imag, + seg.end.real, + seg.end.imag, + ) + +def path_extent(path: svg.path.Path) -> tuple[tuple[float, float], tuple[float, float]]: + """Determine max/min x,y positions in a path.""" + extent_x = [np.inf, -np.inf] + extent_y = [np.inf, -np.inf] + for seg in path: + x, y = seg.end.real, seg.end.imag + for extent, point in [(extent_x, x), (extent_y, y)]: + extent[0] = min(extent[0], point) + extent[1] = max(extent[1], point) + return tuple(extent_x), tuple(extent_y) + +def path_centre(path: svg.path.Path) -> tuple[float, float]: + """Determine the middle point of a path.""" + (x0, x1), (y0, y1) = path_extent(path) + return (x0 + x1)/2, (y0 + y1)/2 + +def load_path_from_svg(filename: str) -> svg.path.Path: + """Load a path from an SVG file. + + This assumes there is a single path in the file, as is true for OpenSCAD + exports. + """ + tree = etree.parse(filename) + path_nodes = tree.xpath("//svg:path", namespaces={'svg': "http://www.w3.org/2000/svg"}) + if len(path_nodes) > 1: + raise RuntimeError("I can only cope with one path in an SVG!") + data = path_nodes[0].get("d") + return svg.path.parse_path(data) \ No newline at end of file From 62ed6da1c09006fb0b40bd415b82ccaa204c887a Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Wed, 3 Sep 2025 13:08:13 +0100 Subject: [PATCH 2/4] pre-commit fixes to whitespace --- boxes/svg_import.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/boxes/svg_import.py b/boxes/svg_import.py index bb03a2960..0c2cb8a0a 100644 --- a/boxes/svg_import.py +++ b/boxes/svg_import.py @@ -12,7 +12,7 @@ def draw_path_on_ctx(ctx: Context, path: svg.path.Path): """Draw an SVG path into the current context. - + This transforms the points according to the current context, but otherwise should just add the commands from the path into the output. """ @@ -49,7 +49,7 @@ def path_centre(path: svg.path.Path) -> tuple[float, float]: def load_path_from_svg(filename: str) -> svg.path.Path: """Load a path from an SVG file. - + This assumes there is a single path in the file, as is true for OpenSCAD exports. """ @@ -58,4 +58,4 @@ def load_path_from_svg(filename: str) -> svg.path.Path: if len(path_nodes) > 1: raise RuntimeError("I can only cope with one path in an SVG!") data = path_nodes[0].get("d") - return svg.path.parse_path(data) \ No newline at end of file + return svg.path.parse_path(data) From f4b28e8f21b16dd769f32adfd66afed91ca63057 Mon Sep 17 00:00:00 2001 From: Florian Festi Date: Sat, 10 Jan 2026 17:17:40 +0100 Subject: [PATCH 3/4] Add requirements --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index 58ee28c6f..6ca9c2c64 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ affine>=2.0 markdown +numpy qrcode>=7.3.1 setuptools shapely>=1.8.2 +svg.path typing_extensions>=4.5.0 From 680872bc0e67ebdbce307cf9a6ca6d00836a2271 Mon Sep 17 00:00:00 2001 From: Florian Festi Date: Sat, 10 Jan 2026 17:19:26 +0100 Subject: [PATCH 4/4] Add QuadraticBezier and error out on arcs Arcs still need to be implemented --- boxes/svg_import.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/boxes/svg_import.py b/boxes/svg_import.py index 0c2cb8a0a..5e1c36892 100644 --- a/boxes/svg_import.py +++ b/boxes/svg_import.py @@ -19,8 +19,12 @@ def draw_path_on_ctx(ctx: Context, path: svg.path.Path): for seg in path: if isinstance(seg, svg.path.Move): ctx.move_to(seg.end.real, seg.end.imag) - if isinstance(seg, svg.path.Close) or isinstance(seg, svg.path.Line): + elif isinstance(seg, svg.path.Close) or isinstance(seg, svg.path.Line): ctx.line_to(seg.end.real, seg.end.imag) + elif isinstance(seg, svg.path.Arc): + raise NotImplementedError + # xc, yc, radius, angle1, angle2, direction + #ctx._arc(seg.arc, seg.sweep) elif isinstance(seg, svg.path.CubicBezier): ctx.curve_to( seg.control1.real, @@ -30,6 +34,15 @@ def draw_path_on_ctx(ctx: Context, path: svg.path.Path): seg.end.real, seg.end.imag, ) + elif isinstance(seg, svg.path.QuadraticBezier): + ctx.curve_to( + seg.control.real, + seg.control.imag, + seg.control.real, + seg.control.imag, + seg.end.real, + seg.end.imag, + ) def path_extent(path: svg.path.Path) -> tuple[tuple[float, float], tuple[float, float]]: """Determine max/min x,y positions in a path."""