diff --git a/extensions/fablabchemnitz/contour_scanner_and_trimmer/contour_scanner_and_trimmer.inx b/extensions/fablabchemnitz/contour_scanner_and_trimmer/contour_scanner_and_trimmer.inx new file mode 100644 index 0000000..e073ac8 --- /dev/null +++ b/extensions/fablabchemnitz/contour_scanner_and_trimmer/contour_scanner_and_trimmer.inx @@ -0,0 +1,188 @@ + + + Contour Scanner And Trimmer + fablabchemnitz.de.contour_scanner_and_trimmer + + + + + + false + false + false + true + 0.100 + 3 + 0.1 + 0.01 + + 1.0 + 30 + false + + + + + + + + + + + + + false + false + false + false + false + false + false + false + false + + true + + + + + + false + + + + false + false + false + false + false + false + false + false + false + + false + false + false + + false + false + + + + + + + + + false + + true + true + false + true + true + + + + true + false + false + false + true + + + + + + + + + + 1630897151 + + 3419879935 + 1592519679 + 3351636735 + 4289703935 + 258744063 + 4118348031 + 4012452351 + 2330080511 + + + + + 897901823 + 869366527 + + 2593756927 + 6320383 + 4239343359 + + 3227634687 + 1923076095 + 3045284607 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ../000_about_fablabchemnitz.svg + + + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/contour_scanner_and_trimmer/contour_scanner_and_trimmer.py b/extensions/fablabchemnitz/contour_scanner_and_trimmer/contour_scanner_and_trimmer.py new file mode 100644 index 0000000..95987eb --- /dev/null +++ b/extensions/fablabchemnitz/contour_scanner_and_trimmer/contour_scanner_and_trimmer.py @@ -0,0 +1,1301 @@ +#!/usr/bin/env python3 + +''' +Extension for InkScape 1.0+ + - ToDo: + - add more comments + - add more debug output + - add documentation about used algorithms at online page + - add statistics about type counts and path lengths (before/after sub splitting/trimming) + - add options: + - replace trimmed paths by bezier paths (calculating lengths and required t parameter) + - filter/remove overlapping/duplicates in + - in original selection (not working bezier but for straight line segments!) We can use another extension for it + - split bezier + - ... + - maybe option: convert abs path to rel path + - maybe option: convert rel path to abs 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) + - note: running this extension might leave some empty parent groups in some circumstances. run the clean groups extension separately to fix that + - sort to groups by path type (open, closed, ...) + +- important to notice + - this algorithm might be really slow. Reduce flattening quality to speed up + - the code quality is horrible. We need a lot of asserts and functions to structure that stuff + - try to adjust snap tolerance and flatness in case of errors, like + 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)'" + - this extension does not check for strange paths. Please ensure that your path 'd' + data is valid (no pointy paths, no duplicates, etc.) + - Notes about shapely: + - we do not use shapely to look for intersections by cutting each line against + each other line (line1.intersection(line2) using two for-loops) because this + 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 + +- things to look at more closely: + - https://gis.stackexchange.com/questions/203048/split-lines-at-points-using-shapely + - https://stackoverflow.com/questions/34754777/shapely-split-linestrings-at-intersections-with-other-linestrings + - There are floating point precision errors when finding a point on a line. Use the distance with an appropriate threshold instead. + - line.within(point) # False + - line.distance(point) # 7.765244949417793e-11 + - line.distance(point) < 1e-8 # True + - https://bezier.readthedocs.io/en/stable/python/reference/bezier.hazmat.clipping.html / https://github.com/dhermes/bezier + - De Casteljau Algorithm + +Author: Mario Voigt / FabLab Chemnitz +Mail: mario.voigt@stadtfabrikanten.org +Date: 09.08.2020 (extension originally called "Contour Scanner") +Last patch: 07.11.2022 +License: GNU GPL v3 + +''' + +import sys +import os +import copy +from lxml import etree +import poly_point_isect +from poly_point_isect import isect_segments +import inkex +from inkex import transforms, bezier, PathElement, Color, Circle +from inkex.bezier import csplength +from inkex.paths import Path, CubicSuperPath +from shapely.geometry import LineString, Point, MultiPoint +from shapely.ops import snap, split +from shapely import speedups +if speedups.available: + speedups.enable() + +idPrefixSubSplit = "subsplit" +idPrefixTrimming = "trimmed" +intersectedVerb = "intersected" +collinearVerb = "collinear" + +class ContourScannerAndTrimmer(inkex.EffectExtension): + + + def break_contours(self, element, breakelements = None): + ''' + this does the same as "CTRL + SHIFT + K" + This functions honors the fact of absolute or relative paths! + ''' + if breakelements == None: + breakelements = [] + if element.tag == inkex.addNS('path','svg'): + parent = element.getparent() + idx = parent.index(element) + idSuffix = 0 + #raw = str(element.path).split() + raw = element.path.to_arrays() + subPaths = [] + prev = 0 + for i in range(len(raw)): # Breaks compound paths into simple paths + #if raw[i][0].upper() == 'M' and i != 0: + if raw[i][0] == 'M' and i != 0: + subPath = raw[prev:i] + subPaths.append(Path(subPath)) + prev = i + subPaths.append(Path(raw[prev:])) #finally add the last path + + for subPath in subPaths: + replacedelement = copy.copy(element) + oldId = replacedelement.get('id') + csp = CubicSuperPath(subPath) + if len(subPath) > 1 and csp[0][0] != csp[0][1]: #avoids pointy paths like M "31.4794 57.6024 Z" + replacedelement.path = subPath + if len(subPaths) == 1: + replacedelement.set('id', oldId) + else: + replacedelement.set('id', oldId + str(idSuffix)) + idSuffix += 1 + parent.insert(idx, replacedelement) + breakelements.append(replacedelement) + element.delete() + for child in element.getchildren(): + self.break_contours(child, breakelements) + return breakelements + + + def get_child_paths(self, element, elements = None): + ''' a function to get child paths from elements (used by "handling groups" option) ''' + if elements == None: + elements = [] + if element.tag == inkex.addNS('path','svg'): + elements.append(element) + for child in element.getchildren(): + self.get_child_paths(child, elements) + return elements + + + def get_path_elements(self): + ''' get all path elements, either from selection or from whole document. Uses options ''' + pathElements = [] + if len(self.svg.selected) == 0: #if nothing selected we search for the complete document + pathElements = self.document.xpath('//svg:path', namespaces=inkex.NSS) + else: # or get selected paths (and children) and convert them to shapely LineString objects + if self.options.handle_groups is False: + pathElements = list(self.svg.selection.filter(PathElement).values()) + else: + for element in self.svg.selection.values(): + pathElements = self.get_child_paths(element, pathElements) + + if len(pathElements) == 0: + self.msg('Selection appears to be empty or does not contain any valid svg:path nodes. Try to cast your objects to paths using CTRL + SHIFT + C or strokes to paths using CTRL + ALT + C') + exit(1) + + if self.options.break_apart is True: + breakApartElements = None + for pathElement in pathElements: + breakApartElements = self.break_contours(pathElement, breakApartElements) + pathElements = breakApartElements + + if self.options.show_debug is True: + self.msg("total processing paths count: {}".format(len(pathElements))) + + return pathElements + + + def find_group(self, groupId): + ''' check if a group with a given id exists or not. Returns None if not found, else returns the group element ''' + groups = self.document.xpath('//svg:g', namespaces=inkex.NSS) + for group in groups: + #self.msg(str(layer.get('inkscape:label')) + " == " + layerName) + if group.get('id') == groupId: + return group + return None + + + def adjust_style(self, element): + ''' Replace some style attributes of the given element ''' + if element.attrib.has_key('style'): + style = element.get('style') + if style: + declarations = style.split(';') + for i,decl in enumerate(declarations): + parts = decl.split(':', 2) + if len(parts) == 2: + (prop, val) = parts + prop = prop.strip().lower() + if prop == 'stroke-width': + declarations[i] = prop + ':' + str(self.svg.unittouu(str(self.options.strokewidth) +"px")) + if prop == 'fill': + declarations[i] = prop + ':none' + element.set('style', ';'.join(declarations) + ';stroke:#000000;stroke-opacity:1.0') + else: + element.set('style', 'stroke:#000000;stroke-opacity:1.0') + + + def line_from_segments(self, segs, i, decimals): + '''builds a straight line for the segment i and the next segment i+2. Returns both point XY coordinates''' + pseudoPath = Path(segs[i:i+2]).to_arrays() + x1 = round(pseudoPath[0][1][-2], decimals) + y1 = round(pseudoPath[0][1][-1], decimals) + if pseudoPath[1][0] == 'Z': #some crappy code when the path is closed + pseudoPathEnd = Path(segs[0:2]).to_arrays() + x2 = round(pseudoPathEnd[0][1][-2], decimals) + y2 = round(pseudoPathEnd[0][1][-1], decimals) + else: + x2 = round(pseudoPath[1][1][-2], decimals) + y2 = round(pseudoPath[1][1][-1], decimals) + return x1, y1, x2, y2 + + + def visualize_self_intersections(self, pathElement, selfIntersectionPoints): + ''' Draw some circles at given point coordinates (data from array)''' + selfIntersectionGroup = pathElement.getparent().add(inkex.Group(id="selfIntersectionPoints-{}".format(pathElement.attrib["id"]))) + selfIntersectionPointStyle = {'stroke': 'none', 'fill': self.options.color_self_intersections} + for selfIntersectionPoint in selfIntersectionPoints: + cx = selfIntersectionPoint[0] + cy = selfIntersectionPoint[1] + selfIntersectionPointCircle = Circle(cx=str(cx), + cy=str(cy), + r=str(self.svg.unittouu(str(self.options.dotsize_intersections / 2) + "px")) + ) + + if pathElement.getparent() != self.svg.root: + selfIntersectionPointCircle.transform = -pathElement.getparent().composed_transform() + selfIntersectionPointCircle.set('id', self.svg.get_unique_id('selfIntersectionPoint-')) + selfIntersectionPointCircle.style = selfIntersectionPointStyle + selfIntersectionGroup.add(selfIntersectionPointCircle) + return selfIntersectionGroup + + + def visualize_global_intersections(self, globalIntersectionPoints): + ''' Draw some circles at given point coordinates (data from array)''' + if len(globalIntersectionPoints.geoms) > 0: #only create a group and add stuff if there are some elements to work on + globalIntersectionGroup = self.svg.root.add(inkex.Group(id="globalIntersectionPoints")) + globalIntersectionPointStyle = {'stroke': 'none', 'fill': self.options.color_global_intersections} + for globalIntersectionPoint in globalIntersectionPoints.geoms: + cx = globalIntersectionPoint.coords[0][0] + cy = globalIntersectionPoint.coords[0][1] + globalIntersectionPointCircle = Circle(cx=str(cx), + cy=str(cy), + r=str(self.svg.unittouu(str(self.options.dotsize_intersections / 2) + "px")) + ) + globalIntersectionPointCircle.set('id', self.svg.get_unique_id('globalIntersectionPoint-')) + globalIntersectionPointCircle.style = globalIntersectionPointStyle + globalIntersectionGroup.add(globalIntersectionPointCircle) + + + def build_trim_line_group(self, subSplitLineArray, subSplitIndex, globalIntersectionPoints): + ''' make a group containing trimmed lines''' + + #Check if we should skip or process the path anyway + isClosed = subSplitLineArray[subSplitIndex].attrib['originalPathIsClosed'] + if self.options.trimming_path_types == 'open_paths' and isClosed == 'True': return #skip this call + elif self.options.trimming_path_types == 'closed_paths' and isClosed == 'False': return #skip this call + elif self.options.trimming_path_types == 'both': pass + + csp = Path(subSplitLineArray[subSplitIndex].path.transform(subSplitLineArray[subSplitIndex].composed_transform())).to_arrays() #will be buggy if draw subsplit lines is deactivated + + ls = LineString([(csp[0][1][0], csp[0][1][1]), (csp[1][1][0], csp[1][1][1])]) + + trimLineStyle = {'stroke': str(self.options.color_trimmed), 'fill': 'none', 'stroke-width': self.options.strokewidth} + + linesWithSnappedIntersectionPoints = snap(ls, globalIntersectionPoints, self.options.snap_tolerance) + trimGroupParentId = subSplitLineArray[subSplitIndex].attrib['originalPathId'] + trimGroupId = '{}-{}-{}'.format(idPrefixTrimming, idPrefixSubSplit, trimGroupParentId) + trimGroupParent = self.svg.getElementById(trimGroupParentId) + trimGroup = self.find_group(trimGroupId) + + if trimGroup is None: + trimGroup = trimGroupParent.getparent().add(inkex.Group(id=trimGroupId)) + trimGroup.transform = -subSplitLineArray[subSplitIndex].composed_transform() + + #apply isBezier and original path id information to group (required for bezier splitting the original path at the end) + trimGroup.attrib['originalPathIsBezier'] = subSplitLineArray[subSplitIndex].attrib['originalPathIsBezier'] + trimGroup.attrib['originalPathIsPolyBezMixed'] = subSplitLineArray[subSplitIndex].attrib['originalPathIsPolyBezMixed'] + trimGroup.attrib['originalPathId'] = subSplitLineArray[subSplitIndex].attrib['originalPathId'] + + #split all lines against all other lines using the intersection points + trimLines = split(linesWithSnappedIntersectionPoints, globalIntersectionPoints) + + 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 + for j in range(len(trimLines.geoms)): + + trimLineId = "{}-{}".format(trimGroupId, subSplitIndex) + splitAt.append(trimGroupId) + if splitAt.count(trimGroupId) > 1: #we detected a lines with intersection on + trimLineId = "{}-{}".format(trimLineId, self.svg.get_unique_id(intersectedVerb + "-")) + ''' + so the previous lines was an intersection lines too. so we change the id to include the intersected verb + (left side and right side of cut) - note: updating element + id sometimes seems not to work if the id was used before in Inkscape + ''' + prevLine.attrib['id'] = "{}-{}".format(trimGroupId, str(subSplitIndex) + "-" + self.svg.get_unique_id(intersectedVerb + "-")) + prevLine.attrib['intersected'] = 'True' #some dirty flag we need + prevLine = trimLine = inkex.PathElement(id=trimLineId) + x, y = trimLines.geoms[j].coords.xy + 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: + # trimLine.path = trimLine.path.transform(-trimGroupParentTransform) + if self.options.trimmed_style == "apply_from_trimmed": + trimLine.style = trimLineStyle + elif self.options.trimmed_style == "apply_from_original": + trimLine.style = subSplitLineArray[subSplitIndex].attrib['originalPathStyle'] + trimGroup.add(trimLine) + 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 #vertical + return (p1[1] - p0[1]) / dx + + + def process_set_x(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 + expected_slope = working_set[i]['slope'] + if (abs(self.slope(working_set[i]['p1'], working_set[j]['p0']) - expected_slope) > self.options.collinear_filter_epsilon) \ + or (abs(self.slope(working_set[i]['p0'], working_set[j]['p1']) - expected_slope) > self.options.collinear_filter_epsilon): + 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'], + 'composed_transform': working_set[i]['composed_transform'] + }) + return (False, new_set) + + return (True, working_set) + + + def process_set_y(self, working_set): + if len(working_set) < 2: + return (True, working_set) + + # sort working set top to bottom + working_set.sort(key=lambda y: -y['p0'][1]) + + for i in range(0, len(working_set)): + for j in range(i + 1, len(working_set)): + + # 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 + s0y0 = working_set[i]['p0'][1] + s0y1 = working_set[i]['p1'][1] + s1y0 = working_set[j]['p0'][1] + s1y1 = working_set[j]['p1'][1] + + if not (s0y0 < s0y1 and s0y1 < s1y0 and s1y0 < s1y1): + # make a duplicate set, omitting segments i and j + new_set = [y for (k, y) 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 y: y[1]) + 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'], + 'composed_transform': working_set[i]['composed_transform'] + }) + return (False, new_set) + + return (True, working_set) + + + def filter_collinear(self, lineArray): + ''' Another sweep line algorithm to scan collinear lines + 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. + Based on the style of the algorithm we have no good influence on the z-index of the items because + it is scanned by slope and point coordinates. That's why we have a more special + 'remove_trim_duplicates()' function for trimmed duplicates! + ''' + + ''' + filter for regular input lines and special vertical lines + ''' + input_set = [] + input_ids = [] + + # collect segments, calculate their slopes, order their points left-to-right + for line in lineArray: + #csp = line.path.to_arrays() + parent = line.getparent() + if parent is not None: + csp = Path(line.path.transform(parent.composed_transform())).to_arrays() + else: + csp = line.path.to_arrays() + #self.msg("csp = {}".format(csp)) + 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'] + s['composed_transform'] = line.composed_transform() + #s['d'] = line.attrib['d'] + input_set.append(s) + + input_set.sort(key=lambda x: x['slope']) + #input_set.append(False) # used to clear out lingering contents of working_set_x on last iteration + + input_set_new = [] + + #loop through input_set to filter out the vertical lines because we need to handle them separately + vertical_set = [] + vertical_ids = [] + for i in range(0, len(input_set)): + if input_set[i]['slope'] == sys.float_info.max: + vertical_set.append(input_set[i]) + else: + input_set_new.append(input_set[i]) + + input_set = input_set_new #overwrite the input_set with the filtered one + input_set.append(False) # used to clear out lingering contents of working_set_x on last iteration + + ''' + process x lines (all lines except vertical ones) + ''' + working_set_x = [] + working_x_ids = [] + output_set_x = [] + output_x_ids = [] + + if len(input_set) > 0: + current_slope = input_set[0]['slope'] + for input in input_set: + # bin sets of input_set by slope (within a tolerance) + dm = input and abs(input['slope'] - current_slope) or 0 + if input and dm < self.options.collinear_filter_epsilon: + working_set_x.append(input) #we put all lines to working set which have similar slopes + if input['id'] != '': input_ids.append(input['id']) + else: # slope discontinuity, process accumulated set + while True: + (done, working_set_x) = self.process_set_x(working_set_x) + if done: + output_set_x.extend(working_set_x) + break + + if input: # begin new working set + working_set_x = [input] + current_slope = input['slope'] + if input['id'] != '': input_ids.append(input['id']) + + + for output_x in output_set_x: + output_x_ids.append(output_x['id']) + + for working_x in working_set_x: + working_x_ids.append(working_x['id']) + else: + if self.options.show_debug is True: + self.msg("Scanning: no non-vertical input lines found. That might be okay or not ...") + + ''' + process vertical lines + ''' + working_set_y = [] + working_y_ids = [] + output_set_y = [] + output_y_ids = [] + + if len(vertical_set) > 0: + vertical_set.sort(key=lambda x: x['p0'][0]) #sort verticals by their x coordinate + vertical_set.append(False) # used to clear out lingering contents of working_set_y on last iteration + current_x = vertical_set[0]['p0'][0] + for vertical in vertical_set: + if vertical and current_x == vertical['p0'][0]: + working_set_y.append(vertical) #we put all lines to working set which have same x coordinate + if vertical['id'] != '': vertical_ids.append(vertical['id']) + else: # x coord discontinuity, process accumulated set + while True: + (done, working_set_y) = self.process_set_y(working_set_y) + if done: + output_set_y.extend(working_set_y) + break + if vertical: # begin new working set + working_set_y = [vertical] + current_x = vertical['p0'][0] + if vertical['id'] != '': vertical_ids.append(vertical['id']) + else: + if self.options.show_debug is True: + self.msg("Scanning: no vertical lines found. That might be okay or not ...") + + for output_y in output_set_y: + output_y_ids.append(output_y['id']) + + for working_y in working_set_y: + working_y_ids.append(working_y['id']) + + output_set = output_set_x + output_set.extend(output_set_y) + output_ids = [] + for output in output_set: + #self.msg(output) + output_ids.append(output['id']) + + #we finally build a list which contains all overlapping elements we want to drop + dropped_ids = [] + for input_id in input_ids: #if the input_id id is not in the output ids we are going to drop it + if input_id not in output_ids: + dropped_ids.append(input_id) + for vertical_id in vertical_ids: #if the input_id id is not in the output ids we are going to drop it + if vertical_id not in output_ids: + dropped_ids.append(vertical_id) + + if self.options.show_debug is True: + #self.msg("input_set:{}".format(input_set)) + self.msg("input_ids [{}]:".format(len(input_ids))) + for input_id in input_ids: + self.msg(input_id) + self.msg("*"*24) + #self.msg("working_set_x:{}".format(working_set_x)) + self.msg("working_x_ids [{}]:".format(len(working_x_ids))) + for working_x_id in working_x_ids: + self.msg(working_x_id) + self.msg("*"*24) + #self.msg("output_set_x:{}".format(output_set_x)) + self.msg("output_x_ids [{}]:".format(len(output_x_ids))) + for output_x_id in output_x_ids: + self.msg(output_x_id) + self.msg("*"*24) + #self.msg("output_set_y:{}".format(output_set_y)) + self.msg("output_y_ids [{}]:".format(len(output_y_ids))) + for output_y_id in output_y_ids: + self.msg(output_y_id) + self.msg("*"*24) + #self.msg("output_set:{}".format(output_set)) + self.msg("output_ids [{}]:".format(len(output_ids))) + for output_id in output_ids: + self.msg(output_id) + self.msg("*"*24) + self.msg("dropped_ids [{}]:".format(len(dropped_ids))) + for dropped_id in dropped_ids: + self.msg(dropped_id) + self.msg("*"*24) + + return output_set, dropped_ids + + + def remove_trim_duplicates(self, allTrimGroups): + ''' + find duplicate lines in a given array [] of groups + note: this function is similar to filter_collinear but we keep it because we have a 'reverse_trim_removal_order' option. + We can use this option in some special situations where we work without the function 'filter_collinear()'. + ''' + totalTrimPaths = [] + if self.options.reverse_trim_removal_order is True: + allTrimGroups = allTrimGroups[::-1] + for trimGroup in allTrimGroups: + for element in trimGroup: + path = element.path.transform(element.composed_transform()) + if path not in totalTrimPaths: + totalTrimPaths.append(path) + else: + if self.options.show_debug is True: + self.msg("Deleting path {}".format(element.get('id'))) + element.delete() + if len(trimGroup) == 0: + if self.options.show_debug is True: + self.msg("Deleting group {}".format(trimGroup.get('id'))) + trimGroup.delete() + + + def combine_nonintersects(self, allTrimGroups): + ''' + combine and chain all non intersected sub split lines which were trimmed at intersection points before. + - At first we sort out all lines by their id: + - if the lines id contains intersectedVerb, we ignore it + - we combine all lines which do not contain intersectedVerb + - Then we loop through that combined structure and chain their segments which touch each other + Changes the style according to user setting. + ''' + + nonTrimLineStyle = {'stroke': str(self.options.color_nonintersected), 'fill': 'none', 'stroke-width': self.options.strokewidth} + trimNonIntersectedStyle = {'stroke': str(self.options.color_combined), 'fill': 'none', 'stroke-width': self.options.strokewidth} + + for trimGroup in allTrimGroups: + totalIntersectionsAtPath = 0 + combinedPath = None + combinedPathData = Path() + if self.options.show_debug is True: + self.msg("trim group {} has {} paths".format(trimGroup.get('id'), len(trimGroup))) + for pElement in trimGroup: + pId = pElement.get('id') + #if self.options.show_debug is True: + # self.msg("trim paths id {}".format(pId)) + if intersectedVerb not in pId: + if combinedPath is None: + combinedPath = pElement + combinedPathData = pElement.path + else: + combinedPathData += pElement.path + pElement.delete() + else: + totalIntersectionsAtPath += 1 + if len(combinedPathData) > 0: + segData = combinedPathData.to_arrays() + newPathData = [] + newPathData.append(segData[0]) + for z in range(1, len(segData)): #skip first because we add it statically + if segData[z][1] != segData[z-1][1]: + newPathData.append(segData[z]) + if self.options.show_debug is True: + self.msg("trim group {} has {} combinable segments:".format(trimGroup.get('id'), len(newPathData))) + self.msg("{}".format(newPathData)) + combinedPath.path = Path(newPathData) + if self.options.trimmed_style == "apply_from_trimmed": + combinedPath.style = trimNonIntersectedStyle + if totalIntersectionsAtPath == 0: + combinedPath.style = nonTrimLineStyle + else: #the group might consist of intersections only. than we have length of 0 + if self.options.show_debug is True: + self.msg("trim group {} has no combinable segments (contains only intersected trim lines)".format(trimGroup.get('id'))) + + + def trim_bezier(self, allTrimGroups): + ''' + trim bezier path by checking the lengths and calculating global t parameter from the trimmed sub split lines groups + This function does not work yet. + ''' + for trimGroup in allTrimGroups: + if (trimGroup.attrib.has_key('originalPathIsBezier') and trimGroup.attrib['originalPathIsBezier'] == "True") or\ + (trimGroup.attrib.has_key('originalPathIsPolyBezMixed') and trimGroup.attrib['originalPathIsPolyBezMixed'] == "True"): + globalTParameters = [] + if self.options.show_debug is True: + self.msg("{}: count of trim lines = {}".format(trimGroup.get('id'), len(trimGroup))) + totalLength = 0 + for trimLine in trimGroup: + ignore, lineLength = csplength(CubicSuperPath(trimLine.get('d'))) + totalLength += lineLength + if self.options.show_debug is True: + self.msg("total length = {}".format(totalLength)) + chainLength = 0 + for trimLine in trimGroup: + ignore, lineLength = csplength(CubicSuperPath(trimLine.get('d'))) + chainLength += lineLength + if trimLine.attrib.has_key('intersected') or trimLine == trimGroup[-1]: #we may not used intersectedVerb because this was used for the affected left as well as the right side of the splitting. This would result in one "intersection" too much. + globalTParameter = chainLength / totalLength + globalTParameters.append(globalTParameter) + if self.options.show_debug is True: + self.msg("chain piece length = {}".format(chainLength)) + self.msg("t parameter = {}".format(globalTParameter)) + chainLength = 0 + if self.options.show_debug is True: + self.msg("Trimming the original bezier path {} at global t parameters: {}".format(trimGroup.attrib['originalPathId'], globalTParameters)) + for globalTParameter in globalTParameters: + csp = CubicSuperPath(self.svg.getElementById(trimGroup.attrib['originalPathId'])) + ''' + Sadly, those calculated global t parameters are useless for splitting because we cannot split the complete curve at a t parameter + Instead we only can split a bezier by getting to commands which build up a bezier path segment. + - we need to find those parts (segment pairs) of the original path first where the sub split line intersection occurs + - then we need to calculate the t parameter + - then we split the bezier part (consisting of two commands) and check the new intersection point. + It should match the sub split lines intersection point. + If they do not match we need to adjust the t parameter or loop to previous or next bezier command to find intersection + ''' + + + def add_arguments(self, pars): + pars.add_argument("--nb_main") + pars.add_argument("--nb_settings_and_actions") + + #Settings - General Input/Output + pars.add_argument("--show_debug", type=inkex.Boolean, default=False, help="Show debug infos") + pars.add_argument("--break_apart", type=inkex.Boolean, default=False, help="Break apart input paths into sub paths") + pars.add_argument("--handle_groups", type=inkex.Boolean, default=False, help="Also looks for paths in groups which are in the current selection") + pars.add_argument("--trimming_path_types", default="closed_paths", help="Process open paths by other open paths, closed paths by other closed paths, or all paths by all other paths") + pars.add_argument("--flattenbezier", type=inkex.Boolean, default=True, help="Flatten bezier curves to (poly)lines") + 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("--snap_tolerance", type=float, default=0.1, help="Snap tolerance for intersection points") + pars.add_argument("--collinear_filter_epsilon", type=float, default=0.01, help="Epsilon for collinear line filter") + #Settings - General Style + pars.add_argument("--strokewidth", type=float, default=1.0, help="Stroke width (px)") + pars.add_argument("--dotsize_intersections", type=int, default=30, help="Dot size (px) for self-intersecting and global intersection points") + pars.add_argument("--removefillsetstroke", type=inkex.Boolean, default=False, help="Remove fill and define stroke for original paths") + pars.add_argument("--bezier_trimming", type=inkex.Boolean, default=False, help="If true we try to use the calculated t parameters from intersection points to receive splitted bezier curves") + pars.add_argument("--subsplit_style", default="default", help="Sub split line style") + pars.add_argument("--trimmed_style", default="apply_from_trimmed", help="Trimmed line style") + + #Removing - Applying to original paths and sub split lines + pars.add_argument("--remove_relative", type=inkex.Boolean, default=False, help="relative cmd") + pars.add_argument("--remove_absolute", type=inkex.Boolean, default=False, help="absolute cmd") + pars.add_argument("--remove_rel_abs_mixed", type=inkex.Boolean, default=False, help="mixed rel/abs cmd (relative + absolute)") + pars.add_argument("--remove_polylines", type=inkex.Boolean, default=False, help="(poly)line") + pars.add_argument("--remove_beziers", type=inkex.Boolean, default=False, help="bezier") + pars.add_argument("--remove_poly_bez_mixed", type=inkex.Boolean, default=False, help="mixed (poly)line/bezier paths") + pars.add_argument("--remove_opened", type=inkex.Boolean, default=False, help="opened") + pars.add_argument("--remove_closed", type=inkex.Boolean, default=False, help="closed") + pars.add_argument("--remove_self_intersecting", type=inkex.Boolean, default=False, help="self-intersecting") + #Removing - Applying to sub split lines only + pars.add_argument("--filter_subsplit_collinear", type=inkex.Boolean, default=True, help="Removes any duplicates by merging (multiple) overlapping line segments into longer lines. Not possible to apply for original paths because this routine does not support bezier type paths.") + pars.add_argument("--filter_subsplit_collinear_action", default="remove", help="What to do with collinear overlapping lines?") + #Removing - Applying to original paths only + pars.add_argument("--delete_original_after_split_trim", type=inkex.Boolean, default=False, help="Delete original paths after sub splitting / trimming") + + #Highlighting - Applying to original paths and sub split lines + pars.add_argument("--highlight_relative", type=inkex.Boolean, default=False, help="relative cmd paths") + pars.add_argument("--highlight_absolute", type=inkex.Boolean, default=False, help="absolute cmd paths") + pars.add_argument("--highlight_rel_abs_mixed", type=inkex.Boolean, default=False, help="mixed rel/abs cmd (relative + absolute) paths") + pars.add_argument("--highlight_polylines", type=inkex.Boolean, default=False, help="(poly)line paths") + pars.add_argument("--highlight_beziers", type=inkex.Boolean, default=False, help="bezier paths") + pars.add_argument("--highlight_poly_bez_mixed", type=inkex.Boolean, default=False, help="mixed (poly)line/bezier 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") + #Highlighting - Applying to sub split lines only + pars.add_argument("--draw_subsplit", type=inkex.Boolean, default=False, help="Draw sub split lines ((poly)lines)") + pars.add_argument("--highlight_duplicates", type=inkex.Boolean, default=False, help="duplicates (only applies to sub split lines)") + pars.add_argument("--highlight_merges", type=inkex.Boolean, default=False, help="merges (only applies to sub split lines)") + #Highlighting - Intersection points + pars.add_argument("--highlight_self_intersecting", type=inkex.Boolean, default=False, help="self-intersecting paths") + 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") + + #Trimming - General trimming settings + pars.add_argument("--draw_trimmed", type=inkex.Boolean, default=False, help="Draw trimmed lines") + pars.add_argument("--combine_nonintersects", type=inkex.Boolean, default=True, help="Combine non-intersected lines") + pars.add_argument("--remove_trim_duplicates", type=inkex.Boolean, default=True, help="Remove duplicate trim lines") + pars.add_argument("--reverse_trim_removal_order", type=inkex.Boolean, default=False, help="Reverses the order of removal. Relevant for keeping certain styles of elements") + pars.add_argument("--remove_subsplit_after_trimming", type=inkex.Boolean, default=True, help="Remove sub split lines after trimming") + #Trimming - Bentley-Ottmann sweep line settings + pars.add_argument("--bent_ott_use_ignore_segment_endings", type=inkex.Boolean, default=True, help="Whether to ignore intersections of line segments when both their end points form the intersection point") + pars.add_argument("--bent_ott_use_debug", type=inkex.Boolean, default=False) + pars.add_argument("--bent_ott_use_verbose", type=inkex.Boolean, default=False) + pars.add_argument("--bent_ott_use_paranoid", type=inkex.Boolean, default=False) + pars.add_argument("--bent_ott_use_vertical", type=inkex.Boolean, default=True) + pars.add_argument("--bent_ott_number_type", default="native") + + #Colors + pars.add_argument("--color_subsplit", type=Color, default='1630897151', help="sub split lines") + #Colors - path structure + pars.add_argument("--color_relative", type=Color, default='3419879935', help="relative cmd paths") + pars.add_argument("--color_absolute", type=Color, default='1592519679', help="absolute cmd paths") + pars.add_argument("--color_rel_abs_mixed", type=Color, default='3351636735', help="mixed rel/abs cmd (relative + absolute) paths") + pars.add_argument("--color_polyline", type=Color, default='4289703935', help="(poly)line paths") + pars.add_argument("--color_bezier", type=Color, default='258744063', help="bezier paths") + pars.add_argument("--color_poly_bez_mixed", type=Color, default='4118348031', help="mixed (poly)line/bezier paths") + pars.add_argument("--color_opened", type=Color, default='4012452351', help="opened paths") + pars.add_argument("--color_closed", type=Color, default='2330080511', help="closed paths") + #Colors - duplicates and merges + pars.add_argument("--color_duplicates", type=Color, default='897901823', help="duplicates") + pars.add_argument("--color_merges", type=Color, default='869366527', help="merges") + #Colors - intersections + pars.add_argument("--color_self_intersecting_paths", type=Color, default='2593756927', help="self-intersecting paths") + pars.add_argument("--color_self_intersections", type=Color, default='6320383', help="self-intersecting path points") + pars.add_argument("--color_global_intersections", type=Color, default='4239343359', help="global intersection points") + #Colors - trimming + pars.add_argument("--color_trimmed", type=Color, default='1923076095', help="trimmed lines") + pars.add_argument("--color_combined", type=Color, default='3227634687', help="non-intersected lines") + pars.add_argument("--color_nonintersected", type=Color, default='3045284607', help="non-intersected paths") + + + def effect(self): + + so = self.options + + if so.break_apart is True and so.show_debug is True: + self.msg("Warning: 'Break apart input' setting is enabled. Cannot check accordingly for relative, absolute or mixed paths for breaked elements (they are always absolute)!") + + so.strokewidth = self.svg.unittouu("{}px".format(so.strokewidth)) #add unit conversion globally + + #some configuration dependecies + if so.highlight_self_intersecting is True or \ + so.highlight_duplicates is True or \ + so.highlight_merges is True: + so.draw_subsplit = True + + if so.highlight_duplicates is True or \ + so.highlight_merges is True: + so.filter_subsplit_collinear = True + + if so.filter_subsplit_collinear is True: #this is a must. + #if so.draw_subsplit is disabled bu we filter sub split lines and follow with trim operation we lose a lot of elements which may not be deleted! + so.draw_subsplit = True + + if so.draw_subsplit is False and so.draw_trimmed is True: + so.delete_original_after_split_trim = True + + if so.draw_trimmed is True: + so.draw_subsplit = True + + if so.bent_ott_use_debug is True: + so.show_debug = True + + #some constant stuff / styles + relativePathStyle = {'stroke': str(so.color_relative), 'fill': 'none', 'stroke-width': so.strokewidth} + absolutePathStyle = {'stroke': str(so.color_absolute), 'fill': 'none', 'stroke-width': so.strokewidth} + mixedRelAbsPathStyle = {'stroke': str(so.color_rel_abs_mixed), 'fill': 'none', 'stroke-width': so.strokewidth} + polylinePathStyle = {'stroke': str(so.color_polyline), 'fill': 'none', 'stroke-width': so.strokewidth} + bezierPathStyle = {'stroke': str(so.color_bezier), 'fill': 'none', 'stroke-width': so.strokewidth} + mixedPolyBezPathStyle = {'stroke': str(so.color_poly_bez_mixed), 'fill': 'none', 'stroke-width': so.strokewidth} + openPathStyle = {'stroke': str(so.color_opened), 'fill': 'none', 'stroke-width': so.strokewidth} + closedPathStyle = {'stroke': str(so.color_closed), 'fill': 'none', 'stroke-width': so.strokewidth} + duplicatesPathStyle = {'stroke': str(so.color_duplicates), 'fill': 'none', 'stroke-width': so.strokewidth} + mergesPathStyle = {'stroke': str(so.color_merges), 'fill': 'none', 'stroke-width': so.strokewidth} + selfIntersectingPathStyle = {'stroke': str(so.color_self_intersecting_paths), 'fill': 'none', 'stroke-width': so.strokewidth} + basicSubSplitLineStyle = {'stroke': str(so.color_subsplit), 'fill': 'none', 'stroke-width': so.strokewidth} + + #some config for Bentley Ottmann - applies to highlighting, removing, trimming + poly_point_isect.USE_IGNORE_SEGMENT_ENDINGS = so.bent_ott_use_ignore_segment_endings + poly_point_isect.USE_DEBUG = so.bent_ott_use_debug + poly_point_isect.USE_VERBOSE = so.bent_ott_use_verbose + if so.show_debug is False: + poly_point_isect.USE_VERBOSE = False + poly_point_isect.USE_PARANOID = so.bent_ott_use_paranoid + poly_point_isect.USE_VERTICAL = so.bent_ott_use_vertical + NUMBER_TYPE = so.bent_ott_number_type + if NUMBER_TYPE == 'native': + Real = float + NUM_EPS = Real("1e-10") + NUM_INF = Real(float("inf")) + elif NUMBER_TYPE == 'numpy': + import numpy + Real = numpy.float64 + del numpy + NUM_EPS = Real("1e-10") + NUM_INF = Real(float("inf")) + poly_point_isect.Real = Real + poly_point_isect.NUM_EPS = NUM_EPS + poly_point_isect.NUM_INF = NUM_INF + poly_point_isect.NUM_EPS_SQ = NUM_EPS * NUM_EPS + poly_point_isect.NUM_ZERO = Real(0.0) + poly_point_isect.NUM_ONE = Real(1.0) + + #get all paths which are within selection or in document and generate sub split lines + pathElements = self.get_path_elements() + + subSplitLineArray = [] + + for pathElement in pathElements: + originalPathId = pathElement.attrib["id"] + path = pathElement.path.transform(pathElement.composed_transform()) + #inkex.utils.debug(path) + + ''' + check for relative or absolute paths + ''' + isRelative = False + isAbsolute = False + isRelAbsMixed = False + relCmds = ['m', 'l', 'h', 'v', 'c', 's', 'q', 't', 'a', 'z'] + if any(relCmd in str(path) for relCmd in relCmds): + isRelative = True + if any(relCmd.upper() in str(path) for relCmd in relCmds): + isAbsolute = True + if isRelative is True and isAbsolute is True: #cannot be both at the same time, so it's mixed + isRelAbsMixed = True + isRelative = False + isAbsolute = False + if so.remove_absolute is True and isAbsolute is True: + pathElement.delete() + continue #skip this loop iteration + if so.remove_relative is True and isRelative is True: + pathElement.delete() + continue #skip this loop iteration + if so.remove_rel_abs_mixed is True and isRelAbsMixed is True: + pathElement.delete() + continue #skip this loop iteration + + ''' + check for bezier or (poly)line paths + ''' + isPoly = False + isBezier = False + isPolyBezMixed = False + chars = set('aAcCqQtTsS') + if any((c in chars) for c in str(path)): + isBezier = True + if ('l' in str(path) or 'L' in str(path)): + isPoly = True + if isPoly is True and isBezier is True: #cannot be both at the same time, so it's mixed + isPolyBezMixed = True + isPoly = False #reset + isBezier = False #reset + + #if so.show_debug is True: + # self.msg("sub path in {} is bezier: {}".format(originalPathId, isBezier)) + if so.remove_beziers is True and isBezier is True: + pathElement.delete() + continue #skip this loop iteration + if so.remove_polylines is True and isPoly is True: + pathElement.delete() + continue #skip this loop iteration + if so.remove_poly_bez_mixed is True and isPolyBezMixed is False: + pathElement.delete() + continue #skip this loop iteration + + '''important conversion step''' + path = pathElement.path.to_absolute().transform(pathElement.composed_transform()) #now we convert to absolute path, because later processing causes some buggy offsets + + ''' + check for closed or open paths + ''' + isClosed = False + raw = path.to_arrays() + #inkex.utils.debug(path) + if raw[-1][0] == 'Z' or \ + (raw[-1][0] == 'L' and raw[0][1] == raw[-1][1]) or \ + (raw[-1][0] == 'C' and raw[0][1] == [raw[-1][1][-2], raw[-1][1][-1]]) \ + : #if first is last point the path is also closed. The "Z" command is not required + isClosed = True + if so.remove_opened is True and isClosed is False: + pathElement.delete() + continue #skip this loop iteration + if so.remove_closed is True and isClosed is True: + pathElement.delete() + continue #skip this loop iteration + + if so.draw_subsplit is True: + subSplitLineGroup = pathElement.getparent().add(inkex.Group(id="{}-{}".format(idPrefixSubSplit, originalPathId))) + + #get all sub paths for the path of the element + subPaths, prev = [], 0 + for i in range(len(raw)): # Breaks compound paths into simple paths + if raw[i][0] == 'M' and i != 0: + subPaths.append(raw[prev:i]) + prev = i + subPaths.append(raw[prev:]) + #inkex.utils.debug(subPaths) + + #now loop through all sub paths (and flatten if desired) to build up single lines + for subPath in subPaths: + subPathData = CubicSuperPath(subPath) + + #flatten bezier curves. If it was already a straight line do nothing! Otherwise we would split straight lines into a lot more straight linesd) + if so.flattenbezier is True and (isBezier is True or isPolyBezMixed is True): + bezier.cspsubdiv(subPathData, so.flatness) #modifies the path + flattenedpath = [] + for seg in subPathData: + first = True + for csp in seg: + cmd = 'L' + if first: + cmd = 'M' + first = False + flattenedpath.append([cmd, [csp[1][0], csp[1][1]]]) + #self.msg("flattened path = " + str(flattenedpath)) + segs = list(CubicSuperPath(flattenedpath).to_segments()) + else: + segs = list(subPathData.to_segments()) + #segs = segs[::-1] #reverse the segments + + #build (poly)lines from segment data + subSplitLines = [] + for i in range(len(segs) - 1): #we could do the same routine to build up (poly)lines using "for x, y in node.path.end_points". See "number nodes" extension + x1, y1, x2, y2 = self.line_from_segments(segs, i, so.decimals) + #inkex.utils.debug(segs) + #self.msg("xy1=({},{}) xy2=({},{})".format(x1, y1, x2, y2)) + subSplitId = "{}-{}-{}".format(idPrefixSubSplit, originalPathId, i) + line = inkex.PathElement(id=subSplitId) + #apply line path with composed negative transform from parent element + line.attrib['d'] = 'M {},{} L {},{}'.format(x1, y1, x2, y2) #we set the path of Line 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: + line.path = line.path.transform(-pathElement.getparent().composed_transform()) + line.style = basicSubSplitLineStyle + line.attrib['originalPathId'] = originalPathId + line.attrib['originalPathIsRelative'] = str(isRelative) + line.attrib['originalPathIsAbsolute'] = str(isAbsolute) + line.attrib['originalPathIsRelAbsMixed'] = str(isRelAbsMixed) + line.attrib['originalPathIsBezier'] = str(isBezier) + line.attrib['originalPathIsPoly'] = str(isPoly) + line.attrib['originalPathIsPolyBezMixed'] = str(isPolyBezMixed) + line.attrib['originalPathIsClosed'] = str(isClosed) + line.attrib['originalPathStyle'] = str(pathElement.style) + subSplitLineArray.append(line) + + if so.subsplit_style == "apply_from_highlightings": + if line.attrib['originalPathIsRelative'] == 'True': + if so.highlight_relative is True: + line.style = relativePathStyle + + if line.attrib['originalPathIsAbsolute'] == 'True': + if so.highlight_absolute is True: + line.style = absolutePathStyle + + if line.attrib['originalPathIsRelAbsMixed'] == 'True': + if so.highlight_rel_abs_mixed is True: + line.style = mixedRelAbsPathStyle + + if line.attrib['originalPathIsPoly'] == 'True': + if so.highlight_polylines is True: + line.style = polylinePathStyle + + if line.attrib['originalPathIsBezier'] == 'True': + if so.highlight_beziers is True: + line.style = bezierPathStyle + + if line.attrib['originalPathIsPolyBezMixed'] == 'True': + if so.highlight_poly_bez_mixed is True: + line.style = mixedPolyBezPathStyle + + if line.attrib['originalPathIsClosed'] == 'True': + if so.highlight_closed is True: + line.style = closedPathStyle + else: + if so.highlight_opened is True: + line.style = openPathStyle + elif so.subsplit_style == "apply_from_original": + line.style = line.attrib['originalPathStyle'] + + if so.draw_subsplit is True: + subSplitLineGroup.add(line) + subSplitLines.append([(x1, y1), (x2, y2)]) + + #check for self intersections using Bentley-Ottmann algorithm. + isSelfIntersecting = False + if so.highlight_self_intersecting is True or so.remove_self_intersecting or so.visualize_self_intersections: + selfIntersectionPoints = isect_segments(subSplitLines, validate=True) + if len(selfIntersectionPoints) > 0: + isSelfIntersecting = True + if so.show_debug is True: + self.msg("{} in {} intersects itself with {} intersections!".format(subSplitId, originalPathId, len(selfIntersectionPoints))) + if so.highlight_self_intersecting is True: + for subSplitLine in subSplitLineGroup: + subSplitLine.style = selfIntersectingPathStyle #adjusts line color + #delete cosmetic sub split lines if desired + if so.remove_self_intersecting: + subSplitLineGroup.delete() + if so.visualize_self_intersections is True: #draw points (circles) + selfIntersectionGroup = self.visualize_self_intersections(pathElement, selfIntersectionPoints) + + #delete self-intersecting sub split lines and orginal paths + if so.remove_self_intersecting: + subSplitLineArray = subSplitLineArray[:len(subSplitLineArray) - len(segs) - 1] #remove all last added lines + pathElement.delete() #and finally delete the orginal path + continue + + #adjust the style of original paths if desired. Has influence to the finally trimmed lines style results too! + if so.removefillsetstroke is True: + self.adjust_style(pathElement) + + #apply styles to original paths + if isRelative is True: + if so.highlight_relative is True: + pathElement.style = relativePathStyle + + if isAbsolute is True: + if so.highlight_absolute is True: + pathElement.style = absolutePathStyle + + if isRelAbsMixed is True: + if so.highlight_rel_abs_mixed is True: + pathElement.style = mixedRelAbsPathStyle + + if isBezier is True: + if so.highlight_beziers is True: + pathElement.style = bezierPathStyle + + if isPoly is True: + if so.highlight_polylines is True: + pathElement.style = polylinePathStyle + + if isPolyBezMixed is True: + if so.highlight_poly_bez_mixed is True: + pathElement.style = mixedPolyBezPathStyle + + if isClosed is True: + if so.highlight_closed is True: + pathElement.style = closedPathStyle + else: + if so.highlight_opened is True: + pathElement.style = openPathStyle + + if isSelfIntersecting is True: + if so.highlight_self_intersecting is True: + pathElement.style = selfIntersectingPathStyle + + if so.draw_subsplit is True: + if subSplitLineGroup is not None: #might get deleted before so we need to check this first + subSplitLineGroup = reversed(subSplitLineGroup) #reverse the order to match the original path segment placing + + if so.show_debug is True: + self.msg("sub split line count: {}".format(len(subSplitLineArray))) + + ''' + check for collinear lines and apply filters to remove or regroup and to restyle them + Run this action only if one of the options requires it (to avoid useless calculation cycles) + ''' + if so.filter_subsplit_collinear is True or \ + so.highlight_duplicates is True or \ + so.highlight_merges is True: + if so.show_debug is True: self.msg("filtering collinear overlapping lines / duplicate lines") + if len(subSplitLineArray) > 0: + output_set, dropped_ids = self.filter_collinear(subSplitLineArray) + deleteIndices = [] + deleteIndice = 0 + for subSplitLine in subSplitLineArray: + ''' + Replace the overlapping items with the new merged output + ''' + for output in output_set: + if output['id'] == subSplitLine.attrib['id']: + originalSplitLinePath = subSplitLine.path + output_line = 'M {},{} L {},{}'.format( + output['p0'][0], output['p0'][1], output['p1'][0], output['p1'][1]) + output_line_reversed = 'M {},{} L {},{}'.format( + output['p1'][0], output['p1'][1], output['p0'][0], output['p0'][1]) + subSplitLine.attrib['d'] = output_line #we set the path using 'd' attribute because if we use trimLine.path the decimals get cut off unwantedly + mergedSplitLinePath = subSplitLine.path + mergedSplitLinePathReversed = Path(output_line_reversed) + #subSplitLine.path = [['M', output['p0']], ['L', output['p1']]] + #self.msg("composed_transform = {}".format(output['composed_transform'])) + #subSplitLine.transform = Transform(-output['composed_transform']) * subSplitLine.transform + + subSplitLine.path = subSplitLine.path.transform(-output['composed_transform']) + if so.highlight_merges is True: + if originalSplitLinePath != mergedSplitLinePath and \ + originalSplitLinePath != mergedSplitLinePathReversed: #if the path changed we are going to highlight it + subSplitLine.style = mergesPathStyle + + + ''' + Delete or move sub split lines which are overlapping + ''' + ssl_id = subSplitLine.get('id') + if ssl_id in dropped_ids: + if so.highlight_duplicates is True: + subSplitLine.style = duplicatesPathStyle + + if so.filter_subsplit_collinear is True: + ssl_parent = subSplitLine.getparent() + if so.filter_subsplit_collinear_action == "remove": + if self.options.show_debug is True: + self.msg("Deleting sub split line {}".format(subSplitLine.get('id'))) + subSplitLine.delete() #delete the line from XML tree + deleteIndices.append(deleteIndice) #store this id to remove it from stupid subSplitLineArray later + elif so.filter_subsplit_collinear_action == "separate_group": + if self.options.show_debug is True: + self.msg("Moving sub split line {}".format(subSplitLine.get('id'))) + originalPathId = subSplitLine.attrib['originalPathId'] + collinearGroupId = '{}-{}'.format(collinearVerb, originalPathId) + originalPathElement = self.svg.getElementById(originalPathId) + collinearGroup = self.find_group(collinearGroupId) + if collinearGroup is None: + collinearGroup = originalPathElement.getparent().add(inkex.Group(id=collinearGroupId)) + collinearGroup.append(subSplitLine) #move to that group + #and delete the containg group if empty (can happen in "remove" or "separate_group" constellation + 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() + + deleteIndice += 1 #end the loop by incrementing +1 + + #shrink the sub split line array to kick out all unrequired indices + for deleteIndice in sorted(deleteIndices, reverse=True): + if self.options.show_debug is True: + self.msg("Deleting index {} from subSplitLineArray".format(deleteIndice)) + del subSplitLineArray[deleteIndice] + + ''' + now we intersect the sub split lines to find the global intersection points using Bentley-Ottmann algorithm (contains self-intersections too!) + ''' + if so.draw_trimmed is True: + try: + allSubSplitLineStrings = [] + for subSplitLine in subSplitLineArray: + csp = Path(subSplitLine.path.transform(subSplitLine.composed_transform())).to_arrays() #will be buggy if draw subsplit lines is deactivated + lineString = [(csp[0][1][0], csp[0][1][1]), (csp[1][1][0], csp[1][1][1])] + #lineStringStyle = {'stroke': '#0000FF', 'fill': 'none', 'stroke-width': str(self.svg.unittouu('1px'))} + #line = self.svg.get_current_layer().add(inkex.PathElement(id=self.svg.get_unique_id('lineString'))) + #line.set('d', "M{:0.6f},{:0.6f} L{:0.6f},{:0.6f}".format(lineString[0][0],lineString[0][1],lineString[1][0],lineString[1][1])) + #line.style = lineStringStyle + #line.transform = -self.svg.get_current_layer().transform + + if so.remove_trim_duplicates is True: + if lineString not in allSubSplitLineStrings: + allSubSplitLineStrings.append(lineString) + else: + if so.show_debug is True: + self.msg("line {} already in sub split line collection. Dropping ...".format(lineString)) + else: #if false we append all segments without filtering duplicate ones + allSubSplitLineStrings.append(lineString) + if so.show_debug is True: + self.msg("Going to calculate intersections using Bentley Ottmann Sweep Line Algorithm") + globalIntersectionPoints = MultiPoint(isect_segments(allSubSplitLineStrings, validate=True)) + if so.show_debug is True: + self.msg("global intersection points count: {}".format(len(globalIntersectionPoints))) + if len(globalIntersectionPoints.geoms) > 0: + if so.visualize_global_intersections is True: + self.visualize_global_intersections(globalIntersectionPoints) + + ''' + now we trim the sub split lines at all calculated intersection points. + We do this path by path to keep the logic between original paths, sub split lines and the final output + ''' + allTrimGroups = [] #container to collect all trim groups for later on processing + for subSplitIndex in range(len(subSplitLineArray)): + trimGroup = self.build_trim_line_group(subSplitLineArray, subSplitIndex, globalIntersectionPoints) + if trimGroup is not None: + if trimGroup not in allTrimGroups: + allTrimGroups.append(trimGroup) + + if so.show_debug is True: self.msg("trim groups count: {}".format(len(allTrimGroups))) + if len(allTrimGroups) == 0: + self.msg("You selected to draw trimmed lines but no intersections could be calculated.") + + if so.bezier_trimming is True: + if so.show_debug is True: self.msg("trimming beziers - not working yet") + self.trim_bezier(allTrimGroups) + + if so.remove_trim_duplicates is True: + if so.show_debug is True: self.msg("checking for duplicate trim lines and deleting them") + self.remove_trim_duplicates(allTrimGroups) + + if so.combine_nonintersects is True: + 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) + + if so.remove_subsplit_after_trimming is True: + if so.show_debug is True: self.msg("removing unwanted subsplit lines after trimming") + for subSplitLine in subSplitLineArray: + ssl_parent = subSplitLine.getparent() + subSplitLine.delete() + 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() + + except AssertionError as e: + self.msg("Error calculating global intersections.\n\ +See https://github.com/ideasman42/isect_segments-bentley_ottmann.\n\n\ +You can try to fix this by:\n\ +- reduce or raise the 'decimals' setting (default is 3 but try to set to 6 for example)\n\ +- reduce or raise the 'flatness' setting (if quantization option is used at all; default is 0.100).") + return + + #clean original paths if selected. + if so.delete_original_after_split_trim is True: + if so.show_debug is True: self.msg("cleaning original paths after sub splitting / trimming") + for pathElement in pathElements: + pathElement.delete() + +if __name__ == '__main__': + ContourScannerAndTrimmer().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/contour_scanner_and_trimmer/meta.json b/extensions/fablabchemnitz/contour_scanner_and_trimmer/meta.json new file mode 100644 index 0000000..a57397f --- /dev/null +++ b/extensions/fablabchemnitz/contour_scanner_and_trimmer/meta.json @@ -0,0 +1,20 @@ +[ + { + "name": "Contour Scanner And Trimmer", + "id": "fablabchemnitz.de.contour_scanner_and_trimmer", + "path": "contour_scanner_and_trimmer", + "dependent_extensions": null, + "original_name": "Contour Scanner And Trimmer", + "original_id": "fablabchemnitz.de.contour_scanner_and_trimmer", + "license": "GNU GPL v3", + "license_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.X/src/branch/master/LICENSE", + "comment": "Written by Mario Voigt from scratch with a lot of help using other extensions as template", + "source_url": "https://gitea.fablabchemnitz.de/FabLab_Chemnitz/mightyscape-1.X/src/branch/master/extensions/fablabchemnitz/contour_scanner_and_trimmer", + "fork_url": null, + "documentation_url": "https://stadtfabrikanten.org/display/IFM/Contour+Scanner+And+Trimmer", + "inkscape_gallery_url": "https://inkscape.org/de/~MarioVoigt/%E2%98%85contour-scanner-and-trimmer", + "main_authors": [ + "github.com/vmario89" + ] + } +] \ No newline at end of file diff --git a/extensions/fablabchemnitz/contour_scanner_and_trimmer/poly_point_isect.py b/extensions/fablabchemnitz/contour_scanner_and_trimmer/poly_point_isect.py new file mode 100644 index 0000000..4d1a302 --- /dev/null +++ b/extensions/fablabchemnitz/contour_scanner_and_trimmer/poly_point_isect.py @@ -0,0 +1,1306 @@ +# BentleyOttmann sweep-line implementation +# (for finding all intersections in a set of line segments) + +from __future__ import annotations +import inkex + +__all__ = ( + "isect_segments", + "isect_polygon", + + # same as above but includes segments with each intersections + "isect_segments_include_segments", + "isect_polygon_include_segments", + + # for testing only (correct but slow) + "isect_segments__naive", + "isect_polygon__naive", +) + +# ---------------------------------------------------------------------------- +# Main Poly Intersection + +# Defines to change behavior. +# +# Whether to ignore intersections of line segments when both +# their end points form the intersection point. +USE_IGNORE_SEGMENT_ENDINGS = True + +USE_DEBUG = True + +USE_VERBOSE = False + +# checks we should NOT need, +# but do them in case we find a test-case that fails. +USE_PARANOID = False + +# Support vertical segments, +# (the bentley-ottmann method doesn't support this). +# We use the term 'START_VERTICAL' for a vertical segment, +# to differentiate it from START/END/INTERSECTION +USE_VERTICAL = True +# end defines! +# ------------ + +# --------- +# Constants +X, Y = 0, 1 + +# ----------------------------------------------------------------------------- +# Switchable Number Implementation + +NUMBER_TYPE = 'native' + +if NUMBER_TYPE == 'native': + Real = float + NUM_EPS = Real("1e-10") + NUM_INF = Real(float("inf")) +elif NUMBER_TYPE == 'decimal': + # Not passing tests! + import decimal + Real = decimal.Decimal + decimal.getcontext().prec = 80 + NUM_EPS = Real("1e-10") + NUM_INF = Real(float("inf")) +elif NUMBER_TYPE == 'numpy': + import numpy + Real = numpy.float64 + del numpy + NUM_EPS = Real("1e-10") + NUM_INF = Real(float("inf")) +elif NUMBER_TYPE == 'gmpy2': + # Not passing tests! + import gmpy2 + gmpy2.set_context(gmpy2.ieee(128)) + Real = gmpy2.mpz + NUM_EPS = Real(float("1e-10")) + NUM_INF = gmpy2.get_emax_max() + del gmpy2 +else: + raise Exception("Type not found") + +NUM_EPS_SQ = NUM_EPS * NUM_EPS +NUM_ZERO = Real(0.0) +NUM_ONE = Real(1.0) + + +class Event: + __slots__ = ( + "type", + "point", + "segment", + + # this is just cache, + # we may remove or calculate slope on the fly + "slope", + "span", + ) + (() if not USE_DEBUG else ( + # debugging only + "other", + "in_sweep", + )) + + class Type: + END = 0 + INTERSECTION = 1 + START = 2 + if USE_VERTICAL: + START_VERTICAL = 3 + + def __init__(self, type, point, segment, slope): + assert(isinstance(point, tuple)) + self.type = type + self.point = point + self.segment = segment + + # will be None for INTERSECTION + self.slope = slope + if segment is not None: + self.span = segment[1][X] - segment[0][X] + + if USE_DEBUG: + self.other = None + self.in_sweep = False + + # note that this isn't essential, + # it just avoids non-deterministic ordering, see #9. + def __hash__(self): + return hash(self.point) + + def is_vertical(self): + # return self.segment[0][X] == self.segment[1][X] + return self.span == NUM_ZERO + + def y_intercept_x(self, x: Real): + # vertical events only for comparison (above_all check) + # never added into the binary-tree its self + if USE_VERTICAL: + if self.is_vertical(): + return None + + if x <= self.segment[0][X]: + return self.segment[0][Y] + elif x >= self.segment[1][X]: + return self.segment[1][Y] + + # use the largest to avoid float precision error with nearly vertical lines. + delta_x0 = x - self.segment[0][X] + delta_x1 = self.segment[1][X] - x + if delta_x0 > delta_x1: + ifac = delta_x0 / self.span + fac = NUM_ONE - ifac + else: + fac = delta_x1 / self.span + ifac = NUM_ONE - fac + assert(fac <= NUM_ONE) + return (self.segment[0][Y] * fac) + (self.segment[1][Y] * ifac) + + @staticmethod + def Compare(sweep_line, this, that): + if this is that: + return 0 + if USE_DEBUG: + if this.other is that: + return 0 + current_point_x = sweep_line._current_event_point_x + this_y = this.y_intercept_x(current_point_x) + that_y = that.y_intercept_x(current_point_x) + # print(this_y, that_y) + if USE_VERTICAL: + if this_y is None: + this_y = this.point[Y] + if that_y is None: + that_y = that.point[Y] + + delta_y = this_y - that_y + + assert((delta_y < NUM_ZERO) == (this_y < that_y)) + # NOTE, VERY IMPORTANT TO USE EPSILON HERE! + # otherwise w/ float precision errors we get incorrect comparisons + # can get very strange & hard to debug output without this. + if abs(delta_y) > NUM_EPS: + return -1 if (delta_y < NUM_ZERO) else 1 + else: + this_slope = this.slope + that_slope = that.slope + if this_slope != that_slope: + if sweep_line._before: + return -1 if (this_slope > that_slope) else 1 + else: + return 1 if (this_slope > that_slope) else -1 + + delta_x_p1 = this.segment[0][X] - that.segment[0][X] + if delta_x_p1 != NUM_ZERO: + return -1 if (delta_x_p1 < NUM_ZERO) else 1 + + delta_x_p2 = this.segment[1][X] - that.segment[1][X] + if delta_x_p2 != NUM_ZERO: + return -1 if (delta_x_p2 < NUM_ZERO) else 1 + + return 0 + + def __repr__(self): + return ("Event(0x%x, s0=%r, s1=%r, p=%r, type=%d, slope=%r)" % ( + id(self), + self.segment[0], self.segment[1], + self.point, + self.type, + self.slope, + )) + + +class SweepLine: + __slots__ = ( + # A map holding all intersection points mapped to the Events + # that form these intersections. + # {Point: set(Event, ...), ...} + "intersections", + "queue", + + # Events (sorted set of ordered events, no values) + # + # note: START & END events are considered the same so checking if an event is in the tree + # will return true if its opposite side is found. + # This is essential for the algorithm to work, and why we don't explicitly remove START events. + # Instead, the END events are never added to the current sweep, and removing them also removes the start. + "_events_current_sweep", + # The point of the current Event. + "_current_event_point_x", + # A flag to indicate if we're slightly before or after the line. + "_before", + ) + + def __init__(self, queue: EventQueue): + self.intersections = {} + self.queue = queue + + self._current_event_point_x = None + self._events_current_sweep = RBTree(cmp=Event.Compare, cmp_data=self) + self._before = True + + def get_intersections(self): + """ + Return a list of unordered intersection points. + """ + if Real is float: + return list(self.intersections.keys()) + else: + return [(float(p[0]), float(p[1])) for p in self.intersections.keys()] + + # Not essential for implementing this algorithm, but useful. + def get_intersections_with_segments(self): + """ + Return a list of unordered intersection '(point, segment)' pairs, + where segments may contain 2 or more values. + """ + if Real is float: + return [ + (p, [event.segment for event in event_set]) + for p, event_set in self.intersections.items() + ] + else: + return [ + ( + (float(p[0]), float(p[1])), + [((float(event.segment[0][0]), float(event.segment[0][1])), + (float(event.segment[1][0]), float(event.segment[1][1]))) + for event in event_set], + ) + for p, event_set in self.intersections.items() + ] + + # Checks if an intersection exists between two Events 'a' and 'b'. + def _check_intersection(self, a: Event, b: Event): + # Return immediately in case either of the events is null, or + # if one of them is an INTERSECTION event. + if ( + (a is None or b is None) or + (a.type == Event.Type.INTERSECTION) or + (b.type == Event.Type.INTERSECTION) + ): + return + + if a is b: + return + + # Get the intersection point between 'a' and 'b'. + p = isect_seg_seg_v2_point( + a.segment[0], a.segment[1], + b.segment[0], b.segment[1], + ) + + # No intersection exists. + if p is None: + return + + # If the intersection is formed by both the segment endings, AND + # USE_IGNORE_SEGMENT_ENDINGS is true, + # return from this method. + if USE_IGNORE_SEGMENT_ENDINGS: + if ((len_squared_v2v2(p, a.segment[0]) < NUM_EPS_SQ or + len_squared_v2v2(p, a.segment[1]) < NUM_EPS_SQ) and + (len_squared_v2v2(p, b.segment[0]) < NUM_EPS_SQ or + len_squared_v2v2(p, b.segment[1]) < NUM_EPS_SQ)): + + return + + # Add the intersection. + events_for_point = self.intersections.pop(p, set()) + is_new = len(events_for_point) == 0 + events_for_point.add(a) + events_for_point.add(b) + self.intersections[p] = events_for_point + + # If the intersection occurs to the right of the sweep line, OR + # if the intersection is on the sweep line and it's above the + # current event-point, add it as a new Event to the queue. + if is_new and p[X] >= self._current_event_point_x: + event_isect = Event(Event.Type.INTERSECTION, p, None, None) + self.queue.offer(p, event_isect) + + def _sweep_to(self, p): + if p[X] == self._current_event_point_x: + # happens in rare cases, + # we can safely ignore + return + + self._current_event_point_x = p[X] + + def insert(self, event): + assert(event not in self._events_current_sweep) + assert(not USE_VERTICAL or event.type != Event.Type.START_VERTICAL) + if USE_DEBUG: + assert(event.in_sweep == False) + assert(event.other.in_sweep == False) + + self._events_current_sweep.insert(event, None) + + if USE_DEBUG: + event.in_sweep = True + event.other.in_sweep = True + + def remove(self, event): + try: + self._events_current_sweep.remove(event) + if USE_DEBUG: + assert(event.in_sweep == True) + assert(event.other.in_sweep == True) + event.in_sweep = False + event.other.in_sweep = False + return True + except KeyError: + if USE_DEBUG: + assert(event.in_sweep == False) + assert(event.other.in_sweep == False) + return False + + def above(self, event): + return self._events_current_sweep.succ_key(event, None) + + def below(self, event): + return self._events_current_sweep.prev_key(event, None) + + ''' + def above_all(self, event): + while True: + event = self.above(event) + if event is None: + break + yield event + ''' + + def above_all(self, event): + # assert(event not in self._events_current_sweep) + return self._events_current_sweep.key_slice(event, None, reverse=False) + + def handle(self, p, events_current): + if len(events_current) == 0: + return + # done already + # self._sweep_to(events_current[0]) + assert(p[0] == self._current_event_point_x) + + if not USE_IGNORE_SEGMENT_ENDINGS: + if len(events_current) > 1: + for i in range(0, len(events_current) - 1): + for j in range(i + 1, len(events_current)): + self._check_intersection( + events_current[i], events_current[j]) + + for e in events_current: + self.handle_event(e) + + def handle_event(self, event): + t = event.type + if t == Event.Type.START: + # print(" START") + self._before = False + self.insert(event) + + e_above = self.above(event) + e_below = self.below(event) + + self._check_intersection(event, e_above) + self._check_intersection(event, e_below) + if USE_PARANOID: + self._check_intersection(e_above, e_below) + + elif t == Event.Type.END: + # print(" END") + self._before = True + + e_above = self.above(event) + e_below = self.below(event) + + self.remove(event) + + self._check_intersection(e_above, e_below) + if USE_PARANOID: + self._check_intersection(event, e_above) + self._check_intersection(event, e_below) + + elif t == Event.Type.INTERSECTION: + # print(" INTERSECTION") + self._before = True + event_set = self.intersections[event.point] + # note: events_current aren't sorted. + reinsert_stack = [] # Stack + for e in event_set: + # Since we know the Event wasn't already removed, + # we want to insert it later on. + if self.remove(e): + reinsert_stack.append(e) + self._before = False + + # Insert all Events that we were able to remove. + while reinsert_stack: + e = reinsert_stack.pop() + + self.insert(e) + + e_above = self.above(e) + e_below = self.below(e) + + self._check_intersection(e, e_above) + self._check_intersection(e, e_below) + if USE_PARANOID: + self._check_intersection(e_above, e_below) + elif (USE_VERTICAL and + (t == Event.Type.START_VERTICAL)): + + # just check sanity + assert(event.segment[0][X] == event.segment[1][X]) + assert(event.segment[0][Y] <= event.segment[1][Y]) + + # In this case we only need to find all segments in this span. + y_above_max = event.segment[1][Y] + + # self.insert(event) + for e_above in self.above_all(event): + if e_above.type == Event.Type.START_VERTICAL: + continue + y_above = e_above.y_intercept_x( + self._current_event_point_x) + if USE_IGNORE_SEGMENT_ENDINGS: + if y_above >= y_above_max - NUM_EPS: + break + else: + if y_above > y_above_max: + break + + # We know this intersects, + # so we could use a faster function now: + # ix = (self._current_event_point_x, y_above) + # ...however best use existing functions + # since it does all sanity checks on endpoints... etc. + self._check_intersection(event, e_above) + + # self.remove(event) + + +class EventQueue: + __slots__ = ( + # note: we only ever pop_min, this could use a 'heap' structure. + # The sorted map holding the points -> event list + # [Point: Event] (tree) + "events_scan", + ) + + def __init__(self, segments): + self.events_scan = RBTree() + # segments = [s for s in segments if s[0][0] != s[1][0] and s[0][1] != s[1][1]] + + for s in segments: + assert(s[0][X] <= s[1][X]) + + slope = slope_v2v2(*s) + + if s[0] == s[1]: + pass + elif USE_VERTICAL and (s[0][X] == s[1][X]): + e_start = Event(Event.Type.START_VERTICAL, s[0], s, slope) + + if USE_DEBUG: + e_start.other = e_start # FAKE, avoid error checking + + self.offer(s[0], e_start) + else: + e_start = Event(Event.Type.START, s[0], s, slope) + e_end = Event(Event.Type.END, s[1], s, slope) + + if USE_DEBUG: + e_start.other = e_end + e_end.other = e_start + + self.offer(s[0], e_start) + self.offer(s[1], e_end) + + def offer(self, p, e: Event): + """ + Offer a new event ``s`` at point ``p`` in this queue. + """ + existing = self.events_scan.setdefault( + p, ([], [], [], []) if USE_VERTICAL else + ([], [], []), + ) + # Can use double linked-list for easy insertion at beginning/end + ''' + if e.type == Event.Type.END: + existing.insert(0, e) + else: + existing.append(e) + ''' + + existing[e.type].append(e) + + # return a set of events + def poll(self): + """ + Get, and remove, the first (lowest) item from this queue. + + :return: the first (lowest) item from this queue. + :rtype: Point, Event pair. + """ + assert(len(self.events_scan) != 0) + p, events_current = self.events_scan.pop_min() + return p, events_current + + +def isect_segments_impl(segments, *, include_segments=False, validate=True) -> list: + # order points left -> right + if Real is float: + segments = [ + # in nearly all cases, comparing X is enough, + # but compare Y too for vertical lines + (s[0], s[1]) if (s[0] <= s[1]) else + (s[1], s[0]) + for s in segments] + else: + segments = [ + # in nearly all cases, comparing X is enough, + # but compare Y too for vertical lines + ( + (Real(s[0][0]), Real(s[0][1])), + (Real(s[1][0]), Real(s[1][1])), + ) if (s[0] <= s[1]) else + ( + (Real(s[1][0]), Real(s[1][1])), + (Real(s[0][0]), Real(s[0][1])), + ) + for s in segments] + + # Ensure segments don't have duplicates or single points, see: #24. + if validate: + segments_old = segments + segments = [] + visited = set() + for s in segments_old: + # Ignore points. + if s[0] == s[1]: + continue + # Ignore duplicates. + if s in visited: + continue + visited.add(s) + segments.append(s) + del segments_old + + queue = EventQueue(segments) + sweep_line = SweepLine(queue) + + while len(queue.events_scan) > 0: + if USE_VERBOSE: + inkex.utils.debug("event {}: x={}".format(len(queue.events_scan), sweep_line._current_event_point_x)) + p, e_ls = queue.poll() + for events_current in e_ls: + if events_current: + sweep_line._sweep_to(p) + sweep_line.handle(p, events_current) + + if include_segments is False: + return sweep_line.get_intersections() + else: + return sweep_line.get_intersections_with_segments() + + +def isect_polygon_impl(points, *, include_segments=False, validate=True) -> list: + n = len(points) + segments = [ + (tuple(points[i]), tuple(points[(i + 1) % n])) + for i in range(n) + ] + return isect_segments_impl(segments, include_segments=include_segments, validate=validate) + + +def isect_segments(segments, *, validate=True) -> list: + return isect_segments_impl(segments, include_segments=False, validate=validate) + + +def isect_polygon(segments, *, validate=True) -> list: + return isect_polygon_impl(segments, include_segments=False, validate=validate) + + +def isect_segments_include_segments(segments, *, validate=True) -> list: + return isect_segments_impl(segments, include_segments=True, validate=validate) + + +def isect_polygon_include_segments(segments, *, validate=True) -> list: + return isect_polygon_impl(segments, include_segments=True, validate=validate) + + +# ---------------------------------------------------------------------------- +# 2D math utilities + + +def slope_v2v2(p1, p2): + if p1[X] == p2[X]: + if p1[Y] < p2[Y]: + return NUM_INF + else: + return -NUM_INF + else: + return (p2[Y] - p1[Y]) / (p2[X] - p1[X]) + + +def sub_v2v2(a, b): + return ( + a[0] - b[0], + a[1] - b[1]) + + +def dot_v2v2(a, b): + return ( + (a[0] * b[0]) + + (a[1] * b[1])) + + +def len_squared_v2v2(a, b): + c = sub_v2v2(a, b) + return dot_v2v2(c, c) + + +def line_point_factor_v2(p, l1, l2, default=NUM_ZERO): + u = sub_v2v2(l2, l1) + h = sub_v2v2(p, l1) + dot = dot_v2v2(u, u) + return (dot_v2v2(u, h) / dot) if dot != NUM_ZERO else default + + +def isect_seg_seg_v2_point(v1, v2, v3, v4, bias=NUM_ZERO): + # Only for predictability and hashable point when same input is given + if v1 > v2: + v1, v2 = v2, v1 + if v3 > v4: + v3, v4 = v4, v3 + + if (v1, v2) > (v3, v4): + v1, v2, v3, v4 = v3, v4, v1, v2 + + div = (v2[0] - v1[0]) * (v4[1] - v3[1]) - (v2[1] - v1[1]) * (v4[0] - v3[0]) + if div == NUM_ZERO: + return None + + vi = (((v3[0] - v4[0]) * + (v1[0] * v2[1] - v1[1] * v2[0]) - (v1[0] - v2[0]) * + (v3[0] * v4[1] - v3[1] * v4[0])) / div, + ((v3[1] - v4[1]) * + (v1[0] * v2[1] - v1[1] * v2[0]) - (v1[1] - v2[1]) * + (v3[0] * v4[1] - v3[1] * v4[0])) / div, + ) + + fac = line_point_factor_v2(vi, v1, v2, default=-NUM_ONE) + if fac < NUM_ZERO - bias or fac > NUM_ONE + bias: + return None + + fac = line_point_factor_v2(vi, v3, v4, default=-NUM_ONE) + if fac < NUM_ZERO - bias or fac > NUM_ONE + bias: + return None + + # vi = round(vi[X], 8), round(vi[Y], 8) + return vi + + +# ---------------------------------------------------------------------------- +# Simple naive line intersect, (for testing only) + + +def isect_segments__naive(segments) -> list: + """ + Brute force O(n2) version of ``isect_segments`` for test validation. + """ + isect = [] + + # order points left -> right + if Real is float: + segments = [ + (s[0], s[1]) if s[0][X] <= s[1][X] else + (s[1], s[0]) + for s in segments] + else: + segments = [ + ( + (Real(s[0][0]), Real(s[0][1])), + (Real(s[1][0]), Real(s[1][1])), + ) if (s[0] <= s[1]) else + ( + (Real(s[1][0]), Real(s[1][1])), + (Real(s[0][0]), Real(s[0][1])), + ) + for s in segments] + + n = len(segments) + + for i in range(n): + a0, a1 = segments[i] + for j in range(i + 1, n): + b0, b1 = segments[j] + if a0 not in (b0, b1) and a1 not in (b0, b1): + ix = isect_seg_seg_v2_point(a0, a1, b0, b1) + if ix is not None: + # USE_IGNORE_SEGMENT_ENDINGS handled already + isect.append(ix) + + return isect + + +def isect_polygon__naive(points) -> list: + """ + Brute force O(n2) version of ``isect_polygon`` for test validation. + """ + isect = [] + + n = len(points) + + if Real is float: + pass + else: + points = [(Real(p[0]), Real(p[1])) for p in points] + + + for i in range(n): + a0, a1 = points[i], points[(i + 1) % n] + for j in range(i + 1, n): + b0, b1 = points[j], points[(j + 1) % n] + if a0 not in (b0, b1) and a1 not in (b0, b1): + ix = isect_seg_seg_v2_point(a0, a1, b0, b1) + if ix is not None: + + if USE_IGNORE_SEGMENT_ENDINGS: + if ((len_squared_v2v2(ix, a0) < NUM_EPS_SQ or + len_squared_v2v2(ix, a1) < NUM_EPS_SQ) and + (len_squared_v2v2(ix, b0) < NUM_EPS_SQ or + len_squared_v2v2(ix, b1) < NUM_EPS_SQ)): + continue + + isect.append(ix) + + return isect + + +# ---------------------------------------------------------------------------- +# Inline Libs +# +# bintrees: 2.0.2, extracted from: +# http://pypi.python.org/pypi/bintrees +# +# - Removed unused functions, such as slicing and range iteration. +# - Added 'cmp' and and 'cmp_data' arguments, +# so we can define our own comparison that takes an arg. +# Needed for sweep-line. +# - Added support for 'default' arguments for prev_item/succ_item, +# so we can avoid exception handling. + +# ------- +# ABCTree + +from operator import attrgetter +_sentinel = object() + + +class _ABCTree(object): + def __init__(self, cmp=None, cmp_data=None): + """T.__init__(...) initializes T; see T.__class__.__doc__ for signature""" + self._root = None + self._count = 0 + if cmp is None: + def cmp(cmp_data, a, b): + if a < b: + return -1 + elif a > b: + return 1 + else: + return 0 + self._cmp = cmp + self._cmp_data = cmp_data + + def clear(self): + """T.clear() -> None. Remove all items from T.""" + def _clear(node): + if node is not None: + _clear(node.left) + _clear(node.right) + node.free() + _clear(self._root) + self._count = 0 + self._root = None + + @property + def count(self): + """Get items count.""" + return self._count + + def _get_value_or_sentinel(self, key): + node = self._root + while node is not None: + cmp = self._cmp(self._cmp_data, key, node.key) + if cmp == 0: + return node.value + elif cmp < 0: + node = node.left + else: + node = node.right + return _sentinel + + def get_value(self, key): + value = self._get_value_or_sentinel(key) + if value is _sentinel: + raise KeyError(str(key)) + return value + + def pop_item(self): + """T.pop_item() -> (k, v), remove and return some (key, value) pair as a + 2-tuple; but raise KeyError if T is empty. + """ + if self.is_empty(): + raise KeyError("pop_item(): tree is empty") + node = self._root + while True: + if node.left is not None: + node = node.left + elif node.right is not None: + node = node.right + else: + break + key = node.key + value = node.value + self.remove(key) + return key, value + popitem = pop_item # for compatibility to dict() + + def min_item(self): + """Get item with min key of tree, raises ValueError if tree is empty.""" + if self.is_empty(): + raise ValueError("Tree is empty") + node = self._root + while node.left is not None: + node = node.left + return node.key, node.value + + def max_item(self): + """Get item with max key of tree, raises ValueError if tree is empty.""" + if self.is_empty(): + raise ValueError("Tree is empty") + node = self._root + while node.right is not None: + node = node.right + return node.key, node.value + + def succ_item(self, key, default=_sentinel): + """Get successor (k,v) pair of key, raises KeyError if key is max key + or key does not exist. optimized for pypy. + """ + # removed graingets version, because it was little slower on CPython and much slower on pypy + # this version runs about 4x faster with pypy than the Cython version + # Note: Code sharing of succ_item() and ceiling_item() is possible, but has always a speed penalty. + node = self._root + succ_node = None + while node is not None: + cmp = self._cmp(self._cmp_data, key, node.key) + if cmp == 0: + break + elif cmp < 0: + if (succ_node is None) or self._cmp(self._cmp_data, node.key, succ_node.key) < 0: + succ_node = node + node = node.left + else: + node = node.right + + if node is None: # stay at dead end + if default is _sentinel: + raise KeyError(str(key)) + return default + # found node of key + if node.right is not None: + # find smallest node of right subtree + node = node.right + while node.left is not None: + node = node.left + if succ_node is None: + succ_node = node + elif self._cmp(self._cmp_data, node.key, succ_node.key) < 0: + succ_node = node + elif succ_node is None: # given key is biggest in tree + if default is _sentinel: + raise KeyError(str(key)) + return default + return succ_node.key, succ_node.value + + def prev_item(self, key, default=_sentinel): + """Get predecessor (k,v) pair of key, raises KeyError if key is min key + or key does not exist. optimized for pypy. + """ + # removed graingets version, because it was little slower on CPython and much slower on pypy + # this version runs about 4x faster with pypy than the Cython version + # Note: Code sharing of prev_item() and floor_item() is possible, but has always a speed penalty. + node = self._root + prev_node = None + + while node is not None: + cmp = self._cmp(self._cmp_data, key, node.key) + if cmp == 0: + break + elif cmp < 0: + node = node.left + else: + if (prev_node is None) or self._cmp(self._cmp_data, prev_node.key, node.key) < 0: + prev_node = node + node = node.right + + if node is None: # stay at dead end (None) + if default is _sentinel: + raise KeyError(str(key)) + return default + # found node of key + if node.left is not None: + # find biggest node of left subtree + node = node.left + while node.right is not None: + node = node.right + if prev_node is None: + prev_node = node + elif self._cmp(self._cmp_data, prev_node.key, node.key) < 0: + prev_node = node + elif prev_node is None: # given key is smallest in tree + if default is _sentinel: + raise KeyError(str(key)) + return default + return prev_node.key, prev_node.value + + def __repr__(self): + """T.__repr__(...) <==> repr(x)""" + tpl = "%s({%s})" % (self.__class__.__name__, '%s') + return tpl % ", ".join(("%r: %r" % item for item in self.items())) + + def __contains__(self, key): + """k in T -> True if T has a key k, else False""" + return self._get_value_or_sentinel(key) is not _sentinel + + def __len__(self): + """T.__len__() <==> len(x)""" + return self.count + + def is_empty(self): + """T.is_empty() -> False if T contains any items else True""" + return self.count == 0 + + def set_default(self, key, default=None): + """T.set_default(k[,d]) -> T.get(k,d), also set T[k]=d if k not in T""" + value = self._get_value_or_sentinel(key) + if value is _sentinel: + self.insert(key, default) + return default + return value + setdefault = set_default # for compatibility to dict() + + def get(self, key, default=None): + """T.get(k[,d]) -> T[k] if k in T, else d. d defaults to None.""" + + value = self._get_value_or_sentinel(key) + if value is _sentinel: + return default + return value + + def pop(self, key, *args): + """T.pop(k[,d]) -> v, remove specified key and return the corresponding value. + If key is not found, d is returned if given, otherwise KeyError is raised + """ + if len(args) > 1: + raise TypeError("pop expected at most 2 arguments, got %d" % (1 + len(args))) + + value = self._get_value_or_sentinel(key) + if value is _sentinel: + if len(args) == 0: + raise KeyError(str(key)) + return args[0] + + self.remove(key) + return value + + def prev_key(self, key, default=_sentinel): + """Get predecessor to key, raises KeyError if key is min key + or key does not exist. + """ + item = self.prev_item(key, default) + return default if item is default else item[0] + + def succ_key(self, key, default=_sentinel): + """Get successor to key, raises KeyError if key is max key + or key does not exist. + """ + item = self.succ_item(key, default) + return default if item is default else item[0] + + def pop_min(self): + """T.pop_min() -> (k, v), remove item with minimum key, raise ValueError + if T is empty. + """ + item = self.min_item() + self.remove(item[0]) + return item + + def pop_max(self): + """T.pop_max() -> (k, v), remove item with maximum key, raise ValueError + if T is empty. + """ + item = self.max_item() + self.remove(item[0]) + return item + + def min_key(self): + """Get min key of tree, raises ValueError if tree is empty. """ + return self.min_item()[0] + + def max_key(self): + """Get max key of tree, raises ValueError if tree is empty. """ + return self.max_item()[0] + + def key_slice(self, start_key, end_key, reverse=False): + """T.key_slice(start_key, end_key) -> key iterator: + start_key <= key < end_key. + + Yields keys in ascending order if reverse is False else in descending order. + """ + return (k for k, v in self.iter_items(start_key, end_key, reverse=reverse)) + + def iter_items(self, start_key=None, end_key=None, reverse=False): + """Iterates over the (key, value) items of the associated tree, + in ascending order if reverse is True, iterate in descending order, + reverse defaults to False""" + # optimized iterator (reduced method calls) - faster on CPython but slower on pypy + + if self.is_empty(): + return [] + if reverse: + return self._iter_items_backward(start_key, end_key) + else: + return self._iter_items_forward(start_key, end_key) + + def _iter_items_forward(self, start_key=None, end_key=None): + for item in self._iter_items(left=attrgetter("left"), right=attrgetter("right"), + start_key=start_key, end_key=end_key): + yield item + + def _iter_items_backward(self, start_key=None, end_key=None): + for item in self._iter_items(left=attrgetter("right"), right=attrgetter("left"), + start_key=start_key, end_key=end_key): + yield item + + def _iter_items(self, left=attrgetter("left"), right=attrgetter("right"), start_key=None, end_key=None): + node = self._root + stack = [] + go_left = True + in_range = self._get_in_range_func(start_key, end_key) + + while True: + if left(node) is not None and go_left: + stack.append(node) + node = left(node) + else: + if in_range(node.key): + yield node.key, node.value + if right(node) is not None: + node = right(node) + go_left = True + else: + if not len(stack): + return # all done + node = stack.pop() + go_left = False + + def _get_in_range_func(self, start_key, end_key): + if start_key is None and end_key is None: + return lambda x: True + else: + if start_key is None: + start_key = self.min_key() + if end_key is None: + return (lambda x: self._cmp(self._cmp_data, start_key, x) <= 0) + else: + return (lambda x: self._cmp(self._cmp_data, start_key, x) <= 0 and + self._cmp(self._cmp_data, x, end_key) < 0) + + +# ------ +# RBTree + +class Node(object): + """Internal object, represents a tree node.""" + __slots__ = ['key', 'value', 'red', 'left', 'right'] + + def __init__(self, key=None, value=None): + self.key = key + self.value = value + self.red = True + self.left = None + self.right = None + + def free(self): + self.left = None + self.right = None + self.key = None + self.value = None + + def __getitem__(self, key): + """N.__getitem__(key) <==> x[key], where key is 0 (left) or 1 (right).""" + return self.left if key == 0 else self.right + + def __setitem__(self, key, value): + """N.__setitem__(key, value) <==> x[key]=value, where key is 0 (left) or 1 (right).""" + if key == 0: + self.left = value + else: + self.right = value + + +class RBTree(_ABCTree): + """ + RBTree implements a balanced binary tree with a dict-like interface. + + see: http://en.wikipedia.org/wiki/Red_black_tree + """ + @staticmethod + def is_red(node): + if (node is not None) and node.red: + return True + else: + return False + + @staticmethod + def jsw_single(root, direction): + other_side = 1 - direction + save = root[other_side] + root[other_side] = save[direction] + save[direction] = root + root.red = True + save.red = False + return save + + @staticmethod + def jsw_double(root, direction): + other_side = 1 - direction + root[other_side] = RBTree.jsw_single(root[other_side], other_side) + return RBTree.jsw_single(root, direction) + + def _new_node(self, key, value): + """Create a new tree node.""" + self._count += 1 + return Node(key, value) + + def insert(self, key, value): + """T.insert(key, value) <==> T[key] = value, insert key, value into tree.""" + if self._root is None: # Empty tree case + self._root = self._new_node(key, value) + self._root.red = False # make root black + return + + head = Node() # False tree root + grand_parent = None + grand_grand_parent = head + parent = None # parent + direction = 0 + last = 0 + + # Set up helpers + grand_grand_parent.right = self._root + node = grand_grand_parent.right + # Search down the tree + while True: + if node is None: # Insert new node at the bottom + node = self._new_node(key, value) + parent[direction] = node + elif RBTree.is_red(node.left) and RBTree.is_red(node.right): # Color flip + node.red = True + node.left.red = False + node.right.red = False + + # Fix red violation + if RBTree.is_red(node) and RBTree.is_red(parent): + direction2 = 1 if grand_grand_parent.right is grand_parent else 0 + if node is parent[last]: + grand_grand_parent[direction2] = RBTree.jsw_single(grand_parent, 1 - last) + else: + grand_grand_parent[direction2] = RBTree.jsw_double(grand_parent, 1 - last) + + # Stop if found + if self._cmp(self._cmp_data, key, node.key) == 0: + node.value = value # set new value for key + break + + last = direction + direction = 0 if (self._cmp(self._cmp_data, key, node.key) < 0) else 1 + # Update helpers + if grand_parent is not None: + grand_grand_parent = grand_parent + grand_parent = parent + parent = node + node = node[direction] + + self._root = head.right # Update root + self._root.red = False # make root black + + def remove(self, key): + """T.remove(key) <==> del T[key], remove item from tree.""" + if self._root is None: + raise KeyError(str(key)) + head = Node() # False tree root + node = head + node.right = self._root + parent = None + grand_parent = None + found = None # Found item + direction = 1 + + # Search and push a red down + while node[direction] is not None: + last = direction + + # Update helpers + grand_parent = parent + parent = node + node = node[direction] + + direction = 1 if (self._cmp(self._cmp_data, node.key, key) < 0) else 0 + + # Save found node + if self._cmp(self._cmp_data, key, node.key) == 0: + found = node + + # Push the red node down + if not RBTree.is_red(node) and not RBTree.is_red(node[direction]): + if RBTree.is_red(node[1 - direction]): + parent[last] = RBTree.jsw_single(node, direction) + parent = parent[last] + elif not RBTree.is_red(node[1 - direction]): + sibling = parent[1 - last] + if sibling is not None: + if (not RBTree.is_red(sibling[1 - last])) and (not RBTree.is_red(sibling[last])): + # Color flip + parent.red = False + sibling.red = True + node.red = True + else: + direction2 = 1 if grand_parent.right is parent else 0 + if RBTree.is_red(sibling[last]): + grand_parent[direction2] = RBTree.jsw_double(parent, last) + elif RBTree.is_red(sibling[1-last]): + grand_parent[direction2] = RBTree.jsw_single(parent, last) + # Ensure correct coloring + grand_parent[direction2].red = True + node.red = True + grand_parent[direction2].left.red = False + grand_parent[direction2].right.red = False + + # Replace and remove if found + if found is not None: + found.key = node.key + found.value = node.value + parent[int(parent.right is node)] = node[int(node.left is None)] + node.free() + self._count -= 1 + + # Update root and make it black + self._root = head.right + if self._root is not None: + self._root.red = False + if not found: + raise KeyError(str(key)) diff --git a/extensions/fablabchemnitz/inkpacking/inkpacking.py b/extensions/fablabchemnitz/inkpacking/inkpacking.py index 3698dad..3182fc1 100644 --- a/extensions/fablabchemnitz/inkpacking/inkpacking.py +++ b/extensions/fablabchemnitz/inkpacking/inkpacking.py @@ -114,8 +114,8 @@ class inkpacking(inkex.EffectExtension): box_id = self.svg.get_unique_id('box') self.box = g = etree.SubElement(self.svg.get_current_layer(), 'g', {'id':box_id}) - cut_line_style = str(inkex.Style(({ 'stroke': '#000000', 'fill': 'none', 'stroke-width':'0.3px' }))) - engrave_line_style = str(inkex.Style(({ 'stroke': '#ff0000', 'fill': 'none', 'stroke-width':'0.3px' }))) + cut_line_style = str(inkex.Style(({ 'stroke': '#000000', 'fill': 'none', 'stroke-width':'1px' }))) + engrave_line_style = str(inkex.Style(({ 'stroke': '#ff0000', 'fill': 'none', 'stroke-width':'1px' }))) gflapoffy = (gflapsize / sin( (gflapangle / 360) * 6.28 )) * sin( ((90 - gflapangle) / 360 ) * 6.28)