diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index b525d67a..1f69ac42 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -62,6 +62,7 @@ repos:
hooks:
- id: mypy
args: [ '--ignore-missing-imports' ]
+ additional_dependencies: [ 'types-PyYAML' ]
files: '^boxes/.*\.py$'
- id: mypy
args: [ '--ignore-missing-imports' ]
@@ -82,6 +83,10 @@ repos:
args: [ '--ignore-missing-imports' ]
additional_dependencies: [ 'types-Markdown' ]
files: '^scripts/boxesserver$'
+ - id: mypy
+ args: [ '--ignore-missing-imports' ]
+ additional_dependencies: [ 'types-PyYAML' ]
+ files: '^scripts/boxes_generator$'
- id: mypy
args: [ '--ignore-missing-imports' ]
files: '^setup.py$'
diff --git a/boxes/formats.py b/boxes/formats.py
index b49f1962..808f19b1 100644
--- a/boxes/formats.py
+++ b/boxes/formats.py
@@ -18,13 +18,13 @@
import shutil
import subprocess
import tempfile
-
+import io
from boxes.drawing import Context, LBRN2Surface, PSSurface, SVGSurface
class Formats:
- pstoedit_candidates = ["/usr/bin/pstoedit", "pstoedit", "pstoedit.exe"]
+ pstoedit_candidates = ["/usr/bin/pstoedit", "pstoedit", r"C:\Program Files\pstoedit\pstoedit.exe", "pstoedit.exe"]
ps2pdf_candidates = ["/usr/bin/ps2pdf", "ps2pdf", "ps2pdf.exe"]
_BASE_FORMATS = ['svg', 'svg_Ponoko', 'ps', 'lbrn2']
@@ -83,23 +83,27 @@ def convert(self, data, fmt):
if fmt not in self._BASE_FORMATS:
fd, tmpfile = tempfile.mkstemp()
- os.write(fd, data.getvalue())
- os.close(fd)
- fd2, outfile = tempfile.mkstemp()
-
- cmd = self.formats[fmt].format(
- pstoedit=self.pstoedit,
- ps2pdf=self.ps2pdf,
- input=tmpfile,
- output=outfile).split()
-
- result = subprocess.run(cmd)
- os.unlink(tmpfile)
- if result.returncode:
- # XXX show stderr output
- raise ValueError("Conversion failed. pstoedit returned %i\n\n %s" % (result.returncode, result.stderr))
- data = open(outfile, 'rb')
- os.unlink(outfile)
- os.close(fd2)
+ try:
+ os.write(fd, data.getvalue())
+ os.close(fd)
+ fd2, outfile = tempfile.mkstemp()
+ try:
+ cmd = self.formats[fmt].format(
+ pstoedit=self.pstoedit,
+ ps2pdf=self.ps2pdf,
+ input=tmpfile,
+ output=outfile).split()
+ result = subprocess.run(cmd)
+
+ if result.returncode:
+ # XXX show stderr output
+ raise ValueError("Conversion failed. pstoedit returned %i\n\n %s" % (result.returncode, result.stderr))
+ with open(outfile, 'rb') as ff:
+ data = io.BytesIO(ff.read())
+ finally:
+ os.close(fd2)
+ os.unlink(outfile)
+ finally:
+ os.unlink(tmpfile)
return data
diff --git a/boxes/generators/gridfinitybase.py b/boxes/generators/gridfinitybase.py
index 6e888c4e..984d3c54 100644
--- a/boxes/generators/gridfinitybase.py
+++ b/boxes/generators/gridfinitybase.py
@@ -58,6 +58,7 @@ def __init__(self) -> None:
self.argparser.add_argument("--pad_radius", type=float, default=0.8, help="The corner radius for each grid opening. Typical is 0.8,")
self.argparser.add_argument("--panel_x", type=int, default=0, help="the maximum sized panel that can be cut in x direction")
self.argparser.add_argument("--panel_y", type=int, default=0, help="the maximum sized panel that can be cut in y direction")
+ self.argparser.add_argument("--base_type", type=str, default="standard", choices=["standard", "refined"])
def generate_grid(self, nx, ny, shift_x=0, shift_y=0):
@@ -83,6 +84,94 @@ def generate_grid(self, nx, ny, shift_x=0, shift_y=0):
y = ly+((pitch // 2)-ofs)*yoff
self.hole(x, y, d=dia)
+ def generate_refined_grid(self, nx, ny, shift_x=0, shift_y=0, dovetails=True):
+ radius, pad_radius = self.radius, self.pad_radius
+ pitch = self.pitch
+ opening = self.opening
+
+ for col in range(nx):
+ for row in range(ny):
+ lx = col*pitch+pitch/2 + shift_x
+ ly = row*pitch+pitch/2 + shift_y
+
+ self.rectangularHole(lx, ly, opening, opening, r=radius, color=Color.ETCHING)
+ self.hole(lx, ly, d=17)
+ # 0,0 is bottom-left grid
+ if dovetails:
+ if col == 0:
+ self.plate_to_plate_hole(lx, ly, "<")
+ if row == 0:
+ self.plate_to_plate_hole(lx, ly, "v")
+ if row == (ny - 1):
+ self.plate_to_plate_hole(lx, ly, "^")
+ if col == (nx - 1):
+ self.plate_to_plate_hole(lx, ly, ">")
+ if self.cut_pads_mag_diameter > 0:
+ ofs = self.cut_pads_mag_offset
+ # make the pads slightly smaller for press fit
+ dia = self.cut_pads_mag_diameter - 0.5
+ for xoff, yoff in ((1,1), (-1,1), (1,-1), (-1,-1)):
+ x = lx+((pitch // 2)-ofs)*xoff
+ y = ly+((pitch // 2)-ofs)*yoff
+ self.hole(x, y, d=dia)
+
+ @restore
+ def plate_to_plate_hole(self, ctr_x, ctr_y, pos):
+ if pos == "<":
+ self.moveTo(ctr_x - self.pitch/2, ctr_y - 3 + self.burn, 0)
+ elif pos == "v":
+ self.moveTo(ctr_x + 3 - self.burn, ctr_y - self.pitch/2, 90)
+ elif pos == "^":
+ self.moveTo(ctr_x - 3 + self.burn, ctr_y + self.pitch/2, -90)
+ elif pos == ">":
+ self.moveTo(ctr_x + self.pitch/2, ctr_y + 3 - self.burn, 180)
+
+ self.edge(3)
+ self.corner(-53, 0)
+ self.edge(5)
+ self.corner(53, 0)
+ self.edge(3)
+ self.corner(90, 0)
+ self.edge(13.5 - (self.burn*2))
+ self.corner(90, 0)
+ self.edge(3)
+ self.corner(53, 0)
+ self.edge(5)
+ self.corner(-53, 0)
+ self.edge(3)
+
+ @restore
+ def plate_to_plate_tab(self, x, y):
+ """ 3mm
+ 5.325mm/-----
+ |-------/ 5mm
+ | 6 mm 14mm
+ |
+ """
+ self.edge(3)
+ self.corner(53, 0)
+ self.edge(5)
+ self.corner(-53, 0)
+ self.edge(6)
+ self.corner(-53, 0)
+ self.edge(5)
+ self.corner(53, 0)
+ self.edge(3)
+ self.corner(90, 0)
+ self.edge(13.5)
+ self.corner(90, 0)
+ self.edge(3)
+ self.corner(53, 0)
+ self.edge(5)
+ self.corner(-53, 0)
+ self.edge(6)
+ self.corner(-53, 0)
+ self.edge(5)
+ self.corner(53, 0)
+ self.edge(3)
+ self.corner(90, 0)
+ self.edge(13.5)
+
def subdivide_grid(self, X, Y, A, B):
# Calculate the number of subdivisions needed in each dimension
num_x = math.ceil(X / A)
@@ -141,6 +230,21 @@ def render(self):
# if both size_y and y were provided, y takes precedence
self.size_y = max(self.size_y, self.y*self.pitch)
+
+ self.exact_size = ((self.size_x == self.x*self.pitch) and (self.size_y == self.y*self.pitch))
+
+ # make tabs for refined bases if:
+ # - height is 0 (no need for dovetails if there are walls), and
+ # - the box is exact sized (no need for dovetails if the box is not exactly on pitch)
+ if self.h == 0 and self.base_type == "refined" and self.exact_size:
+ num_tabs = 8
+ for ii in range(num_tabs):
+ dontdraw = self.move(17, 14, "right", before=True)
+ if not dontdraw:
+ self.plate_to_plate_tab(0,0)
+ self.move(17, 14, "right")
+ self.moveTo(-(17+self.spacing)*8, 15)
+
if self.panel_x != 0 and self.panel_y != 0:
self.render_split(self.size_x, self.size_y, self.h, self.x, self.y, self.pitch, self.m)
else:
@@ -372,11 +476,13 @@ def render_unsplit(self, x, y, h, nx, ny, pitch, margin):
callback=[partial(self.generate_grid, nx, ny, shift_x, shift_y)]
)
- # add margin for walls and lid
- x += 2 * margin
- y += 2 * margin
-
if h > 0:
+ # add margin for walls and lid
+ x += 2 * margin
+ y += 2 * margin
+ shift_x += margin
+ shift_y += margin
+
self.rectangularWall(x, h, [b, sideedge, t1, sideedge],
ignore_widths=[1, 6], move="right")
@@ -388,6 +494,13 @@ def render_unsplit(self, x, y, h, nx, ny, pitch, margin):
ignore_widths=[1, 6], move="left up")
if self.bottom_edge != "e":
- self.rectangularWall(x, y, "ffff", move="up")
+ if self.base_type != "refined":
+ self.rectangularWall(x, y, "ffff", move="right")
+ else:
+ self.rectangularWall(x, y, "ffff", move="right", callback=[partial(self.generate_refined_grid, nx, ny, shift_x, shift_y, False)])
- self.lid(x, y)
+ self.lid(x, y)
+ else:
+ if self.base_type == "refined":
+ # Generate a refined based and include dovetails if exact sized
+ self.rectangularWall(x, y, "eeee", move="right", callback=[partial(self.generate_refined_grid, nx, ny, shift_x, shift_y, self.exact_size)])
diff --git a/boxes/generators/gridfinitytraylayout.py b/boxes/generators/gridfinitytraylayout.py
index 628b14b9..30222740 100644
--- a/boxes/generators/gridfinitytraylayout.py
+++ b/boxes/generators/gridfinitytraylayout.py
@@ -1,5 +1,3 @@
-import argparse
-
import boxes
from boxes import Boxes, lids, restore, boolarg
from boxes.Color import Color
@@ -45,14 +43,9 @@ def __init__(self) -> None:
self.argparser.add_argument("--cut_pads_mag_diameter", type=float, default=6.5, help="if pads are cut add holes for magnets. Typical is 6.5, zero to disable,")
self.argparser.add_argument("--cut_pads_mag_offset", type=float, default=7.75, help="if magnet hole offset from pitch corners. Typical is 7.75.")
self.argparser.add_argument("--base_thickness", type=float, default=0.0, help="the thickness of base the box will sit upon. 0 to use the material thickness, 4.65 for a standard Gridfinity 3D printed base")
- if self.UI == "web":
- self.argparser.add_argument("--layout", type=str, help="You can hand edit this before generating", default="\n");
- else:
- self.argparser.add_argument(
- "--input", action="store", type=argparse.FileType('r'),
- default="traylayout.txt",
- help="layout file")
- self.layout = None
+ self.argparser.add_argument("--layout", type=str, help="You can hand edit this before generating", default="\n")
+ if self.UI != "web":
+ self.argparser.add_argument("--input", action="store", type=str, default="traylayout.txt", help="layout file")
def generate_layout(self):
layout = ''
@@ -63,8 +56,11 @@ def generate_layout(self):
if county == 0:
county = self.ny
- stepx = self.x / countx
- stepy = self.y / county
+ x = self.pitch * self.nx - self.margin
+ y = self.pitch * self.ny - self.margin
+
+ stepx = x / countx
+ stepy = y / county
for i in range(countx):
line = ' |' * i + f" ,> {stepx}mm\n"
layout += line
diff --git a/boxes/generators/photoframe.py b/boxes/generators/photoframe.py
index 65ab9499..a85c5719 100644
--- a/boxes/generators/photoframe.py
+++ b/boxes/generators/photoframe.py
@@ -20,9 +20,6 @@
from boxes import BoolArg, Boxes, Color, edges
logger = logging.getLogger(__name__)
-logger.setLevel(logging.DEBUG)
-logger.addHandler(logging.StreamHandler())
-
@dataclass
class Dimensions:
diff --git a/boxes/generators/traylayout.py b/boxes/generators/traylayout.py
index cad3af63..66325518 100644
--- a/boxes/generators/traylayout.py
+++ b/boxes/generators/traylayout.py
@@ -15,7 +15,7 @@
from __future__ import annotations
import io
-
+import os
import boxes
from boxes import *
from boxes import lids
@@ -102,23 +102,21 @@ def __init__(self) -> None:
self.addSettingsArgs(boxes.edges.FingerJointSettings)
self.addSettingsArgs(lids.LidSettings)
self.buildArgParser("h", "hi", "outside", "sx", "sy")
- if self.UI == "web":
- self.argparser.add_argument(
- "--layout", action="store", type=str, default="\n",
- help="""* Set **sx** and **sy** before editing this!
+ self.argparser.add_argument(
+ "--layout", action="store", type=str, default="\n",
+ help="""* Set **sx** and **sy** before editing this!
* You can still change measurements afterwards
* You can replace the hyphens and vertical bars representing the walls
with a space character to remove the walls.
* You can replace the space characters representing the floor by a "X"
to remove the floor for this compartment.
* Resize text area if necessary.""")
- self.description = ""
- else:
+ self.description = ""
+ if self.UI != "web":
self.argparser.add_argument(
- "--input", action="store", type=argparse.FileType('r'),
+ "--input", action="store", type=str,
default="traylayout.txt",
help="layout file")
- self.layout = None
def vWalls(self, x: int, y: int) -> int:
"""Number of vertical walls at a crossing."""
@@ -152,16 +150,23 @@ def hFloor(self, x: int, y: int) -> bool:
return (y > 0 and self.floors[y - 1][x]) or (y < len(self.y) and self.floors[y][x])
@restore
- def edgeAt(self, edge, x, y, length, angle=0):
+ def edgeAt(self, edge, x, y, length, angle=0, corner=None):
self.moveTo(x, y, angle)
edge = self.edges.get(edge, edge)
edge(length)
+ if corner:
+ self.corner(90)
def prepare(self):
if self.layout:
self.parse(self.layout.split('\n'))
+ elif os.path.exists(self.input):
+ with open(self.input) as input_file:
+ self.parse(input_file)
+ elif callable(getattr(self, "generate_layout", None)):
+ self.parse(self.generate_layout().split('\n'))
else:
- self.parse(self.input)
+ raise RuntimeError("traylayout requires --layout, --input, or implementation of generate_layout")
if self.outside:
self.x = self.adjustSize(self.x)
@@ -333,7 +338,7 @@ def base_plate(self, callback=None, move=None):
posy + w + b, self.x[x],
-180)
if x == 0 or not self.floors[y][x - 1]:
- self.edgeAt("e", posx - w, posy + w + b, w, 0)
+ self.edgeAt("e", posx, posy + w + b, w, -180, corner=True)
elif y == 0 or not self.floors[y - 1][x - 1]:
self.edgeAt("e", posx - t, posy + w + b, t, 0)
if x == lx - 1 or not self.floors[y][x + 1]:
@@ -346,7 +351,7 @@ def base_plate(self, callback=None, move=None):
elif x == 0 or y == ly or not self.floors[y][x - 1]:
self.edgeAt("e", posx - t, posy + t - w - b, t)
if x == lx - 1 or y == 0 or not self.floors[y-1][x + 1]:
- self.edgeAt("e", posx + self.x[x], posy + t -w - b, w)
+ self.edgeAt("e", posx + self.x[x], posy + t -w - b, w, corner=True)
posx += self.x[x] + self.thickness
posy += self.y[y - 1] + self.thickness
@@ -367,7 +372,7 @@ def base_plate(self, callback=None, move=None):
# Right edge
self.edgeAt(e, posx + w + b, posy, self.y[y], 90)
if y == 0 or not self.floors[y-1][x-1]:
- self.edgeAt("e", posx + w + b, posy + self.y[y], w, 90)
+ self.edgeAt("e", posx + w + b, posy + self.y[y], w, 90, corner=True)
elif x == lx or y == 0 or not self.floors[y - 1][x]:
self.edgeAt("e", posx + w + b, posy + self.y[y], t, 90)
if y == ly - 1 or not self.floors[y+1][x-1]:
@@ -382,7 +387,7 @@ def base_plate(self, callback=None, move=None):
self.edgeAt("e", posx + t - w - b,
posy + self.y[y] + t, t, -90)
if y == ly - 1 or not self.floors[y + 1][x]:
- self.edgeAt("e", posx + t - w - b, posy, w, -90)
+ self.edgeAt("e", posx + t - w - b, posy, w, -90, corner=True)
posy += self.y[y] + self.thickness
if x < lx:
posx += self.x[x] + self.thickness
diff --git a/boxes/scripts/boxes_generator.py b/boxes/scripts/boxes_generator.py
new file mode 100755
index 00000000..af41df29
--- /dev/null
+++ b/boxes/scripts/boxes_generator.py
@@ -0,0 +1,567 @@
+#!/usr/bin/env python3
+# Copyright (C) 2025 Michael Ihde
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+"""
+This application is designed to support automation of cataloging of boxes
+by allowing a user to define one our more yaml files that list the boxes
+they want cut.
+
+This will then generate all of the boxes and merge them into a single SVG
+with the pieces of the box being packed into one or more panels
+(set panel_width and panel_height to zero to disable merging).
+
+The merged output is very useful when cutting many different boxes and using
+standard 12" x 12" (i.e. ~305mm x 305mm) panels. The merged output makes
+heavy use of SVG transforms, so some tools may not render them correctly. This
+tool has been tested with LightBurn.
+
+The YAML input looks like this:
+
+```
+Defaults:
+ reference: 0
+
+Boxes:
+ - box_type: GridfinityTrayLayout # required
+ name: "1x3x6u_tray" # optional
+ count: 2 # optional, 1 is default
+ generate: false # optional, true is default
+ args: # the args for the box generator
+ h: 6u
+ nx: 1
+ ny: 3
+ countx: 1
+ county: 1
+ gen_pads: 0
+
+ - box_type: GridfinityTrayLayout # required
+ name: "2x3x6u_tray" # optional
+ count: 2 # optional, 1 is default
+ args: # the args for the box generator
+ h: 6u
+ nx: 2
+ ny: 3
+ countx: 1
+ county: 1
+ gen_pads: 0
+```
+
+Boxes the require a layout can use the following choices:
+
+```
+layout: GENERATE # auto generate a layout
+
+layout: path/to/file.txt
+
+layout: |
+ ,> 125.25mm
+ +-+
+ | | 83.25mm
+ +-+
+```
+
+Currently there is no web front-end for this script.
+"""
+import yaml
+import copy
+import os
+import sys
+import logging
+import argparse
+import sys
+import uuid
+import os
+import re
+
+import xml.etree.ElementTree as ET
+import rectpack
+from rectpack import newPacker, PackingBin
+from svgpathtools import parse_path
+
+try:
+ import boxes.generators
+except ImportError:
+ sys.path.append(os.path.join(os.path.dirname(os.path.realpath(__file__)), "../.."))
+ import boxes.generators
+import boxes
+
+class ArgumentParserError(Exception): pass
+
+class ThrowingArgumentParser(argparse.ArgumentParser):
+ def error(self, message):
+ raise ArgumentParserError(message)
+
+# Evil hack
+boxes.ArgumentParser = ThrowingArgumentParser # type: ignore
+
+PACK_ALGO_CHOICES = (
+ "MaxRectsBl",
+ "MaxRectsBssf",
+ "MaxRectsBaf",
+ "MaxRectsBlsf",
+ "SkylineBl",
+ "SkylineBlWm",
+ "SkylineMwf",
+ "SkylineMwfl",
+ "SkylineMwfWm",
+ "SkylineMwflWm",
+ "GuillotineBssfSas",
+ "GuillotineBssfLas",
+ "GuillotineBssfSlas",
+ "GuillotineBssfLlas",
+ "GuillotineBssfMaxas",
+ "GuillotineBssfMinas",
+ "GuillotineBlsfSas",
+ "GuillotineBlsfLas",
+ "GuillotineBlsfSlas",
+ "GuillotineBlsfLlas",
+ "GuillotineBlsfMaxas",
+ "GuillotineBlsfMinas",
+ "GuillotineBafSas",
+ "GuillotineBafLas",
+ "GuillotineBafSlas",
+ "GuillotineBafLlas",
+ "GuillotineBafMaxas",
+ "GuillotineBafMinas",
+)
+
+GENERATORS = {b.__name__: b for b in boxes.generators.getAllBoxGenerators().values() if b.webinterface}
+
+SVG_NS = "http://www.w3.org/2000/svg"
+ET.register_namespace("", SVG_NS)
+
+def generate_layout(box):
+ """
+ Generates a basic layout the evenly divides a box by box.countx and box.county.
+
+ The boxes dimensions are determined by box.x and box.y (if present), or box.pitch,
+ box.nx, and box.ny.
+ """
+ countx = getattr(box, 'countx', 1)
+ county = getattr(box, 'county', 1)
+
+ if hasattr(box, 'x') and hasattr(box, 'y'):
+ x = box.x - getattr(box, "margin", 0)
+ y = box.y - getattr(box, "margin", 0)
+ elif hasattr(box, 'pitch') and hasattr(box, 'nx') and hasattr(box, 'ny'):
+ x = box.nx * box.pitch - getattr(box, "margin", 0)
+ y = box.ny * box.pitch - getattr(box, "margin", 0)
+ else:
+ raise ValueError
+
+ layout = ''
+
+ stepx = x / countx
+ stepy = y / county
+ for i in range(countx):
+ line = ' |' * i + f" ,> {stepx}mm\n"
+ layout += line
+ for i in range(county):
+ layout += "+-" * countx + f"+\n"
+ layout += "| " * countx + f"|{stepy}mm\n"
+ layout += "+-" * countx + "+\n"
+ return layout
+
+def generate(cut, output_prefix, format="svg"):
+ """
+ Generate a single box
+ """
+ generated_files = []
+ defaults = cut.get("Defaults", {})
+ for ii, box_settings in enumerate(cut.get("Boxes", [])):
+ # Allow for skipping generation
+ if box_settings.get("generate") == False:
+ continue
+
+ # Get the box generator
+ box_type = box_settings.pop("box_type", None)
+ if box_type is None:
+ raise ValueError("box_type must be provided for each cut")
+ box_cls = GENERATORS.get(box_type, None)
+ if box_cls is None:
+ raise ValueError("invalid generator '%s'" % box_type)
+
+ # Instantitate the box object
+ box = box_cls()
+
+ # Create the settings for the generator
+ settings = copy.deepcopy(defaults)
+ settings.update(box_settings.get("args", {}))
+
+ if hasattr(box, "layout") and "layout" in settings:
+ if os.path.exists(settings["layout"]):
+ with open(settings["layout"]) as ff:
+ settings["layout"] = ff.read()
+ else:
+ box.layout = settings["layout"]
+
+ box_args = []
+ for kk, vv in settings.items():
+ # Handle layout and format separately
+ if kk in ("format", "layout"):
+ continue
+ box_args.append(f"--{kk}={vv}")
+ box_args.append(f"--format={format}")
+ try:
+ # Ignore unknown arguments by pre-parsing. This two stage
+ # approach was performed to avoid modifying parseArgs and
+ # changing it's behavior. A long-term better solution
+ # might be to allow parseArgs to take a 'strict' argument
+ # the can enable/disable strict parsing of arguments
+ args, argv = box.argparser.parse_known_args(box_args)
+ if len(argv) > 0:
+ for unknown_arg in argv:
+ box_args.remove(unknown_arg)
+ box.parseArgs(box_args)
+ except ArgumentParserError:
+ logging.exception("Error parsing box args")
+ continue
+
+ # If the box requires a layout, support auto-generation
+ if getattr(box, "layout", None) == "GENERATE":
+ box.layout = generate_layout(box)
+
+ # Render the box SVG
+ box.open()
+ box.render()
+ data = box.close()
+
+ if box_settings.get("name") is not None:
+ output_base = os.path.basename(output_prefix)
+ output_dir = os.path.dirname(output_prefix)
+ output_file = os.path.join(output_dir, f"{output_base}_{box_settings['name']}_{box_type}_{ii}")
+ else:
+ output_file = f"{output_prefix}_{box_type}_{ii}"
+
+ # Write the output
+ if box_settings.get("count") is not None:
+ for jj in range(int(box_settings.get("count"))):
+ logging.info("Writing %s_%s.%s", output_file, jj, format)
+ with open(f"{output_file}_{jj}.{format}", "wb") as ff:
+ ff.write(data.read())
+ data.seek(0)
+ generated_files.append(f"{output_file}_{jj}.{format}")
+
+ else:
+ logging.info("Writing %s.%s", output_file, format)
+ with open(f"{output_file}.{format}", "wb") as ff:
+ ff.write(data.read())
+ generated_files.append(f"{output_file}.{format}")
+
+ return generated_files
+
+def parse_svg_groups(svg_file):
+ """
+ Parse out all the SVG groups from the given SVG file
+ """
+ tree = ET.parse(svg_file)
+ root = tree.getroot()
+ groups = [g for g in root if g.tag.endswith('g')]
+ return groups, tree
+
+def get_bbox_of_group(group):
+ """
+ Get the bounding box of the SVG group
+ """
+ min_x = float("inf")
+ min_y = float("inf")
+ max_x = float("-inf")
+ max_y = float("-inf")
+
+ def update_bbox(x_vals, y_vals):
+ nonlocal min_x, min_y, max_x, max_y
+ min_x = min(min_x, *x_vals)
+ min_y = min(min_y, *y_vals)
+ max_x = max(max_x, *x_vals)
+ max_y = max(max_y, *y_vals)
+
+ for elem in group.iter():
+ tag = elem.tag.split("}")[-1] # Remove namespace
+ if tag == "rect":
+ x = float(elem.attrib.get("x", 0))
+ y = float(elem.attrib.get("y", 0))
+ w = float(elem.attrib.get("width", 0))
+ h = float(elem.attrib.get("height", 0))
+ update_bbox([x, x + w], [y, y + h])
+ elif tag == "circle":
+ cx = float(elem.attrib.get("cx", 0))
+ cy = float(elem.attrib.get("cy", 0))
+ r = float(elem.attrib.get("r", 0))
+ update_bbox([cx - r, cx + r], [cy - r, cy + r])
+ elif tag == "ellipse":
+ cx = float(elem.attrib.get("cx", 0))
+ cy = float(elem.attrib.get("cy", 0))
+ rx = float(elem.attrib.get("rx", 0))
+ ry = float(elem.attrib.get("ry", 0))
+ update_bbox([cx - rx, cx + rx], [cy - ry, cy + ry])
+ elif tag == "line":
+ x1 = float(elem.attrib.get("x1", 0))
+ y1 = float(elem.attrib.get("y1", 0))
+ x2 = float(elem.attrib.get("x2", 0))
+ y2 = float(elem.attrib.get("y2", 0))
+ update_bbox([x1, x2], [y1, y2])
+ elif tag in ["polyline", "polygon"]:
+ points = elem.attrib.get("points", "")
+ point_pairs = re.findall(r"[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?", points)
+ coords = list(map(float, point_pairs))
+ xs = coords[::2]
+ ys = coords[1::2]
+ if xs and ys:
+ update_bbox(xs, ys)
+ elif tag == "path":
+ d = elem.attrib.get("d", "")
+ try:
+ path = parse_path(d)
+ box = path.bbox() # (min_x, max_x, min_y, max_y)
+ update_bbox([box[0], box[1]], [box[2], box[3]])
+ except Exception as e:
+ print(f"Warning: Failed to parse path in group. Error: {e}")
+
+ # Fallback if nothing was found
+ if min_x == float("inf"):
+ raise ValueError
+ return [min_x, min_y, max_x, max_y]
+
+def extract_elements(svg_files):
+ """
+ Extract all group elements from the SVG
+ """
+ elements = []
+ for file in svg_files:
+ groups, tree = parse_svg_groups(file)
+ for g in groups:
+ bbox = get_bbox_of_group(g)
+ width = bbox[2] - bbox[0]
+ height = bbox[3] - bbox[1]
+ style = g.attrib.get("style", '')
+ elements.append({
+ 'group': g,
+ 'bbox': bbox,
+ 'width': width,
+ 'height': height,
+ 'style': style,
+ 'id': str(uuid.uuid4()),
+ 'source_file': file
+ })
+ return elements
+
+def pack_elements(elements, box_width, box_height, margin, rotation, bin_algo, pack_algo):
+ """
+ Pack all the group elements into the minimum number of panels.
+ """
+ try:
+ bin_algo = getattr(PackingBin, bin_algo)
+ except AttributeError:
+ raise RuntimeError("invalid bin algorithm specified")
+
+ try:
+ pack_algo = getattr(rectpack, pack_algo)
+ except AttributeError:
+ raise RuntimeError("invalid pack algorithm specified")
+
+ packer = newPacker(
+ rotation=rotation, # rotating packing is still a WIP
+ pack_algo=pack_algo,
+ bin_algo=bin_algo
+ )
+ for elem in elements:
+ packer.add_rect(elem['width'] + (margin*2), elem['height'] + (margin*2), elem['id'])
+ packer.add_bin(box_width, box_height, float("inf")) # unlimited bins
+ packer.pack()
+
+ packed = []
+ for bid, abin in enumerate(packer):
+ for rect in abin:
+ x, y, packed_w, packed_h, rid = rect.x, rect.y, rect.width, rect.height, rect.rid
+ elem = next(e for e in elements if e['id'] == rid)
+
+ original_w = elem['width'] + (margin*2)
+ original_h = elem['height']+ (margin*2)
+ rotated = (
+ round(packed_w) == round(original_h) and round(packed_h) == round(original_w)
+ )
+
+ packed.append({
+ 'element': elem,
+ 'x': x,
+ 'y': y,
+ 'bin': bid,
+ 'style': elem['style'],
+ 'rotated': rotated
+ })
+ return packed
+
+def create_output_svg(packed_elements, box_width, box_height, margin, include_debug_bbox=False):
+ """
+ Create the merged SVG output.
+ """
+ svg = ET.Element(f"{{{SVG_NS}}}svg")
+ bins = {}
+ spacing = 20 # Space between bins in the output SVG
+
+ for item in packed_elements:
+ bin_id = item['bin']
+ if bin_id not in bins:
+ # Create a new bin group
+ bin_group = ET.SubElement(svg, "g", attrib={'id': f'bin_{bin_id}'})
+ # Place each bin horizontally spaced apart
+ bin_group.set("transform", f"translate({bin_id * (box_width + spacing)}, 0)")
+ bins[bin_id] = bin_group
+
+ # Add bounding box rectangle to bin
+ rect = ET.Element("rect", {
+ "x": "0",
+ "y": "0",
+ "width": str(box_width),
+ "height": str(box_height),
+ "fill": "none",
+ "stroke": "rgb( 208, 208, 0)",
+ "stroke-width": "1"
+ })
+ bin_group.append(rect)
+
+ elem = item['element']
+ g = elem['group']
+ bbox = elem['bbox']
+ original_w = elem['width']
+ original_h = elem['height']
+ x, y = item['x'], item['y']
+ rotated = item['rotated']
+ style = item['style']
+
+ # Normalize the group to (0, 0)
+ dx = -bbox[0]
+ dy = -bbox[1]
+
+ # Create a new group and apply transforms in order:
+ # 1. Move to (x, y) in the bin
+ # 2. Rotate if needed
+ # 3. Offset to normalize original group position
+
+ transform_parts = []
+
+ # Step 1: move to packed (x, y)
+ transform_parts.append(f"translate({x+margin},{y+margin})")
+
+ if rotated:
+ dy -= original_h
+ # Step 2: rotate 90° around the origin
+ transform_parts.append("rotate(90)")
+ # Step 3: apply offset to align rotated group
+ transform_parts.append(f"translate({dx}, {dy})")
+ else:
+ # Step 3: apply offset without rotation
+ transform_parts.append(f"translate({dx},{dy})")
+
+ full_transform = " ".join(transform_parts)
+
+ # Clone the group with transformation
+ new_g = ET.Element("g", attrib={"transform": full_transform, "style": style})
+ for child in list(g):
+ new_g.append(child)
+
+ if include_debug_bbox:
+ new_g.append(
+ ET.Element(
+ "rect",
+ attrib={
+ "x": str(bbox[0]),
+ "y": str(bbox[1]),
+ "width": str(original_w),
+ "height": str(original_h),
+ "stroke": "rgb(255,128,0)",
+ "fill": "none",
+ }
+ )
+ )
+ bins[bin_id].append(new_g)
+
+
+ return ET.ElementTree(svg)
+
+def main(args):
+ generated_files = set()
+ for cut_file in args.cuts:
+ output_prefix = args.prefix
+ if output_prefix is None:
+ output_prefix = os.path.splitext(cut_file)[0]
+
+ with open(cut_file) as ff:
+ cut = yaml.safe_load(ff)
+ generated_files.update( generate(cut, output_prefix, args.format) )
+
+ # convert width/height in mm to pixels
+ if args.panel_width > 0 and args.panel_height > 0 and args.merge and args.format == "svg":
+ width_px = int( (args.panel_width / 25.4) * 96)
+ height_px = int( (args.panel_height / 25.4) * 96)
+ margin_px = int( (args.margin / 25.4) * 96)
+
+ logging.info("Merging %s files", len(generated_files))
+ elements = extract_elements(list(generated_files))
+ for element in elements:
+ if element['width'] > args.panel_width or element['height'] > args.panel_height:
+ logging.warning("Element in %s is larger than panel width and will not be included in merged output", element['source_file'])
+ packed = pack_elements(
+ elements,
+ args.panel_width,
+ args.panel_height,
+ margin_px,
+ args.rotation,
+ args.bin_algo,
+ args.pack_algo
+ )
+ result_svg = create_output_svg(
+ packed,
+ args.panel_width,
+ args.panel_height,
+ margin_px,
+ args.debug
+ )
+
+ output_file = f"{output_prefix}_{args.output}"
+ result_svg.write(output_file, encoding='utf-8', xml_declaration=True)
+ logging.info("Merge output %s", output_file)
+
+if __name__ == "__main__":
+ formats = boxes.formats.Formats()
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument("cuts", nargs="+", help="Input cut files")
+ parser.add_argument("--prefix", type=str, default=None)
+ parser.add_argument("--debug", default=False, action="store_true")
+ parser.add_argument("--rotation", default=False, action="store_true")
+ parser.add_argument("--bin_algo", default="Global", choices=("BNF", "BFF", "BBF", "Global"))
+ parser.add_argument("--pack_algo", default="MaxRectsBssf", choices=PACK_ALGO_CHOICES)
+ parser.add_argument("--panel_width", type=int, default=300, help="Panel width in mm")
+ parser.add_argument("--panel_height", type=int, default=300, help="Panel height in mm")
+ parser.add_argument("--dpi", type=int, default=96, help="SVG resolution in dots-per-inch")
+ parser.add_argument("--margin", type=int, default=1, help="margin around outside of element in mm")
+ parser.add_argument("--output", default="merged_output.svg", help="Merged output SVG file suffix")
+ parser.add_argument("--merge", default=False, action="store_true", help="Produce merged output")
+ parser.add_argument("--format",
+ action="store",
+ type=str,
+ default="svg",
+ choices=formats.getFormats(),
+ help="format of resulting file [\U0001F6C8](https://florianfesti.github.io/boxes/html/usermanual.html#format)"
+ )
+ args = parser.parse_args()
+
+ logging.basicConfig(level=logging.INFO)
+ if args.debug:
+ logging.getLogger().setLevel(logging.DEBUG)
+
+ main(args)
diff --git a/boxes/scripts/boxes_main.py b/boxes/scripts/boxes_main.py
index 4d0f3f0a..1c012ded 100755
--- a/boxes/scripts/boxes_main.py
+++ b/boxes/scripts/boxes_main.py
@@ -1,24 +1,17 @@
#!/usr/bin/env python3
-"""boxes.py
+"""
Generate stencils for wooden boxes.
-Usage:
- boxes [...]
- boxes --list
- boxes --examples
- boxes (-h | --help)
-
-Options:
- --list List available generators.
- --examples Generates an SVG for every generator into the "examples" folder.
- -h --help Show this screen.
"""
from __future__ import annotations
import gettext
import os
import sys
+import copy
+import argparse
+import hashlib
from pathlib import Path
try:
@@ -28,8 +21,17 @@
import boxes
import boxes.generators
+import boxes.svgmerge
+
+import yaml
+class ArgumentParserError(Exception): pass
+
+class ThrowingArgumentParser(argparse.ArgumentParser):
+ def error(self, message):
+ raise ArgumentParserError(message)
+
def print_grouped_generators() -> None:
class ConsoleColors:
BOLD = '\033[1m'
@@ -48,31 +50,154 @@ class ConsoleColors:
description = description.replace("\n", "").replace("\r", "").strip()
print(f' * {box.__name__:<15} - {ConsoleColors.ITALIC}{description}{ConsoleColors.CLEAR}')
+def multi_generate(config_path : Path, output_path : Path|str, output_name_formater=None, format="svg") -> list[str]:
+ if isinstance(config_path, str) or isinstance(config_path, Path):
+ with open(config_path) as ff:
+ config_data = yaml.safe_load(ff)
+ else:
+ config_data = yaml.safe_load(config_path)
-def create_example_every_generator() -> None:
- print("Generating SVG examples for every possible generator.")
- for group in generator_groups():
- for boxExample in group.generators:
- boxName = boxExample.__name__
- notTestGenerator = ('GridfinityTrayLayout', 'TrayLayout', 'TrayLayoutFile', 'TypeTray', 'Edges',)
- brokenGenerator = ()
- avoidGenerator = notTestGenerator + brokenGenerator
- if boxName in avoidGenerator:
- print(f"SKIP: {boxName}")
+ all_generators = boxes.generators.getAllBoxGenerators()
+ generators_by_name = {b.__name__: b for b in all_generators.values()}
+
+ generated_files = []
+ defaults = config_data.get("Defaults", {})
+
+ for ii, box_settings in enumerate(config_data.get("Boxes", [])):
+ # Allow for skipping generation
+ if box_settings.get("generate") == False:
+ continue
+
+ # Get the box generator
+ box_type = box_settings.pop("box_type", None)
+ if box_type is None:
+ raise ValueError("box_type must be provided for each cut")
+
+ # __ALL__ is a special case
+ box_classes: tuple|None = None
+ if box_type != "__ALL__":
+ box_classes = ( generators_by_name.get(box_type, None), )
+ if box_classes == (None,):
+ raise ValueError("invalid generator '%s'" % box_type)
+ else:
+ skipGenerators = set(box_settings.get("skipGenerators", []))
+ brokenGenerators = set(box_settings.get("brokenGenerators", []))
+ avoidGenerators = skipGenerators | brokenGenerators
+ box_classes = tuple(filter(lambda x: x.__name__ not in avoidGenerators, all_generators.values()))
+
+ for box_cls in box_classes:
+ # box_cls should never be None, but this check prevents mypy from complaining
+ if box_cls is None:
continue
- print(f"Generate example for: {boxName}")
+ box_cls_name = box_cls.__name__
- box = boxExample()
+ # Instantitate the box object
+ box = box_cls()
box.translations = get_translation()
- box.parseArgs("")
+
+ # Create the settings for the generator
+ settings = copy.deepcopy(defaults)
+ settings.update(box_settings.get("args", {}))
+
+ # Handle layout separately
+ if hasattr(box, "layout") and "layout" in settings:
+ if os.path.exists(settings["layout"]):
+ with open(settings["layout"]) as ff:
+ settings["layout"] = ff.read()
+ else:
+ box.layout = settings["layout"]
+
+ # Turn the settings into arguments, but ignore format
+ # in the YAML file if provided and use the argument to the function
+ box_args = []
+ for kk, vv in settings.items():
+ # Handle format separately
+ if kk in ("format","layout"):
+ continue
+ box_args.append(f"--{kk}={vv}")
+
+ # Layout has three options:
+ # - provided verbatim in the YAML file
+ # - provided as a path to a file in the YAML file
+ # - using the special placeholder __GENERATE__ which will invoke the default
+ if "layout" in settings:
+ if os.path.exists(settings["layout"]):
+ with open(settings["layout"]) as ff:
+ layout = ff.read()
+ else:
+ layout = settings["layout"]
+ box_args.append(f"--layout={layout}")
+
+ # SVG is default, only apply argument if changing default
+ if format != "svg":
+ box_args.append(f"--format={format}")
+
+ # Parse the box arguments - because we allow arguments at the
+ # top-level defaults, we ignore unknown arguments
+ try:
+ # Ignore unknown arguments by pre-parsing. This two stage
+ # approach was performed to avoid modifying parseArgs and
+ # changing it's behavior. A long-term better solution
+ # might be to allow parseArgs to take a 'strict' argument
+ # the can enable/disable strict parsing of arguments
+ args, argv = box.argparser.parse_known_args(box_args)
+ if len(argv) > 0:
+ for unknown_arg in argv:
+ box_args.remove(unknown_arg)
+ box.parseArgs(box_args)
+ except ArgumentParserError:
+ print("Error parsing box args for box %s : %s", ii, box_cls_name)
+ continue
+
+ # handle __GENERATE__ which must be called after parseArgs
+ if getattr(box, "layout", None) == "__GENERATE__":
+ if hasattr(box, "generate_layout") and callable(box.generate_layout):
+ setattr(box, "layout", box.generate_layout()) # use setattr to avoid mypy warning
+ else:
+ print("Error box %s : %s requires manual layout", ii, box_cls_name)
+ continue
+
box.metadata["reproducible"] = True
+
+ # Render the box SVG
box.open()
box.render()
- boxData = box.close()
-
- file = Path('examples') / (boxName + '.svg')
- file.write_bytes(boxData.getvalue())
-
+ data = box.close()
+
+ if callable(output_name_formater):
+ output_fname = output_name_formater(
+ box_type=box_cls_name,
+ name=box_settings.get("name", box_cls_name),
+ box_idx=ii,
+ metadata=box.metadata,
+ box_args=box_args
+ )
+ else:
+ output_fname = output_name_formater.format(
+ box_type=box_cls_name,
+ name=box_settings.get("name", box_cls_name),
+ box_idx=ii,
+ metadata=box.metadata,
+ )
+
+ # Write the output - if count is provided generate multiple copies
+ if box_settings.get("count") is not None:
+ for jj in range(int(box_settings.get("count"))):
+ output_file = os.path.join(output_path, f"{output_fname}_{jj}.{format}")
+ print(f"Writing {output_file}")
+ with open(output_file, "wb") as ff:
+ ff.write(data.read())
+ data.seek(0)
+ generated_files.append(output_file)
+
+ else:
+ output_file = os.path.join(output_path, f"{output_fname}.{format}")
+ print(f"Writing {output_file}")
+ with open(output_file, "wb") as ff:
+ ff.write(data.read())
+ generated_files.append(output_file)
+
+ return generated_files
def get_translation():
try:
@@ -124,31 +249,85 @@ def generators_by_name() -> dict[str, type[boxes.Boxes]]:
}
-def print_usage() -> None:
- print(__doc__)
-
-
def print_version() -> None:
print("boxes does not use versioning.")
+def example_output_fname_formatter(box_type, name, box_idx, metadata, box_args):
+ if not box_args:
+ return f"{name}"
+ else:
+ args_hash = hashlib.sha1(" ".join(sorted(box_args)).encode("utf-8")).hexdigest()
+ return f"{name}_{args_hash[0:8]}"
+
+
def main() -> None:
- if len(sys.argv) > 1 and sys.argv[1].startswith("--id="):
- del sys.argv[1]
- if len(sys.argv) == 1 or sys.argv[1] == '--help' or sys.argv[1] == '-h':
- print_usage()
- elif sys.argv[1] == '--version':
+ parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter, description=__doc__)
+ parser.allow_abbrev = False
+ parser.add_argument("--generator", type=str, default=None)
+ parser.add_argument("--id", type=str, default=None, help="ignored")
+ parser.add_argument("--debug", action="store_true", default=False)
+ parser.add_argument("--version", action="store_true", default=False)
+ parser.add_argument("--list", action="store_true", default=False, help="List available generators.")
+ parser.add_argument("--examples", action="store_true", default=False, help='Generates an SVG for every generator into the "examples" folder.')
+ parser.add_argument("--multi-generator", type=argparse.FileType('r', encoding='UTF-8'), help="Generate multiple boxes from a configuration YAML")
+ parser.add_argument("--merge", action="store_true", default=False, help="Merge multiple SVG files into optimal cuts for a given panel size")
+ args, extra = parser.parse_known_args()
+ if args.generator and (args.examples or args.multi_generator or args.list):
+ parser.error("cannot combine --generator with other commands")
+
+ # if debug is True set logging level
+ if args.debug:
+ logging.getLogger().setLevel(logging.DEBUG)
+
+ # Handle various actions
+ if args.version:
print_version()
- elif sys.argv[1] == '--list':
+ elif args.list:
print_grouped_generators()
- elif sys.argv[1] == '--examples':
- create_example_every_generator()
+ elif args.examples:
+ print("Generating SVG examples for every possible generator.")
+ config_path = Path(__file__).parent.parent.parent / 'examples.yml'
+ output_path = Path("examples")
+ multi_generate(config_path, output_path, example_output_fname_formatter)
+ elif args.multi_generator:
+ try:
+ if os.path.isdir(extra[0]):
+ # if the output path is a folder assume the default name format
+ # and write all files to the sub-folder
+ output_dest = extra[0]
+ output_fname_format = "{name}_{box_idx}"
+ elif "{" not in extra[0] and "}" not in extra[0]:
+ # if substitution brackets aren't found, assume that isn't
+ # the desired behavior since it would cause every file to overwrite previous files
+ # so use this as a prefix with box index
+ output_dest = os.path.dirname(extra[0])
+ output_fname_format = extra[0] + "_{box_idx}"
+ else:
+ # The user has provided a full path template, so use it as-is
+ output_dest = os.path.dirname(extra[0])
+ output_fname_format = os.path.basename(extra[0])
+ except IndexError:
+ output_dest = "."
+ output_fname_format = "{name}_{box_idx}"
+ multi_generate(args.multi_generator, output_dest, output_fname_format)
+ elif args.merge:
+ merger = boxes.svgmerge.SvgMerge()
+ merger.parseArgs(extra)
+ merger.render(extra)
+ data = merger.close()
+ with os.fdopen(sys.stdout.fileno(), "wb", closefd=False) if merger.output == "-" else open(merger.output, 'wb') as f:
+ f.write(data.getvalue())
else:
- name = sys.argv[1].lower()
- if name.startswith("--generator="):
- name = name[12:]
- run_generator(name, sys.argv[2:])
-
+ if args.generator:
+ name = args.generator
+ else:
+ name = extra.pop(0).lower()
+ run_generator(name, extra)
if __name__ == '__main__':
+ # Setup basic logging
+ import logging
+ logging.basicConfig(level=logging.INFO)
+
main()
diff --git a/boxes/svgmerge.py b/boxes/svgmerge.py
new file mode 100644
index 00000000..f6c5167f
--- /dev/null
+++ b/boxes/svgmerge.py
@@ -0,0 +1,344 @@
+import logging
+import argparse
+import uuid
+import io
+import re
+
+import xml.etree.ElementTree as ET
+import rectpack
+from rectpack import newPacker, PackingBin
+from svgpathtools import parse_path
+
+SVG_NS = "http://www.w3.org/2000/svg"
+ET.register_namespace("", SVG_NS)
+
+PACK_ALGO_CHOICES = (
+ "MaxRectsBl",
+ "MaxRectsBssf",
+ "MaxRectsBaf",
+ "MaxRectsBlsf",
+ "SkylineBl",
+ "SkylineBlWm",
+ "SkylineMwf",
+ "SkylineMwfl",
+ "SkylineMwfWm",
+ "SkylineMwflWm",
+ "GuillotineBssfSas",
+ "GuillotineBssfLas",
+ "GuillotineBssfSlas",
+ "GuillotineBssfLlas",
+ "GuillotineBssfMaxas",
+ "GuillotineBssfMinas",
+ "GuillotineBlsfSas",
+ "GuillotineBlsfLas",
+ "GuillotineBlsfSlas",
+ "GuillotineBlsfLlas",
+ "GuillotineBlsfMaxas",
+ "GuillotineBlsfMinas",
+ "GuillotineBafSas",
+ "GuillotineBafLas",
+ "GuillotineBafSlas",
+ "GuillotineBafLlas",
+ "GuillotineBafMaxas",
+ "GuillotineBafMinas",
+)
+
+class SvgMerge:
+ def __init__(self):
+ self.args = None
+ self.non_default_args = {}
+ self.output = None
+ self.argparser = argparse.ArgumentParser()
+ self.argparser.add_argument("cuts", nargs="+", help="Input cut files")
+ self.argparser.add_argument("--rotation", default=False, action="store_true")
+ self.argparser.add_argument("--debug-bbox", default=False, action="store_true")
+ self.argparser.add_argument("--bin_algo", default="Global", choices=("BNF", "BFF", "BBF", "Global"))
+ self.argparser.add_argument("--pack_algo", default="MaxRectsBssf", choices=PACK_ALGO_CHOICES)
+ self.argparser.add_argument("--panel_width", type=int, default=300, help="Panel width in mm")
+ self.argparser.add_argument("--panel_height", type=int, default=300, help="Panel height in mm")
+ self.argparser.add_argument("--dpi", type=int, default=96, help="SVG resolution in dots-per-inch")
+ self.argparser.add_argument("--margin", type=int, default=1, help="margin around outside of element in mm")
+ self.argparser.add_argument("--output", type=str, default="merged.svg", help="name of resulting file")
+
+ @staticmethod
+ def parse_svg_groups(svg_file):
+ """
+ Parse out all the SVG groups from the given SVG file
+ """
+ tree = ET.parse(svg_file)
+ root = tree.getroot()
+ groups = [g for g in root if g.tag.endswith('g')]
+ return groups, tree
+
+ @staticmethod
+ def get_bbox_of_group(group):
+ """
+ Get the bounding box of the SVG group
+ """
+ min_x = float("inf")
+ min_y = float("inf")
+ max_x = float("-inf")
+ max_y = float("-inf")
+
+ def update_bbox(x_vals, y_vals):
+ nonlocal min_x, min_y, max_x, max_y
+ min_x = min(min_x, *x_vals)
+ min_y = min(min_y, *y_vals)
+ max_x = max(max_x, *x_vals)
+ max_y = max(max_y, *y_vals)
+
+ for elem in group.iter():
+ tag = elem.tag.split("}")[-1] # Remove namespace
+ if tag == "rect":
+ x = float(elem.attrib.get("x", 0))
+ y = float(elem.attrib.get("y", 0))
+ w = float(elem.attrib.get("width", 0))
+ h = float(elem.attrib.get("height", 0))
+ update_bbox([x, x + w], [y, y + h])
+ elif tag == "circle":
+ cx = float(elem.attrib.get("cx", 0))
+ cy = float(elem.attrib.get("cy", 0))
+ r = float(elem.attrib.get("r", 0))
+ update_bbox([cx - r, cx + r], [cy - r, cy + r])
+ elif tag == "ellipse":
+ cx = float(elem.attrib.get("cx", 0))
+ cy = float(elem.attrib.get("cy", 0))
+ rx = float(elem.attrib.get("rx", 0))
+ ry = float(elem.attrib.get("ry", 0))
+ update_bbox([cx - rx, cx + rx], [cy - ry, cy + ry])
+ elif tag == "line":
+ x1 = float(elem.attrib.get("x1", 0))
+ y1 = float(elem.attrib.get("y1", 0))
+ x2 = float(elem.attrib.get("x2", 0))
+ y2 = float(elem.attrib.get("y2", 0))
+ update_bbox([x1, x2], [y1, y2])
+ elif tag in ["polyline", "polygon"]:
+ points = elem.attrib.get("points", "")
+ point_pairs = re.findall(r"[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?", points)
+ coords = list(map(float, point_pairs))
+ xs = coords[::2]
+ ys = coords[1::2]
+ if xs and ys:
+ update_bbox(xs, ys)
+ elif tag == "path":
+ d = elem.attrib.get("d", "")
+ try:
+ path = parse_path(d)
+ box = path.bbox() # (min_x, max_x, min_y, max_y)
+ update_bbox([box[0], box[1]], [box[2], box[3]])
+ except Exception as e:
+ print(f"Warning: Failed to parse path in group. Error: {e}")
+
+ # Fallback if nothing was found
+ if min_x == float("inf"):
+ raise ValueError
+ return [min_x, min_y, max_x, max_y]
+
+ @staticmethod
+ def extract_elements(svg_files):
+ """
+ Extract all group elements from the SVG
+ """
+ elements = []
+ for file in svg_files:
+ groups, tree = SvgMerge.parse_svg_groups(file)
+ for g in groups:
+ bbox = SvgMerge.get_bbox_of_group(g)
+ width = bbox[2] - bbox[0]
+ height = bbox[3] - bbox[1]
+ style = g.attrib.get("style", '')
+ elements.append({
+ 'group': g,
+ 'bbox': bbox,
+ 'width': width,
+ 'height': height,
+ 'style': style,
+ 'id': str(uuid.uuid4()),
+ 'source_file': file
+ })
+ return elements
+
+ @staticmethod
+ def pack_elements(elements, box_width, box_height, margin, rotation, bin_algo, pack_algo):
+ """
+ Pack all the group elements into the minimum number of panels.
+ """
+ try:
+ bin_algo = getattr(PackingBin, bin_algo)
+ except AttributeError:
+ raise RuntimeError("invalid bin algorithm specified")
+
+ try:
+ pack_algo = getattr(rectpack, pack_algo)
+ except AttributeError:
+ raise RuntimeError("invalid pack algorithm specified")
+
+ packer = newPacker(
+ rotation=rotation, # rotating packing is still a WIP
+ pack_algo=pack_algo,
+ bin_algo=bin_algo
+ )
+ for elem in elements:
+ packer.add_rect(elem['width'] + (margin*2), elem['height'] + (margin*2), elem['id'])
+ packer.add_bin(box_width, box_height, float("inf")) # unlimited bins
+ packer.pack()
+
+ packed = []
+ for bid, abin in enumerate(packer):
+ for rect in abin:
+ x, y, packed_w, packed_h, rid = rect.x, rect.y, rect.width, rect.height, rect.rid
+ elem = next(e for e in elements if e['id'] == rid)
+
+ original_w = elem['width'] + (margin*2)
+ original_h = elem['height']+ (margin*2)
+ rotated = (
+ round(packed_w) == round(original_h) and round(packed_h) == round(original_w)
+ )
+
+ packed.append({
+ 'element': elem,
+ 'x': x,
+ 'y': y,
+ 'bin': bid,
+ 'style': elem['style'],
+ 'rotated': rotated
+ })
+ return packed
+
+ @staticmethod
+ def create_output_svg(packed_elements, box_width, box_height, margin, include_debug_bbox=False):
+ """
+ Create the merged SVG output.
+ """
+ svg = ET.Element(f"{{{SVG_NS}}}svg")
+ bins = {}
+ spacing = 20 # Space between bins in the output SVG
+
+ for item in packed_elements:
+ bin_id = item['bin']
+ if bin_id not in bins:
+ # Create a new bin group
+ bin_group = ET.SubElement(svg, "g", attrib={'id': f'bin_{bin_id}'})
+ # Place each bin horizontally spaced apart
+ bin_group.set("transform", f"translate({bin_id * (box_width + spacing)}, 0)")
+ bins[bin_id] = bin_group
+
+ # Add bounding box rectangle to bin
+ rect = ET.Element("rect", {
+ "x": "0",
+ "y": "0",
+ "width": str(box_width),
+ "height": str(box_height),
+ "fill": "none",
+ "stroke": "rgb( 208, 208, 0)",
+ "stroke-width": "1"
+ })
+ bin_group.append(rect)
+
+ elem = item['element']
+ g = elem['group']
+ bbox = elem['bbox']
+ original_w = elem['width']
+ original_h = elem['height']
+ x, y = item['x'], item['y']
+ rotated = item['rotated']
+ style = item['style']
+
+ # Normalize the group to (0, 0)
+ dx = -bbox[0]
+ dy = -bbox[1]
+
+ # Create a new group and apply transforms in order:
+ # 1. Move to (x, y) in the bin
+ # 2. Rotate if needed
+ # 3. Offset to normalize original group position
+
+ transform_parts = []
+
+ # Step 1: move to packed (x, y)
+ transform_parts.append(f"translate({x+margin},{y+margin})")
+
+ if rotated:
+ dy -= original_h
+ # Step 2: rotate 90° around the origin
+ transform_parts.append("rotate(90)")
+ # Step 3: apply offset to align rotated group
+ transform_parts.append(f"translate({dx}, {dy})")
+ else:
+ # Step 3: apply offset without rotation
+ transform_parts.append(f"translate({dx},{dy})")
+
+ full_transform = " ".join(transform_parts)
+
+ # Clone the group with transformation
+ new_g = ET.Element("g", attrib={"transform": full_transform, "style": style})
+ for child in list(g):
+ new_g.append(child)
+
+ if include_debug_bbox:
+ new_g.append(
+ ET.Element(
+ "rect",
+ attrib={
+ "x": str(bbox[0]),
+ "y": str(bbox[1]),
+ "width": str(original_w),
+ "height": str(original_h),
+ "stroke": "rgb(255,128,0)",
+ "fill": "none",
+ }
+ )
+ )
+ bins[bin_id].append(new_g)
+
+
+ return ET.ElementTree(svg)
+
+ def parseArgs(self, args):
+ self.args = args
+ for key, value in vars(self.argparser.parse_args(args=args)).items():
+ default = self.argparser.get_default(key)
+
+ # treat edge settings separately
+ setattr(self, key, value)
+ if value != default:
+ self.non_default_args[key] = value
+
+ def render(self, files):
+ if self.args is None:
+ raise RuntimeError("parseArgs must be called first")
+
+ files = set(files)
+
+ # convert width/height in mm to pixels
+ if self.panel_width > 0 and self.panel_height > 0:
+ width_px = int( (self.panel_width / 25.4) * 96)
+ height_px = int( (self.panel_height / 25.4) * 96)
+ margin_px = int( (self.margin / 25.4) * 96)
+
+ logging.info("Merging %s files", len(files))
+ elements = SvgMerge.extract_elements(list(files))
+ for element in elements:
+ if element['width'] > self.panel_width or element['height'] > self.panel_height:
+ logging.warning("Element in %s is larger than panel width and will not be included in merged output", element['source_file'])
+ packed = SvgMerge.pack_elements(
+ elements,
+ self.panel_width,
+ self.panel_height,
+ margin_px,
+ self.rotation,
+ self.bin_algo,
+ self.pack_algo
+ )
+ self.result_svg = SvgMerge.create_output_svg(
+ packed,
+ self.panel_width,
+ self.panel_height,
+ margin_px,
+ self.debug_bbox
+ )
+
+ def close(self):
+ result = io.BytesIO()
+ self.result_svg.write(result, encoding='utf-8', xml_declaration=True)
+ return result
diff --git a/examples.yml b/examples.yml
new file mode 100644
index 00000000..405258bf
--- /dev/null
+++ b/examples.yml
@@ -0,0 +1,56 @@
+Boxes:
+ - box_type: __ALL__
+ skipGenerators: [ GridfinityTrayLayout, TrayLayout, TrayLayoutFile, TypeTray, Edges ]
+ brokenGenerators: []
+ - box_type: GridfinityTrayLayout
+ args:
+ h: 6u
+ nx: 3
+ ny: 3
+ gen_pads: 0
+ layout: |2
+ ,> 62.625mm
+ | ,> 62.625mm
+ +-+-+
+ | | | 62.625mm
+ +-+-+
+ | | | 62.625mm
+ +-+-+
+ - box_type: TrayLayout
+ args:
+ h: 50
+ layout: |2
+ ,> 25.00mm
+ | ,> 25.00mm
+ | | ,> 25.00mm
+ +-+-+-+
+ | | | | 25.00mm
+ +-+-+-+
+ | | | | 25.00mm
+ +-+-+-+
+ | | | | 25.00mm
+ +-+-+-+
+ - box_type: GridfinityBase
+ args:
+ base_type: refined
+ size_x: 300
+ size_y: 300
+ x: 0
+ y: 0
+ - box_type: GridfinityBase
+ args:
+ base_type: refined
+ x: 5
+ y: 5
+ h: 0
+ - box_type: GridfinityBase
+ name: GridfinityBaseSplit
+ args:
+ cut_pads: 1
+ panel_x: 290
+ panel_y: 290
+ size_x: 393
+ size_y: 600
+ x: 0
+ y: 0
+ m: 0
diff --git a/examples/GridfinityBaseSplit.svg b/examples/GridfinityBaseSplit_24199a33.svg
similarity index 99%
rename from examples/GridfinityBaseSplit.svg
rename to examples/GridfinityBaseSplit_24199a33.svg
index 9f3310a4..3f988708 100644
--- a/examples/GridfinityBaseSplit.svg
+++ b/examples/GridfinityBaseSplit_24199a33.svg
@@ -10,15 +10,13 @@ This is a configurable gridfinity base. This
Created with Boxes.py (https://boxes.hackerspace-bamberg.de/)
-Creation date: 2025-02-24 21:30:05
-Command line (remove spaces between dashes): boxes GridfinityBase - -cut_pads - -sx - -sy - -x - -y - -m - -cut_pads
+Command line (remove spaces between dashes): boxes GridfinityBase - -cut_pads=1 - -panel_x=290 - -panel_y=290 - -size_x=393 - -size_y=600 - -x=0 - -y=0 - -m=0
-->
GridfinityBase
Tray - GridfinityBase
-2025-02-24 21:30:05
-boxes GridfinityBase --cut_pads 1 --panel-x 290 --panel-y 290 --sx 393 --sy 600 --x 0 --y 0 --m 0 --cut_pads 1 --thickness 3
+boxes GridfinityBase --cut_pads=1 --panel_x=290 --panel_y=290 --size_x=393 --size_y=600 --x=0 --y=0 --m=0
A parameterized Gridfinity base
This is a configurable gridfinity base. This
@@ -26,8 +24,8 @@ This is a configurable gridfinity base. This
<a href="https://www.youtube.com/watch?app=desktop&v=ra_9zU-mnl8">Zach Freedman's Gridfinity system</a>
Created with Boxes.py (https://boxes.hackerspace-bamberg.de/)
-Command line: boxes GridfinityBase --cut_pads 1 --panel-x 290 --panel-y 290 --sx 393 --sy 600 --x 0 --y 0 --m 0 --cut_pads 1 --thickness 3
-Command line short: boxes GridfinityBase --cut_pads --sx --sy --x --y --m --cut_pads
+Command line: boxes GridfinityBase --cut_pads=1 --panel_x=290 --panel_y=290 --size_x=393 --size_y=600 --x=0 --y=0 --m=0
+Command line short: boxes GridfinityBase --cut_pads=1 --panel_x=290 --panel_y=290 --size_x=393 --size_y=600 --x=0 --y=0 --m=0
diff --git a/examples/GridfinityBase_a661b1b8.svg b/examples/GridfinityBase_a661b1b8.svg
new file mode 100644
index 00000000..f6ab462d
--- /dev/null
+++ b/examples/GridfinityBase_a661b1b8.svg
@@ -0,0 +1,396 @@
+
+
\ No newline at end of file
diff --git a/examples/GridfinityBase_c5e11e14.svg b/examples/GridfinityBase_c5e11e14.svg
new file mode 100644
index 00000000..bb217700
--- /dev/null
+++ b/examples/GridfinityBase_c5e11e14.svg
@@ -0,0 +1,256 @@
+
+
\ No newline at end of file
diff --git a/examples/GridfinityTrayLayout_75809341.svg b/examples/GridfinityTrayLayout_75809341.svg
new file mode 100644
index 00000000..5311299a
--- /dev/null
+++ b/examples/GridfinityTrayLayout_75809341.svg
@@ -0,0 +1,107 @@
+
+
\ No newline at end of file
diff --git a/examples/TrayLayout_21abf2e5.svg b/examples/TrayLayout_21abf2e5.svg
new file mode 100644
index 00000000..2d060522
--- /dev/null
+++ b/examples/TrayLayout_21abf2e5.svg
@@ -0,0 +1,106 @@
+
+
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 0f0d4106..8d40cf69 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,9 @@
affine>=2.0
markdown
+PyYAML
qrcode>=7.3.1
+rectpack
setuptools
shapely>=1.8.2
sphinx
+svgpathtools
diff --git a/requirements_dev.txt b/requirements_dev.txt
index d53760d5..feb5153c 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -3,3 +3,4 @@ mypy
pre-commit
pytest>=8.1.1
types-Markdown
+types-PyYAML
diff --git a/scripts/boxes_generator b/scripts/boxes_generator
new file mode 100644
index 00000000..b2c29fec
--- /dev/null
+++ b/scripts/boxes_generator
@@ -0,0 +1 @@
+../boxes/scripts/boxes_generator.py
diff --git a/tests/test_svg.py b/tests/test_svg.py
index c72d5c08..6c08dc12 100644
--- a/tests/test_svg.py
+++ b/tests/test_svg.py
@@ -1,6 +1,8 @@
from __future__ import annotations
import sys
+import os
+import hashlib
from pathlib import Path
import pytest
@@ -15,17 +17,33 @@
import boxes.generators
+import yaml
+
+
class TestSVG:
"""Test SVG creation of box generators.
Just test generators which have a default output without an input requirement.
Uses files from examples folder as reference.
"""
- all_generators = boxes.generators.getAllBoxGenerators().values()
- # Ignore multistep generators and generators which require input.
- notTestGenerators = ('GridfinityTrayLayout', 'TrayLayout', 'TrayLayoutFile', 'TypeTray', 'Edges',)
- brokenGenerators = ()
- avoidGenerator = notTestGenerators + brokenGenerators
+ configPath = Path(__file__).parent.parent / 'examples.yml'
+ with open(configPath) as ff:
+ configData = yaml.safe_load(ff)
+
+ all_generators = boxes.generators.getAllBoxGenerators()
+ generators_by_name = {b.__name__: b for b in all_generators.values()}
+
+ notTestGenerators = set()
+ brokenGenerators = set()
+ additionalTests = []
+ # find the __ALL__ generator in examples.yml
+ for ii, box_settings in enumerate(configData.get("Boxes")):
+ if box_settings.get("box_type") == "__ALL__":
+ notTestGenerators = set(box_settings.get("skipGenerators", []))
+ brokenGenerators = set(box_settings.get("brokenGenerators", []))
+ else:
+ additionalTests.append(box_settings)
+ avoidGenerator = notTestGenerators | brokenGenerators
def test_generators_available(self) -> None:
assert len(self.all_generators) != 0
@@ -36,6 +54,15 @@ def test_generators_available(self) -> None:
# result = subprocess.run(['svgcheck', file_path], capture_output=True, text=True)
# return "INFO: File conforms to SVG requirements." in result.stdout
+ @staticmethod
+ def get_additional_test_args_hash(generator_settings):
+ # this needs to stay synchronized with boxes_main example_output_fname_formatter
+ boxArgs = []
+ for kk, vv in generator_settings["args"].items():
+ boxArgs.append(f"--{kk}={vv}")
+ argsHash = hashlib.sha1(" ".join(sorted(boxArgs)).encode("utf-8")).hexdigest()
+ return boxArgs, argsHash
+
@staticmethod
def is_valid_xml_by_lxml(xml_string: str) -> bool:
try:
@@ -48,12 +75,20 @@ def is_valid_xml_by_lxml(xml_string: str) -> bool:
def idfunc(val) -> str:
return f"{val.__name__}"
+ @staticmethod
+ def idfunc_args(generator_idx) -> str:
+ generator_settings = TestSVG.additionalTests[generator_idx]
+ boxName = generator_settings["box_type"]
+ boxArgs, argsHash = TestSVG.get_additional_test_args_hash(generator_settings)
+ boxArgs = " ". join(boxArgs)
+ return f"{boxName}_{generator_idx}_{argsHash}_{boxArgs}"
+
@pytest.mark.parametrize(
"generator",
- all_generators,
+ all_generators.values(),
ids=idfunc.__func__,
)
- def test_generator(self, generator: type[boxes.Boxes], capsys) -> None:
+ def test_default_generator(self, generator: type[boxes.Boxes], capsys) -> None:
boxName = generator.__name__
if boxName in self.avoidGenerator:
pytest.skip("Skipped generator")
@@ -81,3 +116,75 @@ def test_generator(self, generator: type[boxes.Boxes], capsys) -> None:
assert referenceData.exists() is True, "Reference file for comparison does not exist."
assert referenceData.is_file() is True, "Reference file for comparison does not exist."
assert referenceData.read_bytes() == boxData.getvalue(), "SVG files are not equal. If change is intended, please update example files."
+
+ if additionalTests:
+ @pytest.mark.parametrize(
+ "generator_idx",
+ range(len(additionalTests)),
+ ids=idfunc_args.__func__,
+ )
+ def test_additonal_generator(self, generator_idx, capsys) -> None:
+ generator_settings = self.additionalTests[generator_idx]
+ boxType = generator_settings.get("box_type", None)
+ if boxType is None:
+ pytest.fail("box_type must be provided for additional tests")
+ generator = self.generators_by_name.get(boxType, None)
+ if generator is None:
+ pytest.fail(f"{boxType} is not a valid generator {self.all_generators.keys()}")
+ boxName = generator_settings.get("name", boxType)
+ box = generator()
+
+ boxArgs, argsHash = TestSVG.get_additional_test_args_hash(generator_settings)
+
+ box.parseArgs(boxArgs)
+ box.metadata["reproducible"] = True
+ box.metadata["args_hash"] = argsHash
+ box.open()
+ box.render()
+ boxData = box.close()
+
+ out, err = capsys.readouterr()
+
+ assert 100 < boxData.__sizeof__(), "No data generated."
+ assert 0 == len(out), "Console output generated."
+ assert 0 == len(err), "Console error generated."
+
+ # Use external library lxml as cross-check.
+ assert self.is_valid_xml_by_lxml(boxData.getvalue()) is True, "Invalid XML according to library lxml."
+
+ file = Path(__file__).resolve().parent / 'data' / (boxName + '_' + argsHash[0:8] + '.svg')
+ file.write_bytes(boxData.getvalue())
+
+ # Use example data from repository as reference data.
+ referenceData = Path(__file__).resolve().parent.parent / 'examples' / (boxName + '_' + argsHash[0:8] + '.svg')
+ assert referenceData.exists() is True, "Reference file for comparison does not exist."
+ assert referenceData.is_file() is True, "Reference file for comparison does not exist."
+ assert referenceData.read_bytes() == boxData.getvalue(), "SVG files are not equal. If change is intended, please update example files."
+
+ def test_abondoned_examples(self, capsys) -> None:
+ # Load the args hash for all defined additionalTests
+ validTests = set()
+ for generator_settings in self.additionalTests:
+ boxType = generator_settings.get("box_type", None)
+ boxName = generator_settings.get("name", boxType)
+
+ if boxName is None:
+ continue
+ generator = self.generators_by_name.get(boxType, None)
+ if generator is None:
+ continue
+
+ boxArgs, argsHash = TestSVG.get_additional_test_args_hash(generator_settings)
+ validTests.add((boxName, argsHash[0:8]))
+
+ # Now look for the files
+ exampleFiles = set()
+ referenceData = Path(__file__).resolve().parent.parent / 'examples'
+ for referenceFile in os.listdir(referenceData):
+ if referenceFile.endswith(".svg") and "_" in referenceFile:
+ boxName, argsHash = referenceFile[:-4].split("_")
+ exampleFiles.add((boxName, argsHash))
+
+ extraExamples = exampleFiles - validTests
+ if extraExamples:
+ pytest.fail(f"{len(extraExamples)} extra files found: {extraExamples}")