From 5169343be70851b31e8865f8467171eee084379f Mon Sep 17 00:00:00 2001 From: "suks.ae" Date: Sun, 5 Jun 2022 09:24:54 +0200 Subject: [PATCH] Add fillHoles() function to fill a polygon with a hole pattern --- .travis.yml | 2 +- boxes/__init__.py | 392 +++++++++++++++++++++++++- documentation/src/install.rst | 6 + documentation/src/install/macos.rst | 2 +- documentation/src/install/windows.rst | 2 +- requirements.txt | 1 + scripts/Dockerfile | 2 +- setup.py | 2 +- 8 files changed, 403 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index fe3dc4f..f110ff3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,7 +10,7 @@ branches: only: - master install: - - pip install sphinx affine + - pip install sphinx affine shapely script: #- python setup.py install - cd ./documentation/src/ && make html #linkchecker diff --git a/boxes/__init__.py b/boxes/__init__.py index 4b3ad6f..97003ab 100755 --- a/boxes/__init__.py +++ b/boxes/__init__.py @@ -24,6 +24,9 @@ from xml.sax.saxutils import quoteattr from contextlib import contextmanager import copy from shlex import quote +from shapely.geometry import * +from shapely.ops import split +import random from boxes import edges from boxes import formats @@ -241,6 +244,33 @@ Values: relative_params = {} +class fillHolesSettings(edges.Settings): + """Settings for Hole filling + +Values: + +* absolute + * fill_pattern : "no fill" : style of hole pattern + * hole_style : "round" : style of holes (does not apply to fill patterns 'vbar' and 'hbar') + * max_random : 1000 : maximum number of random holes + * bar_length : 50 : maximum length of bars + * hole_max_radius : 12.0 : maximum radius of generated holes (in mm) + * hole_min_radius : 4.0 : minimum radius of generated holes (in mm) + * space_between_holes : 4.0 : hole to hole spacing (in mm) + * space_to_border : 4.0 : hole to border spacing (in mm) + +""" + + absolute_params = { + "fill_pattern": ("no fill", "hex", "square", "random", "hbar", "vbar"), + "hole_style": ("round", "triangle", "square", "hexagon", "octagon"), + "max_random": 1000, + "bar_length": 50, + "hole_max_radius": 3.0, + "hole_min_radius": 0.5, + "space_between_holes": 4.0, + "space_to_border": 4.0, + } ############################################################################## ### Main class @@ -1501,7 +1531,367 @@ class Boxes: y * 0.5 * holedistance, 0.5 * diameter) - # hexHoles + @restore + def showBorderPoly(self,border,color=Color.ANNOTATIONS): + """ + draw border polygon (for debugging only) + + :param border: array with coordinate [(x0,y0), (x1,y1),...] of the border polygon + + """ + self.set_source_color(color) + self.ctx.save() + self.ctx.move_to(*border[0]) + + for x, y in border[1:]: + self.ctx.line_to(x, y) + + self.ctx.line_to(*border[0]) + self.ctx.restore() + + i = 0 + for x, y in border: + i += 1 + self.hole(x, y, 0.5, color=color) + self.text(str(i), x, y, fontsize=2, color=color) + + @restore + @holeCol + def fillHoles(self, pattern, border, max_radius, hspace=3, bspace=0, min_radius=0.5, style="round", bar_length=50, max_random=1000): + """ + fill a polygon defined by its outline with holes + + :param pattern: defines the hole pattern - currently "random", "hex", "square" "hbar" or "vbar" are supported + :param border: array with coordinate [(x0,y0), (x1,y1),...] of the border polygon + :param max_radius: maximum hole radius + :param hspace: space between holes + :param bspace: space to border + :param min_radius: minimum hole radius + :param style: defines hole style - currently one of "round", "triangle", "square", "hexagon" or "octagon" + :param bar_length: maximum bar length + :param max_random: maximum number of random holes + + """ + if pattern not in ["random", "hex", "square", "hbar", "vbar"]: + return + + a = 0 + if style == "round": + n = 0 + elif style == "triangle": + n = 3 + a = 60 + elif style == "square": + n = 4 + elif style == "hexagon": + n = 6 + a = 30 + elif style == "octagon": + n = 8 + a = 22.5 + else: + raise ValueError("fillHoles - unknown hole style: %s)" % style) + +# note to myself: ^y x> + + if self.debug: + self.showBorderPoly(border) + + borderPoly = Polygon(border) + min_x, min_y, max_x, max_y = borderPoly.bounds + + if pattern == "vbar": + border = [(max_y - y + min_y, x) for x, y in border] + borderPoly = Polygon(border) + min_x, min_y, max_x, max_y = borderPoly.bounds + self.moveTo(0, max_x + min_x, -90) + pattern = "hbar" + if self.debug: + self.showBorderPoly(border, color=Color.MAGENTA) + + row = 0 + i = 0 + + # calc the next smaller radius to fit an 'optimum' number of circles + # for x direction + nx = math.ceil((max_x - min_x - 2 * bspace + hspace) / (2 * max_radius + hspace)) + max_radius_x = (max_x - min_x - 2 * bspace - (nx - 1) * hspace) / nx / 2 + + # for y direction + if pattern == "hex": + ny = math.ceil((max_y - min_y - 2 * bspace - 2 * max_radius) / (math.sqrt(3) / 2 * (2 * max_radius + hspace))) + max_radius_y = (max_y - min_y - 2 * bspace - math.sqrt(3) / 2 * ny * hspace) / (math.sqrt(3) * ny + 2 ) + else: + ny = math.ceil((max_y - min_y - 2 * bspace + hspace) / (2 * max_radius + hspace)) + max_radius_y = (max_y - min_y - 2 * bspace - (ny - 1) * hspace) / ny / 2 + + if pattern == "random": + grid = {} + misses = 0 # in a row + while i < max_random and misses < 20: + i += 1 + misses += 1 + # random new point + x = random.randrange(math.floor(min_x + bspace), math.ceil(max_x - bspace)) # randomness takes longer to compute + y = random.randrange(math.floor(min_y + bspace), math.ceil(max_y - bspace)) # but generates a new pattern for each run + pt = Point(x, y).buffer(min_radius + bspace) + # check if point is within border + if borderPoly.contains(pt): + pt1 = Point(x, y) + grid_x = int(x//(2*max_radius+hspace)) + grid_y = int(y//(2*max_radius+hspace)) + # compute distance between hole and border + bdist = borderPoly.exterior.distance(pt1) - bspace + # compute minimum distance to all other holes + hdist = max_radius + try: # learned from https://medium.com/techtofreedom/5-ways-to-break-out-of-nested-loops-in-python-4c505d34ace7 + for gx in (-1, 0, 1): + for gy in (-1, 0, 1): + for pt2 in grid.get((grid_x+gx, grid_y+gy), []): + pt3 = Point(pt2.x, pt2.y) + hdist = min(hdist, pt1.distance(pt3) - pt2.z - hspace) + if hdist < min_radius: + hdist = 0 + raise StopIteration + except StopIteration: + pass + # find maximum radius depending on distances + r = min(bdist, hdist) + # if too small, dismiss cycle + if r < min_radius: + continue + # if too large, limit to max size + if r > max_radius: + r = max_radius + # store in grid with radius as z value + grid.setdefault((grid_x, grid_y), []).append( + Point(x, y, r)) + misses = 0 + # and finally paint the hole + self.regularPolygonHole(x, y, r=r, n=n, a=a) + # rinse and repeat + + elif pattern in ("square", "hex"): + # use 'optimum' hole size + max_radius = min(max_radius_x, max_radius_y) + + # check if at least one line fits (we do horizontal filling) + if (max_y - min_y) < (2 * max_radius + 2 * bspace): + return + + # make cutPolys a little wider to avoid + # overlapping with lines to be cut + outerCutPoly = borderPoly.buffer(-1 * (bspace - 0.000001), + join_style=2) + outerTestPoly = borderPoly.buffer(-1 * (bspace - 0.01), + join_style=2) + # shrink original polygon to get place for full size polygons + innerCutPoly = borderPoly.buffer(-1 * (bspace + max_radius - 0.0001), join_style=2) + innerTestPoly = borderPoly.buffer(-1 * (bspace + max_radius - 0.001), join_style=2) + + # get left and right boundaries of cut polygon + x_cpl, y_cpl, x_cpr, y_cpr = outerCutPoly.bounds + + if self.debug: + self.showBorderPoly(list(outerCutPoly.exterior.coords)) + self.showBorderPoly(list(innerCutPoly.exterior.coords)) + + # set startpoint + y = min_y + bspace + max_radius_y + + while y < (max_y - bspace - max_radius_y): + if pattern == "square" or row % 2 == 0: + xs = min_x + bspace + max_radius_x + else: + xs = min_x + max_radius_x * 2 + hspace / 2 + bspace + + # create line segments cut by the polygons + line_complete = LineString([(x_cpl, y), (max_x + 1, y)]) + # cut accurate + outer_line_split = split(line_complete, outerCutPoly) + line_complete = LineString([(x_cpl, y), (max_x + 1, y)]) + inner_line_split = split(line_complete, innerCutPoly) + inner_line_index = 0 + + if self.debug and False: + for line in inner_line_split.geoms: + self.hole(line.bounds[0], line.bounds[1], 1.1) + self.hole(line.bounds[2], line.bounds[3], .9) + + # process each line + for line_this in outer_line_split.geoms: + + if self.debug and False: # enable to debug missing lines + x_start, y_start, x_end, y_end = line_this.bounds + with self.saved_context(): + self.moveTo(x_start, y_start ,0) + self.hole(0, 0, 0.5) + self.edge(x_end - x_start) + with self.saved_context(): + self.moveTo(x_start, y_start ,0) + self.text(str(outerTestPoly.contains(line_this)), 0, 0, fontsize=2, color=Color.ANNOTATIONS) + with self.saved_context(): + self.moveTo(x_end, y_end ,0) + self.hole(0, 0, 0.5) + + if not outerTestPoly.contains(line_this): + continue + x_start, y_start , x_end, y_end = line_this.bounds + #initialize walking x coordinate + xw = (math.ceil((x_start - xs) / (2 * max_radius_x + hspace)) * (2 * max_radius_x + hspace)) + xs + + # look up matching inner line + while (inner_line_index < len(inner_line_split) and + (inner_line_split.geoms[inner_line_index].bounds[2] < xw + or not innerTestPoly.contains(inner_line_split.geoms[inner_line_index]))): + inner_line_index += 1 + + # and process line + while not xw > x_end: + # are we in inner polygone already? + if (len(inner_line_split) > inner_line_index and + xw > inner_line_split.geoms[inner_line_index].bounds[0]): + # place inner, full size polygons + while xw < inner_line_split.geoms[inner_line_index].bounds[2]: + self.regularPolygonHole(xw, y, r=max_radius, n=n, a=a) + xw += (2 * max_radius_x + hspace) + # forward to next inner line + while (inner_line_index < len(inner_line_split) and + (inner_line_split.geoms[inner_line_index].bounds[0] < xw + or not innerTestPoly.contains(inner_line_split.geoms[inner_line_index]))): + inner_line_index += 1 + if xw > x_end: + break + + # Check distance to border to size the polygon + pt = Point(xw, y) + r = min(borderPoly.exterior.distance(pt) - bspace, + max_radius) + # if too small, dismiss + if r >= min_radius: + self.regularPolygonHole(xw, y, r=r, n=n, a=a) + xw += (2 * max_radius_x + hspace) + + row += 1 + if pattern == "square": + y += 2 * max_radius_y + hspace - 0.0001 + else: + y += (math.sqrt(3) / 2 * (2 * max_radius_y + hspace)) - 0.0001 + + elif pattern == "hbar": + # 'optimum' hole size to be used + max_radius = max_radius_y + # check if at least one bar fits + if (max_y - min_y) < (2 * max_radius + 2 * bspace): + return + + #shrink original polygon + shrinkPoly = borderPoly.buffer(-1 * (bspace + max_radius - 0.01), join_style=2) + cutPoly = borderPoly.buffer(-1 * (bspace + max_radius - 0.000001), join_style=2) + + if self.debug: + self.showBorderPoly(list(shrinkPoly.exterior.coords)) + + segment_length = [bar_length / 2, bar_length] + segment_max = 1 + segment_toggle = False + + # set startpoint + y = min_y + bspace + max_radius + # and calc step width + step_y = 2 * max_radius_y + hspace - 0.0001 + + while y < (max_y - bspace - max_radius): + # toggle segment length each new line + if segment_toggle: + segment_max = 0 + segment_toggle ^= 1 + + # create line from left to right and cut according to shrinked polygon + line_complete = LineString([(min_x - 1, y), (max_x + 1, y)]) + line_split = split(line_complete, cutPoly) + + # process each line + for line_this in line_split.geoms: + + if self.debug and False: # enable to debug missing lines + x_start, y_start , x_end, y_end = line_this.bounds + with self.saved_context(): + self.moveTo(x_start, y_start ,0) + self.hole(0, 0, 0.5) + self.edge(x_end - x_start) + with self.saved_context(): + self.moveTo(x_start, y_start ,0) + self.text(str(shrinkPoly.contains(line_this)), 0, 0, fontsize=2, color=Color.ANNOTATIONS) + with self.saved_context(): + self.moveTo(x_end, y_end ,0) + self.hole(0, 0, 0.5) + + if shrinkPoly.contains(line_this): + # long segment are cut down further + if line_this.length > segment_length[segment_max]: + line_working = line_this + length = line_working.length + while length > 0: + x_start, y_start , xw_end, yw_end = line_working.bounds + # calculate point with required distance from start point + p = line_working.interpolate(segment_length[segment_max]) + # and use its coordinates as endpoint for this segment + x_end = p.x + y_end = p.y + # draw segment + self.set_source_color(Color.INNER_CUT) + with self.saved_context(): + self.moveTo(x_start, y_start + max_radius,0) + self.edge(x_end - x_start) + self.corner(-180, max_radius) + self.edge(x_end - x_start) + self.corner(-180, max_radius) + + if self.debug and False: # enable to debug cutting lines + self.set_source_color(Color.ANNOTATIONS) + with self.saved_context(): + self.moveTo(x_start, y_start, 0) + self.edge(x_end - x_start) + + s = "long - y: " + str(round(y, 1)) + " xs: " + str(round(x_start, 1)) + " xe: " + str(round(x_end, 1)) + " l: " + str(round(length, 1)) + " max: " + str(round(segment_length[segment_max], 1)) + with self.saved_context(): + self.text(s, x_start, y_start, fontsize=2, color=Color.ANNOTATIONS) + + # subtract length of segmant from total segment length + length -= (x_end - x_start + hspace + 2 * max_radius) + # create remaining line to work with + line_working = LineString([(x_end + hspace + 2 * max_radius, y_end), (xw_end, yw_end)]) + # next segment shall be long + segment_max = 1 + else: + # short segment can be drawn instantly + x_start, y_start , x_end, y_end = line_this.bounds + self.set_source_color(Color.INNER_CUT) + with self.saved_context(): + self.moveTo(x_start, y_start + max_radius, 0) + self.edge(x_end - x_start) + self.corner(-180, max_radius) + self.edge(x_end - x_start) + self.corner(-180, max_radius) + + if self.debug and False: # enable to debug short lines + self.set_source_color(Color.ANNOTATIONS) + with self.saved_context(): + self.moveTo(x_start, y_start, 0) + self.edge(x_end - x_start) + + s = "short - y: " + str(round(y, 1)) + " xs: " + str(round(x_start, 1)) + " xe: " + str(round(x_end, 1)) + " l: " + str(round(line_this.length, 1)) + " max: " + str(round(segment_length[segment_max], 1)) + with self.saved_context(): + self.text(s, x_start, y_start, fontsize=2, color=Color.ANNOTATIONS) + + segment_max = 1 + # short segment shall be skipped if a short segment shall start the line + if segment_toggle: + segment_max = 0 + y += step_y + else: + raise ValueError("fillHoles - unknown hole pattern: %s)" % pattern) def hexHolesRectangle(self, x, y, settings=None, skip=None): """Fills a rectangle with holes in a hex pattern. diff --git a/documentation/src/install.rst b/documentation/src/install.rst index ee00ef7..8e24196 100644 --- a/documentation/src/install.rst +++ b/documentation/src/install.rst @@ -14,6 +14,12 @@ Affine :code:`Affine` (package name may be :code:`python-affine` or :code:`python3-affine`) is used for vector calculation. +Shapely +....... +:code:`shapely` (package name may be :code:`python-shapely` or +:code:`python3-shapely`) is used for filling shapes (with holes). + + Markdown ........ :code:`Markdown` (package name may be :code:`python-markdown` or diff --git a/documentation/src/install/macos.rst b/documentation/src/install/macos.rst index 39d847c..f385d15 100644 --- a/documentation/src/install/macos.rst +++ b/documentation/src/install/macos.rst @@ -31,7 +31,7 @@ General .. code:: - pip3 install Markdown affine + pip3 install Markdown affine shapely 4. Download Boxes.py via Git: diff --git a/documentation/src/install/windows.rst b/documentation/src/install/windows.rst index ae1121b..de7ebb0 100644 --- a/documentation/src/install/windows.rst +++ b/documentation/src/install/windows.rst @@ -27,7 +27,7 @@ Following steps are known to work under Windows 10 (64-bit): :alt: Screenshot of Python 3.7 (64-bit) installer with PATH checked :align: center -3. Run the command :code:`pip install Markdown affine` +3. Run the command :code:`pip install Markdown affine shapely` (Note: If the command pip is not found, you probably forgot to add the Python installation to the PATH environment variable in step 2) diff --git a/requirements.txt b/requirements.txt index 9d4616b..91ca5f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ affine markdown setuptools sphinx +shapely diff --git a/scripts/Dockerfile b/scripts/Dockerfile index e25d471..40d6f89 100644 --- a/scripts/Dockerfile +++ b/scripts/Dockerfile @@ -14,7 +14,7 @@ FROM fedora:latest # Install requirements -RUN dnf install -y git-core python3-markdown python3-setuptools python3-affine pstoedit && dnf clean all +RUN dnf install -y git-core python3-markdown python3-setuptools python3-affine python3-shapely pstoedit && dnf clean all # Get Boxes.py sources to /boxes RUN git clone --depth 1 -b master https://github.com/florianfesti/boxes.git diff --git a/setup.py b/setup.py index 07b78b7..bd47c76 100755 --- a/setup.py +++ b/setup.py @@ -81,7 +81,7 @@ setup( url='https://github.com/florianfesti/boxes', packages=find_packages(), python_requires='>=3.6', - install_requires=['affine>=2.0', 'markdown'], + install_requires=['affine>=2.0', 'markdown', 'shapely>=1.8.2'], scripts=['scripts/boxes', 'scripts/boxesserver'], cmdclass={ 'build_py': CustomBuildExtCommand,