diff --git a/README.md b/README.md index 5eb5516..bdfefca 100644 --- a/README.md +++ b/README.md @@ -130,16 +130,13 @@ These parameters can be defined in the global scope of a scad file. #### lkerf Compensate laser kerf (shrinkage caused by the laser) in millimeters. *Default = 0* -#### lmargin -Distance between lparts in 2D in millimeters. *Default = 2* - ### Exporting to 2D 1. Drag your ``.scad`` source file(s) into the ``scad`` folder. 2. Open a shell in the folder containing ``Makefile`` and run ``make``. The resulting DXF files are located in the ``dxf`` folder. 3. *Recommended:* Open ``scad/_2d.scad`` with OpenSCAD to verify that all ``lpart`` dimensions were defined correctly and nothing overlaps. ### Sheet Size -There is currently no nice way of specifying the sheet size. However, it can be changed with a text editor in the ``Makefile``. The default is 600x300 (millimeters). +There is currently no nice way of specifying the sheet size. However, it can be changed with a text editor in the ``Makefile``. The default is 600x300 (millimeters). In the same spot, the 2D object margins can be set (*Default = 2*). ## FAQ ### What does laserscad do? diff --git a/dist/Makefile b/dist/Makefile index c325ba4..db69507 100644 --- a/dist/Makefile +++ b/dist/Makefile @@ -1,5 +1,8 @@ +# one of [fastest, medium, high, insane] +quality = medium sheet_xlen = 600 sheet_ylen = 300 +object_margin = 2 SOURCE = scad TEMP = temp @@ -54,12 +57,12 @@ $(TEMP)/%_bb.csv: $(SOURCE)/%.scad | $(SOURCE) $(TEMP) # optimize object placement $(TEMP)/%_pos.csv: $(TEMP)/%_bb.csv | $(UTIL) @echo Optimizing object placement... - @python3 $(UTIL)/column_packing.py $(sheet_xlen) $(sheet_ylen) $< $@ + @python3 $(UTIL)/packing.py $< $@ $(quality) $(sheet_xlen) $(sheet_ylen) $(object_margin) # copy the source scad file, then inject the optimal translations into it $(SOURCE)/%_2d.scad: $(SOURCE)/%.scad $(TEMP)/%_pos.csv | $(UTIL) @cp $< $@ - @python3 $(UTIL)/writeback.py $@ $(word 2,$^) + @python3 $(UTIL)/write_back.py $@ $(word 2,$^) $(OUTPUTS_DXF): $(TARGET_DXF)/%.dxf: $(SOURCE)/%_2d.scad | $(TARGET_DXF) @echo Creating $@... diff --git a/dist/laserscad.scad b/dist/laserscad.scad index 401fcc7..ae5fc65 100644 --- a/dist/laserscad.scad +++ b/dist/laserscad.scad @@ -55,11 +55,11 @@ module lpart(id, dims) { } } -// overwritten once optimal translations are known after packing +// overwritten once optimal translations/rotations are known after packing function _lpart_translation(id) = [0,0,0]; +function _lpart_rotation(id) = [0,0,0]; _lkerf_default = 0; -_lmargin_default = 2; // actual lpart after sanity checks module _lpart_sane(id, dims) { @@ -67,21 +67,22 @@ module _lpart_sane(id, dims) { children(); } else { lkerf = lkerf == undef? _lkerf_default : lkerf; - lmargin = lmargin == undef? _lmargin_default : lmargin; if (_laserscad_mode == 1) { - ext_dims = dims + 2 * (lkerf + lmargin) * [1,1]; + ext_dims = dims + 2*lkerf * [1,1]; echo(str("[laserscad] ##",id,",",ext_dims[0],",",ext_dims[1],"##")); } else { - translate(_lpart_translation(id) + (lkerf + lmargin)*[1,1,0]) { - // show the bounding box if in validate mode - if (_laserscad_mode == 2) { - color("magenta", 0.6) - square(dims + lkerf*[1,1]); + translate(_lpart_translation(id) + lkerf*[1,1,0]) { + rotate(_lpart_rotation(id)) { + // show the bounding box if in validate mode + if (_laserscad_mode == 2) { + color("magenta", 0.6) + square(dims + lkerf*[1,1]); + } + offset(delta=lkerf) + projection(cut=false) + children(); } - offset(delta=lkerf) - projection(cut=false) - children(); } } } @@ -93,7 +94,6 @@ module _lpart_sane(id, dims) { // print hints in dev mode if variables are undefined if (_laserscad_mode == 0) { _laserscad_var_sanity_check(lkerf, "lkerf", _lkerf_default); - _laserscad_var_sanity_check(lmargin, "lmargin", _lmargin_default); } module _laserscad_var_sanity_check(var, name, default) { diff --git a/dist/util/column_packing.py b/dist/util/column_packing.py deleted file mode 100644 index 60c3251..0000000 --- a/dist/util/column_packing.py +++ /dev/null @@ -1,46 +0,0 @@ -import sys, csv - - -def pack(sheet_x, sheet_y, bb_path, pos_path): - '''Arranges rectangles in columns. - ''' - - sheet_x, sheet_y = [float(s) for s in [sheet_x, sheet_y]] - - # make sure sheet_x > sheet_y - if (sheet_y > sheet_x): - sheet_x, sheet_y = sheet_y, sheet_x - - # read input - with open(bb_path, 'r') as bb_file: - reader = csv.reader(bb_file) - objects = [(name, float(x), float(y)) for name, x, y in list(reader)] - - # create a useful ordering for the objects: sort by x lengths - objects.sort(key=lambda tup: tup[1]) - - positions = {} - x = y = 0 - max_lenx_in_col = 0 - - for name, len_x, len_y in objects: - if (y + len_y > sheet_y): - x += max_lenx_in_col - y = 0 - max_lenx_in_col = 0 - - if (len_x > max_lenx_in_col): - max_lenx_in_col = len_x - - positions[name] = (x,y) - y += len_y - - # write to file - with open(pos_path, 'w') as pos_file: - writer = csv.writer(pos_file) - for name, pos in positions.items(): - writer.writerow([name, pos[0], pos[1]]) - - -if __name__=="__main__": - pack(*sys.argv[1:]) \ No newline at end of file diff --git a/dist/util/pack_annealing.py b/dist/util/pack_annealing.py new file mode 100644 index 0000000..27e7a0c --- /dev/null +++ b/dist/util/pack_annealing.py @@ -0,0 +1,376 @@ +import math +import random +import copy +from enum import Enum +from collections import defaultdict + +from pack_shared import Packer, Quality, Placement + + +class SimulatedAnnealingNeighborhood: + def get_random_neighbor(self): + raise NotImplementedError + + def get_score(self): + raise NotImplementedError + + +class SimulatedAnnealingListener: + def on_new_neighbor(self, k, tk, neighbor_score, neighbor): + pass + + def on_new_best_solution(self, k, tk, neighbor_score, neighbor): + pass + + def on_start(self, n, t0, tn, neighbor_score, neighbor): + pass + + def on_finish(self, neighbor_score, neighbor): + pass + + +# import matplotlib.pyplot as plt +# from pack_shared import plot_placed_rectangles +# +# +# class PlacementPlottingSimulatedAnnealingListener(SimulatedAnnealingListener): +# def __init__(self, objects): +# self.objects = objects +# +# def on_start(self, n, t0, tn, neighbor_score, neighbor): +# plot_placed_rectangles(neighbor.get_placement(), self.objects) +# +# def on_new_neighbor(self, k, tk, neighbor_score, neighbor): +# pass +# +# def on_new_best_solution(self, k, tk, neighbor_score, neighbor): +# pass +# +# def on_finish(self, neighbor_score, neighbor): +# plot_placed_rectangles(neighbor.get_placement(), self.objects) +# +# +# class ProgressPlottingSimulatedAnnealingListener(SimulatedAnnealingListener): +# def on_start(self, n, t0, tn, neighbor_score, neighbor): +# self.n = n +# self.hist = [(0, t0, neighbor_score)] +# +# def on_new_neighbor(self, k, tk, neighbor_score, neighbor): +# self.hist.append((k, tk, neighbor_score)) +# +# def on_finish(self, neighbor_score, neighbor): +# plt.plot([i for i, t, s in self.hist], [s for i, t, s in self.hist], 'ro') +# plt.show() + + +class SimulatedAnnealingPacker(Packer): + def pack(self, quality, objects, sheet_x, sheet_y): + factor_by_preset = {Quality.fastest: 150, + Quality.medium: 300, + Quality.high: 600, + Quality.insane: 2000} + params = {"t0": 30000, + "tn": 26, + "n": int(math.sqrt(len(objects)) * factor_by_preset[quality]), + "alpha": 0.95} + + tree = SlicingTree() + tree.set_sheet(sheet_x, sheet_y) + tree.set_objects(objects) + return self.simulated_annealing(tree, params).get_placement() + + @staticmethod + def simulated_annealing(initial_solution, params, listener=None): + n = params["n"] + T0 = params["t0"] + Tn = params["tn"] + alpha = params["alpha"] + + last_neighbor, last_neighbor_score = initial_solution, initial_solution.get_score() + best_neighbor, best_neighbor_score = last_neighbor, last_neighbor_score + if listener: + listener.on_start(n, T0, Tn, best_neighbor_score, best_neighbor) + + Tk = T0 + for k in range(n): + neighbor = last_neighbor.get_random_neighbor() + neighbor_score = neighbor.get_score() + if listener: + listener.on_new_neighbor(k, Tk, neighbor_score, neighbor) + + if neighbor_score <= last_neighbor_score: + last_neighbor, last_neighbor_score = neighbor, neighbor_score + if neighbor_score <= best_neighbor_score: + best_neighbor, best_neighbor_score = neighbor, neighbor_score + if listener: + listener.on_new_best_solution(k, Tk, best_neighbor_score, best_neighbor) + elif random.uniform(0, 1) <= math.exp(-(neighbor_score - last_neighbor_score) / Tk): + last_neighbor, last_neighbor_score = neighbor, neighbor_score + Tk = T0 * math.pow(alpha, k) + if not Tk: + break + + if listener: + listener.on_finish(best_neighbor_score, best_neighbor) + return best_neighbor + + +class SlicingTreeElement: + def is_leaf(self): + raise NotImplementedError + + def rotate(self): + raise NotImplementedError + + +class Rectangle(SlicingTreeElement): + def __init__(self, unique_id, x, y): + self.id = unique_id + self.x = x + self.y = y + self.rotated = False + + def is_leaf(self): + return True + + def rotate(self): + self.x, self.y = self.y, self.x + self.rotated = not self.rotated + + +class Direction(Enum): + H = 0 + V = 1 + + def get_opposite(self): + return self.H if self is self.V else self.V + + +class SliceNode(SlicingTreeElement): + def __init__(self, direction): + self.dir = direction + + def is_leaf(self): + return False + + def rotate(self): + self.dir = self.dir.get_opposite() + + +class SlicingTree(SimulatedAnnealingNeighborhood): + + def __init__(self): + self.sheet_x = -1 + self.sheet_y = -1 + self.listener = None + self.tree = None + + def set_sheet(self, sheet_x, sheet_y): + self.sheet_x = sheet_x + self.sheet_y = sheet_y + self.listener = ScorePlacementTraversalListener(sheet_x, sheet_y) + + def set_objects(self, objects): + number_of_rectangles = len(objects) + + # determine indices of tree nodes for an initially balanced binary tree stored in prefix notation + slice_indices = [] + slice_dir = Direction.H + subtree_sizes = [1] * number_of_rectangles + while len(subtree_sizes) > 1: + node_index = 0 + for _ in range(math.floor(len(subtree_sizes) / 2)): + slice_indices.append((node_index, slice_dir)) + new_subtree_size = subtree_sizes.pop() + subtree_sizes.pop() + 1 + node_index += new_subtree_size + subtree_sizes.insert(0, new_subtree_size) + subtree_sizes = subtree_sizes[::-1] + slice_dir = slice_dir.get_opposite() + + # create rectangles and insert slice nodes at the indices computed above + self.tree = [Rectangle(name, *dims) for name, dims in objects.items()] + for i, slice_dir in slice_indices: + self.tree.insert(i, SliceNode(slice_dir)) + + def __deepcopy__(self, memodict): + result = object.__new__(self.__class__) + memodict[id(self)] = result + + # deepcopying the tree is a huge bottleneck, but otherwise rectangle rotation would have to be solved differently + result.sheet_x = copy.copy(self.sheet_x) + result.sheet_y = copy.copy(self.sheet_y) + result.tree = copy.deepcopy(self.tree) + result.listener = copy.copy(self.listener) + return result + + def _traverse(self, listener): + depth = 0 + elmts_on_depth = defaultdict(lambda: 0) + + for i, elmt in enumerate(self.tree): + elmts_on_depth[depth] += 1 + if not elmt.is_leaf(): + listener.enter_node(i, depth, elmt) + depth += 1 + else: + listener.visit_leaf(i, depth, elmt) + + # if both children have been visited, move upwards + while elmts_on_depth[depth] == 2: + elmts_on_depth.pop(depth) + depth -= 1 + listener.exit_node(depth) + + def get_score(self): + self.listener.reset() + self._traverse(self.listener) + return self.listener.get_score() / 1000 + + def get_placement(self): + self.listener.reset(do_placement=True) + self._traverse(self.listener) + + placement = Placement() + for rect, (x_pos, y_pos) in self.listener.get_placement().items(): + placement.add_object(rect.id, x_pos, y_pos, rect.rotated) + return placement + + def _find_random_subtrees(self, how_many=1): + # note that this method can return two sibling subtrees, which is nonsensical for swapping (but shouldn't hurt the results) + + # the lowest possible start index is 1 to avoid selecting the whole tree as a subtree (makes sense, right?) + possible_subtree_start_indices = set(range(1, len(self.tree))) + + subtrees = [] + while how_many and possible_subtree_start_indices: + success = True + + # roll random start index, then iterate over the tree until the end of the subtree is found + i_start = random.choice(list(possible_subtree_start_indices)) + i = i_start + read_ahead = 1 + while read_ahead: + # it can happen that a subtree overlaps with a previously found subtree (i.e. iterating over a part of the tree which was covered before) + # in that case, the subtree found so far needs to be invalidated and search continues with a newly randomized starting index + if i not in possible_subtree_start_indices: + success = False + break + # if we encounter a tree node, the subtree has to consist of (at least) two more elements + if not self.tree[i].is_leaf(): + read_ahead += 2 + read_ahead -= 1 + i += 1 + + subtree = [i_start, i] # [inclusive, exclusive] + if success: + subtrees.append(subtree) + how_many -= 1 + + # remove identified subtree from set of possible start indices to avoid finding overlapping subtrees + possible_subtree_start_indices -= set(range(i_start, i)) + + return subtrees + + def _rotate_random_subtree(self): + for i in range(*self._find_random_subtrees()[0]): + self.tree[i].rotate() + + def _swap_random_objects(self): + rectangle_indices = [i for i in range(len(self.tree)) if self.tree[i].is_leaf()] + i, j = random.sample(rectangle_indices, 2) + self.tree[i], self.tree[j] = self.tree[j], self.tree[i] + + def _swap_random_subtrees(self): + s1, s2 = sorted(self._find_random_subtrees(how_many=2)) + s1_from, s1_to = s1 + s2_from, s2_to = s2 + self.tree = self.tree[:s1_from] + self.tree[s2_from:s2_to] + self.tree[s1_to:s2_from] \ + + self.tree[s1_from:s1_to] + self.tree[s2_to:] + + def get_random_neighbor(self): + neighbor = copy.deepcopy(self) + + f = random.choice(range(2)) + if f==2: + neighbor._rotate_random_subtree() + elif f==1: + neighbor._swap_random_objects() + else: + neighbor._swap_random_subtrees() + + return neighbor + + +class SlicingTreeTraversalListener: + def reset(self): + raise NotImplementedError + + def enter_node(self, i, depth, node): + raise NotImplementedError + + def exit_node(self, depth): + raise NotImplementedError + + def visit_leaf(self, i, depth, leaf): + raise NotImplementedError + + +class ScorePlacementTraversalListener(SlicingTreeTraversalListener): + def __init__(self, sheet_x, sheet_y, ): + self.sheet_area = sheet_x * sheet_y + + def reset(self, do_placement=False): + self.dim_stack = [] + self.slice_stack = [] + self.placement = {} if do_placement else None + self.full_tree_dims = [-1, -1] + + def enter_node(self, i, depth, node): + self.dim_stack.append([]) + self.slice_stack.append(node.dir) + + def exit_node(self, depth): + child_dims = self.dim_stack.pop() + assert len(child_dims) == 2 + + # obtain x and y dimensions of the children of this node + children_x = [x for x, y in child_dims] + children_y = [y for x, y in child_dims] + + # determine dimensions of this subtree (including the node being exited) + direction = self.slice_stack.pop() + subtree_dim = (max(children_x), sum(children_y)) if direction == Direction.H else ( + sum(children_x), max(children_y)) + + # if exiting the root, the dimensions of the whole tree are known + if not depth: + self.full_tree_dims = subtree_dim + else: + self.dim_stack[depth-1] += [subtree_dim] + + def visit_leaf(self, i, depth, leaf): + # calculate leaf x,y position if desired + if self.placement is not None: + # a leaf's position is derived from the sizes of subtrees on the path to the root + x_pos = y_pos = 0 + valid_translations = [(t[0], direction) for t, direction in zip(self.dim_stack, self.slice_stack) if t] + for translation, direction in valid_translations: + x, y = translation + if direction == Direction.V: + x_pos += x + else: + y_pos += y + self.placement[leaf] = (x_pos, y_pos) + + # report leaf size to node above + self.dim_stack[depth-1] += [(leaf.x, leaf.y)] + + def get_score(self): + x, y = self.full_tree_dims + return x * y + + def get_placement(self): + if self.placement is None: + raise ValueError + else: + return self.placement diff --git a/dist/util/pack_columns.py b/dist/util/pack_columns.py new file mode 100644 index 0000000..d82a595 --- /dev/null +++ b/dist/util/pack_columns.py @@ -0,0 +1,22 @@ +from pack_shared import Packer, Placement + + +class ColumnPacker(Packer): + def pack(self, quality, objects, sheet_x, sheet_y): + placement = Placement() + x = y = 0 + max_lenx_in_col = 0 + + # create a useful ordering for the objects: sort by x lengths + for name, (len_x, len_y) in sorted(objects.items(), key=lambda kv: kv[1][0]): + if y + len_y > sheet_y: + x += max_lenx_in_col + y = 0 + max_lenx_in_col = 0 + + if len_x > max_lenx_in_col: + max_lenx_in_col = len_x + + placement.add_object(name, x, y, False) # False -> no rotation + y += len_y + return placement diff --git a/dist/util/pack_shared.py b/dist/util/pack_shared.py new file mode 100644 index 0000000..8f2212a --- /dev/null +++ b/dist/util/pack_shared.py @@ -0,0 +1,57 @@ +from enum import Enum + +# import random +# import matplotlib.pyplot as plt +# import matplotlib.patches as patches + + +class Quality(Enum): + fastest = 0 + medium = 1 + high = 2 + insane = 3 + + +class Packer: + def pack(self, quality, objects, sheet_x, sheet_y): + raise NotImplementedError + + +class Placement: + def __init__(self): + self.placement = {} + + def add_object(self, unique_id, x_pos, y_pos, rotated=False): + self.placement[unique_id] = (x_pos, y_pos, rotated) + + def get_placement(self): + return self.placement + + +# def random_objects(n): +# randx = [random.randint(10, 50) for _ in range(n)] +# randy = [random.randint(10, 50) for _ in range(n)] +# objects = {str(i): (x, y) for i, (x, y) in enumerate(zip(randx, randy))} +# return objects +# +# +# def plot_placed_rectangles(placement, objects): +# fig = plt.figure() +# ax = plt.gca() +# ax.set_aspect('equal', adjustable='box') +# x_max = y_max = 0 +# +# for unique_id, (x_pos, y_pos, z_rot) in placement.items(): +# width, height = objects[unique_id] +# if z_rot: +# width, height = height, width +# ax.add_patch( +# patches.Rectangle((x_pos, y_pos), width, height, fill=False) +# ) +# if x_pos + width > x_max: +# x_max = x_pos + width +# if y_pos + height > y_max: +# y_max = y_pos + height +# ax.set_xlim([0, x_max]) +# ax.set_ylim([0, y_max]) +# fig.show() diff --git a/dist/util/packing.py b/dist/util/packing.py new file mode 100644 index 0000000..6d0c3d7 --- /dev/null +++ b/dist/util/packing.py @@ -0,0 +1,40 @@ +import sys, csv + +from pack_shared import Quality +from pack_annealing import SimulatedAnnealingPacker +from pack_columns import ColumnPacker + + +def run(bb_path, pos_path, quality, sheet_x, sheet_y, margin): + """Packs rectangles. + """ + + quality = Quality[quality] + sheet_x, sheet_y, margin = [float(s) for s in [sheet_x, sheet_y, margin]] + + # make sure sheet_x > sheet_y + if sheet_y > sheet_x: + sheet_x, sheet_y = sheet_y, sheet_x + + # read input + with open(bb_path, 'r') as bb_file: + bounding_boxes = list(csv.reader(bb_file)) + + # add margins + objects = {name: (float(x)+2*margin, float(y)+2*margin) for name, x, y in bounding_boxes} + + packer = ColumnPacker() if quality == Quality.fastest else SimulatedAnnealingPacker() + placement = packer.pack(quality, objects, sheet_x, sheet_y) + + # write to file + with open(pos_path, 'w') as pos_file: + writer = csv.writer(pos_file) + for unique_id, (x_pos, y_pos, rotate) in placement.get_placement().items(): + # remove margins again, adjust positions accordingly + x_len, y_len = [v - 2*margin for v in objects[unique_id]] + x_pos, y_pos = [v + margin for v in [x_pos, y_pos]] + writer.writerow([unique_id, x_len, y_len, x_pos, y_pos, rotate]) + + +if __name__ == "__main__": + run(*sys.argv[1:]) diff --git a/dist/util/write_back.py b/dist/util/write_back.py new file mode 100644 index 0000000..cbe4196 --- /dev/null +++ b/dist/util/write_back.py @@ -0,0 +1,42 @@ +import sys, csv + + +def create_recursive_ternary_expr(vecs_by_name): + name, vec = vecs_by_name[0] + if len(vecs_by_name) == 1: + return vec + else: + tail = create_recursive_ternary_expr(vecs_by_name[1:]) + return 'id=="{}"? {} : {}'.format(name, vec, tail) + + +def write_back(scad_path, pos_path): + """Writes optimized object positions into a given SCAD file. + """ + + with open(pos_path, 'r') as pos_file: + packing_output = list(csv.reader(pos_file)) + + translations = [] + rotations = [] + for name, x_len, y_len, x_pos, y_pos, rotate in packing_output: + x_len, y_len, x_pos, y_pos = [float(v) for v in [x_len, y_len, x_pos, y_pos]] + rotate = rotate == 'True' + + # rotating 90° around the origin requires translating the object back into the first octant + if rotate: + x_pos += y_len + translations += [(name, "[{},{},0]".format(x_pos, y_pos))] + rotations += [(name, "[0,0,{}]".format(90 if rotate else 0))] + + # assemble the function definitions for translations and rotations in OpenSCAD + out_str = "\n_laserscad_mode=2;\n" + for vecs, func in [(translations, "_lpart_translation"), (rotations, "_lpart_rotation")]: + out_str += "\nfunction {}(id) = {};\n".format(func, create_recursive_ternary_expr(vecs)) + + with open(scad_path, 'a') as scad_file: + scad_file.write(out_str) + + +if __name__ == "__main__": + write_back(*sys.argv[1:]) diff --git a/dist/util/writeback.py b/dist/util/writeback.py deleted file mode 100644 index e9496ed..0000000 --- a/dist/util/writeback.py +++ /dev/null @@ -1,30 +0,0 @@ -import sys, csv - - -def create_recursive_ternary_expr(translations): - name, trans = translations[0] - if len(translations) == 1: - return trans + ";" - else: - tail = create_recursive_ternary_expr(translations[1:]) - return 'id=="{}"? {} : {}'.format(name, trans, tail) - - -def writeback(scad_path, pos_path): - '''Writes optimized object positions into a given SCAD file. - ''' - - # read input - with open(pos_path, 'r') as pos_file: - reader = csv.reader(pos_file) - translations = [(name, "[{},{},0]".format(x, y)) for name, x, y in list(reader)] - - _lpart_translation = "\nfunction _lpart_translation(id) = " + create_recursive_ternary_expr(translations) - - with open(scad_path, 'a') as scad_file: - scad_file.write(_lpart_translation) - scad_file.write("\n_laserscad_mode=2;") - - -if __name__=="__main__": - writeback(*sys.argv[1:]) \ No newline at end of file