added basic support to kick out colinear lines / merge overlaps using contour scanner

This commit is contained in:
Mario Voigt 2021-06-22 11:55:59 +02:00
parent 920e0aefc2
commit 1d3716225f
2 changed files with 188 additions and 25 deletions

View File

@ -13,6 +13,7 @@
<param name="decimals" type="int" min="0" max="16" gui-text="Decimals" gui-description="Accuracy for sub split lines / lines trimmed by shapely (default: 3)">3</param> <param name="decimals" type="int" min="0" max="16" gui-text="Decimals" gui-description="Accuracy for sub split lines / lines trimmed by shapely (default: 3)">3</param>
<param name="snap_tolerance" type="float" min="0.01" max="10.0" gui-text="Snap tolerance" gui-description="Snap tolerance for intersection points on paths (default: 0.1)">0.1</param> <param name="snap_tolerance" type="float" min="0.01" max="10.0" gui-text="Snap tolerance" gui-description="Snap tolerance for intersection points on paths (default: 0.1)">0.1</param>
<param name="draw_subsplit" type="bool" gui-text="Draw sub split lines (for debugging purposes)" gui-description="Draws polylines. Will be automatically enabled if any highlighting is activated.">false</param> <param name="draw_subsplit" type="bool" gui-text="Draw sub split lines (for debugging purposes)" gui-description="Draws polylines. Will be automatically enabled if any highlighting is activated.">false</param>
<param name="remove_subsplit_collinear" type="bool" gui-text="Remove collinear overlapping lines (experimental)" gui-description="Removes any duplicates by merging (multiple) overlapping line segments into longer lines.">true</param>
</page> </page>
<page name="tab_scanning" gui-text="Scanning and Trimming"> <page name="tab_scanning" gui-text="Scanning and Trimming">
<hbox> <hbox>
@ -129,6 +130,7 @@ Tips:
<spacer/> <spacer/>
<label appearance="header">Third Party Modules</label> <label appearance="header">Third Party Modules</label>
<label appearance="url">https://github.com/ideasman42/isect_segments-bentley_ottmann</label> <label appearance="url">https://github.com/ideasman42/isect_segments-bentley_ottmann</label>
<label appearance="url">https://gist.github.com/sbma44/dc34e5005d9827aa7b1c8c11e68b0c6b</label>
<spacer/> <spacer/>
<label appearance="header">MightyScape Extension Collection</label> <label appearance="header">MightyScape Extension Collection</label>
<label>This piece of software is part of the MightyScape for Inkscape Extension Collection and is licensed under GNU GPL v3</label> <label>This piece of software is part of the MightyScape for Inkscape Extension Collection and is licensed under GNU GPL v3</label>

View File

