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 @@ + + + +GridfinityBase + + +Tray - GridfinityBase +boxes GridfinityBase --base_type=refined --size_x=300 --size_y=300 --x=0 --y=0 +A parameterized Gridfinity base + +This is a configurable gridfinity base. This + design is based on + <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 --base_type=refined --size_x=300 --size_y=300 --x=0 --y=0 +Command line short: boxes GridfinityBase --base_type=refined --size_x=300 --size_y=300 --x=0 --y=0 + + + + + 100.0mm, burn:0.10mm + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + +GridfinityBase + + +Tray - GridfinityBase +boxes GridfinityBase --base_type=refined --x=5 --y=5 --h=0 +A parameterized Gridfinity base + +This is a configurable gridfinity base. This + design is based on + <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 --base_type=refined --x=5 --y=5 --h=0 +Command line short: boxes GridfinityBase --base_type=refined --x=5 --y=5 --h=0 + + + + + 100.0mm, burn:0.10mm + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + +GridfinityTrayLayout + + +Tray - GridfinityTrayLayout +boxes GridfinityTrayLayout --h=6u --nx=3 --ny=3 --gen_pads=0 '--layout= ,> 62.625mm\n | ,> 62.625mm\n+-+-+\n| | | 62.625mm\n+-+-+\n| | | 62.625mm\n+-+-+\n' +A Gridfinity Tray Generator based on TrayLayout + + +This is a general purpose gridfinity tray generator. You can create +somewhat arbitrarily shaped trays, or just do nothing for simple grid +shaped trays. + +The dimensions are automatically calculated to fit perfectly into a +gridfinity grid (like the GridfinityBase, or any other Gridfinity +based base). + +Edit the layout text graphics to adjust your tray. +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. + + +Created with Boxes.py (https://boxes.hackerspace-bamberg.de/) +Command line: boxes GridfinityTrayLayout --h=6u --nx=3 --ny=3 --gen_pads=0 '--layout= ,> 62.625mm\n | ,> 62.625mm\n+-+-+\n| | | 62.625mm\n+-+-+\n| | | 62.625mm\n+-+-+\n' +Command line short: boxes GridfinityTrayLayout --h=6u --ny=3 --gen_pads=0 '--layout= ,> 62.625mm\n | ,> 62.625mm\n+-+-+\n| | | 62.625mm\n+-+-+\n| | | 62.625mm\n+-+-+\n' + + + + + 100.0mm, burn:0.10mm + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + +TrayLayout + + +Tray - TrayLayout +boxes TrayLayout --h=50 '--layout= ,> 25.00mm\n | ,> 25.00mm\n | | ,> 25.00mm\n+-+-+-+\n| | | | 25.00mm\n+-+-+-+\n| | | | 25.00mm\n+-+-+-+\n| | | | 25.00mm\n+-+-+-+\n' +Generate a typetray from a layout file. + +This is a two step process. This is step 2. +Edit the layout text graphics to adjust your tray. +Put in the sizes for each column and row. 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. + + +Created with Boxes.py (https://boxes.hackerspace-bamberg.de/) +Command line: boxes TrayLayout --h=50 '--layout= ,> 25.00mm\n | ,> 25.00mm\n | | ,> 25.00mm\n+-+-+-+\n| | | | 25.00mm\n+-+-+-+\n| | | | 25.00mm\n+-+-+-+\n| | | | 25.00mm\n+-+-+-+\n' +Command line short: boxes TrayLayout --h=50 '--layout= ,> 25.00mm\n | ,> 25.00mm\n | | ,> 25.00mm\n+-+-+-+\n| | | | 25.00mm\n+-+-+-+\n| | | | 25.00mm\n+-+-+-+\n| | | | 25.00mm\n+-+-+-+\n' + + + + + 100.0mm, burn:0.10mm + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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}")