Add fillHoles() function

to fill a polygon with a hole pattern
This commit is contained in:
suks.ae 2022-06-05 09:24:54 +02:00 committed by Florian Festi
parent 3793a2bb09
commit 5169343be7
8 changed files with 403 additions and 6 deletions

View File

@ -10,7 +10,7 @@ branches:
only: only:
- master - master
install: install:
- pip install sphinx affine - pip install sphinx affine shapely
script: script:
#- python setup.py install #- python setup.py install
- cd ./documentation/src/ && make html #linkchecker - cd ./documentation/src/ && make html #linkchecker

View File

@ -24,6 +24,9 @@ from xml.sax.saxutils import quoteattr
from contextlib import contextmanager from contextlib import contextmanager
import copy import copy
from shlex import quote from shlex import quote
from shapely.geometry import *
from shapely.ops import split
import random
from boxes import edges from boxes import edges
from boxes import formats from boxes import formats
@ -241,6 +244,33 @@ Values:
relative_params = {} 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 ### Main class
@ -1501,7 +1531,367 @@ class Boxes:
y * 0.5 * holedistance, y * 0.5 * holedistance,
0.5 * diameter) 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): def hexHolesRectangle(self, x, y, settings=None, skip=None):
"""Fills a rectangle with holes in a hex pattern. """Fills a rectangle with holes in a hex pattern.

View File

@ -14,6 +14,12 @@ Affine
:code:`Affine` (package name may be :code:`python-affine` or :code:`Affine` (package name may be :code:`python-affine` or
:code:`python3-affine`) is used for vector calculation. :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 Markdown
........ ........
:code:`Markdown` (package name may be :code:`python-markdown` or :code:`Markdown` (package name may be :code:`python-markdown` or

View File

@ -31,7 +31,7 @@ General
.. code:: .. code::
pip3 install Markdown affine pip3 install Markdown affine shapely
4. Download Boxes.py via Git: 4. Download Boxes.py via Git:

View File

@ -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 :alt: Screenshot of Python 3.7 (64-bit) installer with PATH checked
:align: center :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 (Note: If the command pip is not found, you probably forgot to add the
Python installation to the PATH environment variable in step 2) Python installation to the PATH environment variable in step 2)

View File

@ -2,3 +2,4 @@ affine
markdown markdown
setuptools setuptools
sphinx sphinx
shapely

View File

@ -14,7 +14,7 @@
FROM fedora:latest FROM fedora:latest
# Install requirements # 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 # Get Boxes.py sources to /boxes
RUN git clone --depth 1 -b master https://github.com/florianfesti/boxes.git RUN git clone --depth 1 -b master https://github.com/florianfesti/boxes.git

View File

@ -81,7 +81,7 @@ setup(
url='https://github.com/florianfesti/boxes', url='https://github.com/florianfesti/boxes',
packages=find_packages(), packages=find_packages(),
python_requires='>=3.6', 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'], scripts=['scripts/boxes', 'scripts/boxesserver'],
cmdclass={ cmdclass={
'build_py': CustomBuildExtCommand, 'build_py': CustomBuildExtCommand,