@ -4,16 +4,8 @@
Extension for InkScape 1.0+ Extension for InkScape 1.0+
- WARNING: HORRIBLY SLOW CODE. PLEASE HELP TO MAKE IT USEFUL FOR LARGE AMOUNT OF PATHS - WARNING: HORRIBLY SLOW CODE. PLEASE HELP TO MAKE IT USEFUL FOR LARGE AMOUNT OF PATHS
- add options: - add options:
- efficiently find overlapping colinear lines by checking their slope/gradient
- get all lines and sort by slope; kick out all slopes which are unique. We only want re-occuring slopes
- intersects() is equivalent to the OR-ing of contains(), crosses(), equals(), touches(), and within().
So there might be some cases where two lines intersect eachother without crossing,
in particular when one line contains another or when two lines are equals.
- crosses() returns True if the dimension of the intersection is less than the dimension of the one or the other.
So if two lines overlap, they won't be considered as "crossing". intersection() will return a geometric object.
- replace trimmed paths by bezier paths (calculating lengths and required t parameter) - replace trimmed paths by bezier paths (calculating lengths and required t parameter)
- find more duplicates - find more duplicates
- overlapping lines in sub splits
- overlapping in original selection - overlapping in original selection
- duplicates in original selection - duplicates in original selection
- duplicates in split bezier - duplicates in split bezier
@ -22,7 +14,6 @@ Extension for InkScape 1.0+
- maybe option: convert rel path to abs path - maybe option: convert rel path to abs path
replacedelement.path = replacedelement.path.to_absolute().to_superpath().to_path() replacedelement.path = replacedelement.path.to_absolute().to_superpath().to_path()
- maybe option: break apart while keeping relative/absolute commands (more complex and not sure if we have a great advantage having this) - maybe option: break apart while keeping relative/absolute commands (more complex and not sure if we have a great advantage having this)
- if calculation of trim lines fails (bentley ottmann) we could try to sort out the lines where slope is nearly identical and then we try again
- important to notice - important to notice
- this algorithm might be really slow. Reduce flattening quality to speed up - this algorithm might be really slow. Reduce flattening quality to speed up
@ -31,11 +22,17 @@ Extension for InkScape 1.0+
poly_point_isect.py: "KeyError: 'Event(0x21412ce81c0, s0=(47.16, 179.1), poly_point_isect.py: "KeyError: 'Event(0x21412ce81c0, s0=(47.16, 179.1),
s1=(47.17, 178.21), p=(47.16, 179.1), type=2, slope=-88.9999999999531)'" s1=(47.17, 178.21), p=(47.16, 179.1), type=2, slope=-88.9999999999531)'"
- this extension does not check for strange paths. Please ensure that your path 'd' - this extension does not check for strange paths. Please ensure that your path 'd'
data is valid (no pointy paths, no duplicates, etc.) data is valid (no pointy paths, no duplicates, etc.)
- we do not use shapely to look for intersections by cutting each line against - Notes about shapely:
each other line (line1.intersection(line2) using two for-loops) because this - we do not use shapely to look for intersections by cutting each line against
kind of logic is really really slow for huge amount. You could use that only each other line (line1.intersection(line2) using two for-loops) because this
for ~50-100 elements. So we use special algorihm (Bentley-Ottmann) kind of logic is really really slow for huge amount. You could use that only
for ~50-100 elements. So we use special algorihm (Bentley-Ottmann)
- intersects() is equivalent to the OR-ing of contains(), crosses(), equals(), touches(), and within().
So there might be some cases where two lines intersect eachother without crossing,
in particular when one line contains another or when two lines are equals.
- crosses() returns True if the dimension of the intersection is less than the dimension of the one or the other.
So if two lines overlap, they won't be considered as "crossing". intersection() will return a geometric object.
- Cool tool to visualize sweep line algorithm Bentley-Ottmann: https://bl.ocks.org/1wheel/464141fe9b940153e636 - Cool tool to visualize sweep line algorithm Bentley-Ottmann: https://bl.ocks.org/1wheel/464141fe9b940153e636
- things to look at more closely: - things to look at more closely:
@ -76,6 +73,7 @@ if speedups.available:
idPrefixSubSplit = "subsplit" idPrefixSubSplit = "subsplit"
idPrefixTrimming = "shapely" idPrefixTrimming = "shapely"
intersectedVerb = "intersected" intersectedVerb = "intersected"
EPS_M = 0.01
class ContourScannerAndTrimmer(inkex.EffectExtension): class ContourScannerAndTrimmer(inkex.EffectExtension):
@ -271,6 +269,7 @@ class ContourScannerAndTrimmer(inkex.EffectExtension):
splitAt = [] #if the sub split line was split by an intersecting line we receive two trim lines with same assigned original path id! splitAt = [] #if the sub split line was split by an intersecting line we receive two trim lines with same assigned original path id!
prevLine = None prevLine = None
for j in range(len(trimLines)): for j in range(len(trimLines)):
trimLineId = "{}-{}".format(trimGroupId, subSplitIndex) trimLineId = "{}-{}".format(trimGroupId, subSplitIndex)
splitAt.append(trimGroupId) splitAt.append(trimGroupId)
if splitAt.count(trimGroupId) > 1: #we detected a lines with intersection on if splitAt.count(trimGroupId) > 1: #we detected a lines with intersection on
@ -283,8 +282,19 @@ class ContourScannerAndTrimmer(inkex.EffectExtension):
prevLine.attrib['id'] = "{}-{}".format(trimGroupId, str(subSplitIndex) + "-" + self.svg.get_unique_id(intersectedVerb + "-")) prevLine.attrib['id'] = "{}-{}".format(trimGroupId, str(subSplitIndex) + "-" + self.svg.get_unique_id(intersectedVerb + "-"))
prevLine.attrib['intersected'] = 'True' #some dirty flag we need prevLine.attrib['intersected'] = 'True' #some dirty flag we need
prevLine = trimLine = inkex.PathElement(id=trimLineId) prevLine = trimLine = inkex.PathElement(id=trimLineId)
x, y = trimLines[j].coords.xy x, y = trimLines[j].coords.xy
trimLine.path = [['M', [x[0],y[0]]], ['L', [x[1],y[1]]]] x0 = round(x[0], self.options.decimals)
x1 = round(x[1], self.options.decimals)
y0 = round(y[0], self.options.decimals)
y1 = round(y[1], self.options.decimals)
if x0 == x1 and y0 == y1: #check if the trimLine is a pointy one (rounded start point equals rounded end point)
if self.options.show_debug is True:
self.msg("pointy trim line (start point equals end point). Skipping ...")
continue
trimLine.attrib['d'] = 'M {},{} L {},{}'.format(x0, y0, x1, y1) #we set the path of trimLine using 'd' attribute because if we use trimLine.path the decimals get cut off unwantedly
#trimLine.path = Path([['M', [x0,y0]], ['L', [x1,y1]]])
#if trimGroupParentTransform is not None: #if trimGroupParentTransform is not None:
# trimLine.path = trimLine.path.transform(-trimGroupParentTransform) # trimLine.path = trimLine.path.transform(-trimGroupParentTransform)
if self.options.apply_style_to_trimmed is False: if self.options.apply_style_to_trimmed is False:
@ -295,6 +305,129 @@ class ContourScannerAndTrimmer(inkex.EffectExtension):
return trimGroup return trimGroup
def slope(self, p0, p1):
'''
Calculate the slope (gradient) of a line's start point p0 + end point p1
'''
dx = p1[0] - p0[0]
if dx == 0:
return sys.float_info.max
return (p1[1] - p0[1]) / dx
def process_set(self, working_set):
if len(working_set) < 2:
return (True, working_set)
# sort working set left to right
working_set.sort(key=lambda x: x['p0'][0])
for i in range(0, len(working_set)):
for j in range(i + 1, len(working_set)):
# calculate slope from S0P0 to S1P1 and S0P1 to S1P0
# if slopes all match the working set's slope, we're collinear
# if not, these segments are parallel but not collinear and should be left alone
p0 = working_set[i]['p0']
expected_slope = working_set[i]['slope']
if (abs(self.slope(working_set[i]['p1'], working_set[j]['p0']) - expected_slope) > EPS_M) or (abs(self.slope(working_set[i]['p0'], working_set[j]['p1']) - expected_slope) > EPS_M):
continue
# the only remaining permissible configuration: collinear segments with a gap between them
# e.g. --- -----
# otherwise we combine segments and flag the set as requiring more processing
s0x0 = working_set[i]['p0'][0]
s0x1 = working_set[i]['p1'][0]
s1x0 = working_set[j]['p0'][0]
s1x1 = working_set[j]['p1'][0]
if not (s0x0 < s0x1 and s0x1 < s1x0 and s1x0 < s1x1):
# make a duplicate set, omitting segments i and j
new_set = [x for (k, x) in enumerate(working_set) if k not in (i, j)]
# add a segment representing i and j's furthest points
pts = [ working_set[i]['p0'], working_set[i]['p1'], working_set[j]['p0'], working_set[j]['p1'] ]
pts.sort(key=lambda x: x[0])
new_set.append({
'p0': pts[0],
'p1': pts[-1],
'slope': self.slope(pts[0], pts[-1]),
'id': working_set[i]['id'],
'originalPathId': working_set[i]['originalPathId']
})
return (False, new_set)
return (True, working_set)
def filter_collinear(self, lineArray):
'''
Loop through a set of lines and find + fiter all overlapping segments / duplicate segments
finally returns a set of merged-like lines and a set of original items which should be dropped
'''
segments = []
# collect segments, calculate their slopes, order their points left-to-right
for line in lineArray:
csp = line.path.to_arrays()
x1, y1, x2, y2 = csp[0][1][0], csp[0][1][1], csp[1][1][0], csp[1][1][1]
# ensure p0 is left of p1
if x1 < x2:
s = {
'p0': [x1, y1],
'p1': [x2, y2]
}
else:
s = {
'p0': [x2, y2],
'p1': [x1, y1]
}
s['slope'] = self.slope(s['p0'], s['p1'])
s['id'] = line.attrib['id']
s['originalPathId'] = line.attrib['originalPathId']
segments.append(s)
working_set = []
output_set = []
segments.sort(key=lambda x: x['slope'])
segments.append(False) # used to clear out lingering contents of working_set on last iteration
current_slope = segments[0]['slope']
for seg in segments:
# bin sets of segments by slope (within a tolerance)
dm = seg and abs(seg['slope'] - current_slope) or 0
if seg and dm < EPS_M:
working_set.append(seg)
else: # slope discontinuity, process accumulated set
while True:
(done, working_set) = self.process_set(working_set)
if done:
output_set.extend(working_set)
break
if seg: # begin new working set
working_set = [seg]
current_slope = seg['slope']
seg_ids = []
for seg in segments:
if seg is not False and seg['id'] != '':
seg_ids.append(seg['id'])
out_ids = []
for output in output_set:
out_ids.append(output['id'])
dropped_ids = set(seg_ids) - set(out_ids)
#self.msg("segments:{}".format(segments))
#self.msg("_____________")
#self.msg("working_set:{}".format(working_set))
#self.msg("_____________")
#self.msg("output_set:{}".format(output_set))
#self.msg("_____________")
#self.msg("dropped_set:{}".format(dropped_ids))
#self.msg("_____________")
return output_set, dropped_ids
def remove_duplicates(self, allTrimGroups): def remove_duplicates(self, allTrimGroups):
''' find duplicate lines in a given array [] of groups ''' ''' find duplicate lines in a given array [] of groups '''
totalTrimPaths = [] totalTrimPaths = []
@ -421,6 +554,8 @@ class ContourScannerAndTrimmer(inkex.EffectExtension):
pars.add_argument("--flatness", type=float, default=0.1, help="Minimum flatness = 0.001. The smaller the value the more fine segments you will get (quantization). Large values might destroy the line continuity.") pars.add_argument("--flatness", type=float, default=0.1, help="Minimum flatness = 0.001. The smaller the value the more fine segments you will get (quantization). Large values might destroy the line continuity.")
pars.add_argument("--decimals", type=int, default=3, help="Accuracy for sub split lines / lines trimmed by shapely") pars.add_argument("--decimals", type=int, default=3, help="Accuracy for sub split lines / lines trimmed by shapely")
pars.add_argument("--snap_tolerance", type=float, default=0.1, help="Snap tolerance for intersection points") pars.add_argument("--snap_tolerance", type=float, default=0.1, help="Snap tolerance for intersection points")
pars.add_argument("--draw_subsplit", type=inkex.Boolean, default=False, help="Draw sub split lines (polylines)")
pars.add_argument("--remove_subsplit_collinear", type=inkex.Boolean, default=True, help="Removes any duplicates by merging (multiple) overlapping line segments into longer lines.")
#Scanning - Removing #Scanning - Removing
pars.add_argument("--remove_relative", type=inkex.Boolean, default=False, help="relative cmd") pars.add_argument("--remove_relative", type=inkex.Boolean, default=False, help="relative cmd")
@ -441,7 +576,6 @@ class ContourScannerAndTrimmer(inkex.EffectExtension):
pars.add_argument("--highlight_opened", type=inkex.Boolean, default=False, help="opened paths") pars.add_argument("--highlight_opened", type=inkex.Boolean, default=False, help="opened paths")
pars.add_argument("--highlight_closed", type=inkex.Boolean, default=False, help="closed paths") pars.add_argument("--highlight_closed", type=inkex.Boolean, default=False, help="closed paths")
pars.add_argument("--highlight_self_intersecting", type=inkex.Boolean, default=False, help="self-intersecting paths") pars.add_argument("--highlight_self_intersecting", type=inkex.Boolean, default=False, help="self-intersecting paths")
pars.add_argument("--draw_subsplit", type=inkex.Boolean, default=False, help="Draw sub split lines (polylines)")
pars.add_argument("--visualize_self_intersections", type=inkex.Boolean, default=False, help="self-intersecting path points") pars.add_argument("--visualize_self_intersections", type=inkex.Boolean, default=False, help="self-intersecting path points")
pars.add_argument("--visualize_global_intersections", type=inkex.Boolean, default=False, help="global intersection points") pars.add_argument("--visualize_global_intersections", type=inkex.Boolean, default=False, help="global intersection points")
@ -506,7 +640,7 @@ class ContourScannerAndTrimmer(inkex.EffectExtension):
#get all paths which are within selection or in document and generate sub split lines #get all paths which are within selection or in document and generate sub split lines
pathElements = self.get_path_elements() pathElements = self.get_path_elements()
subSplitLineArray = [] subSplitLineArray = []
for pathElement in pathElements: for pathElement in pathElements:
@ -613,7 +747,8 @@ class ContourScannerAndTrimmer(inkex.EffectExtension):
subSplitId = "{}-{}-{}".format(idPrefixSubSplit, originalPathId, i) subSplitId = "{}-{}-{}".format(idPrefixSubSplit, originalPathId, i)
line = inkex.PathElement(id=subSplitId) line = inkex.PathElement(id=subSplitId)
#apply line path with composed negative transform from parent element #apply line path with composed negative transform from parent element
line.path = [['M', [x1, y1]], ['L', [x2, y2]]] line.attrib['d'] = 'M {},{} L {},{}'.format(x1, y1, x2, y2) #we set the path of trimLine using 'd' attribute because if we use trimLine.path the decimals get cut off unwantedly
#line.path = [['M', [x1, y1]], ['L', [x2, y2]]]
if pathElement.getparent() != self.svg.root and pathElement.getparent() != None: if pathElement.getparent() != self.svg.root and pathElement.getparent() != None:
line.path = line.path.transform(-pathElement.getparent().composed_transform()) line.path = line.path.transform(-pathElement.getparent().composed_transform())
line.style = basicSubSplitLineStyle line.style = basicSubSplitLineStyle
@ -722,6 +857,28 @@ class ContourScannerAndTrimmer(inkex.EffectExtension):
if so.show_debug is True: if so.show_debug is True:
self.msg("sub split line count: {}".format(len(subSplitLineArray))) self.msg("sub split line count: {}".format(len(subSplitLineArray)))
if so.remove_subsplit_collinear is True:
if so.show_debug is True: self.msg("filtering collinear overlapping lines / duplicate lines")
output_set, dropped_ids = self.filter_collinear(subSplitLineArray)
for subSplitLine in subSplitLineArray:
ssl_id = subSplitLine.get('id')
if ssl_id in dropped_ids:
ssl_parent = subSplitLine.getparent()
subSplitLine.delete() #delete the line
#and delete the containg group if empty
if ssl_parent is not None and len(ssl_parent) == 0:
if self.options.show_debug is True:
self.msg("Deleting group {}".format(ssl_parent.get('id')))
ssl_parent.delete()
# and now we replace the overlapping items with the new merged output
for output in output_set:
if output['id'] == subSplitLine.attrib['id']:
#self.msg(output['p0'])
subSplitLine.attrib['d'] = line.attrib['d'] = 'M {},{} L {},{}'.format(
output['p0'][0], output['p0'][1], output['p1'][0], output['p1'][1]) #we set the path of trimLine using 'd' attribute because if we use trimLine.path the decimals get cut off unwantedly
#subSplitLine.path = line.path = [['M', output['p0']], ['L', output['p1']]]
''' '''
now we intersect the sub split lines to find the global intersection points using Bentley-Ottmann algorithm (contains self-intersections too!) now we intersect the sub split lines to find the global intersection points using Bentley-Ottmann algorithm (contains self-intersections too!)
''' '''
@ -794,17 +951,21 @@ class ContourScannerAndTrimmer(inkex.EffectExtension):
if len(allTrimGroups) == 0: if len(allTrimGroups) == 0:
self.msg("You selected to draw trimmed lines but no intersections could be calculated.") self.msg("You selected to draw trimmed lines but no intersections could be calculated.")
#trim beziers - not working yet if so.bezier_trimming is True:
if so.bezier_trimming is True: self.trim_bezier(allTrimGroups) if so.show_debug is True: self.msg("trimming beziers - not working yet")
self.trim_bezier(allTrimGroups)
#check for duplicate trim lines and delete them if desired if so.remove_duplicates is True:
if so.remove_duplicates is True: self.remove_duplicates(allTrimGroups) if so.show_debug is True: self.msg("checking for duplicate trim lines and deleting them")
self.remove_duplicates(allTrimGroups)
#glue together all non-intersected sub split lines to larger path structures again (cleaning up). if so.combine_nonintersects is True:
if so.combine_nonintersects is True: self. combine_nonintersects(allTrimGroups) if so.show_debug is True: self.msg("glueing together all non-intersected sub split lines to larger path structures again (cleaning up)")
self. combine_nonintersects(allTrimGroups)
#clean original paths if selected. This option is explicitely independent from remove_open, remove_closed #clean original paths if selected. This option is explicitely independent from remove_open, remove_closed
if so.keep_original_after_trim is False: if so.keep_original_after_trim is False:
if so.show_debug is True: self.msg("cleaning original paths")
for pathElement in pathElements: for pathElement in pathElements:
pathElement.delete() pathElement.delete()