#!/usr/bin/env python3 """ Extension for InkScape 1.0 Features - helps to find contours which are closed or not. Good for repairing contours, closing contours,... - works for paths which are packed into groups or groups of groups. # - can break contours apart like in "Path -> Break Apart" - implements Bentley-Ottmann algorithm from https://github.com/ideasman42/isect_segments-bentley_ottmann to scan for self-intersecting paths. You might get "assert(event.in_sweep == False) AssertionError". Don't know how to fix rgis - colorized paths respective to their type - can add dots to intersection points you'd like to fix Author: Mario Voigt / FabLab Chemnitz Mail: mario.voigt@stadtfabrikanten.org Date: 09.08.2020 Last patch: 19.05.2021 License: GNU GPL v3 ToDo: - add option to replace last segment of closed paths by 'Z' or 'z' in case the first and last segment touch each other (coincident point) """ import sys from math import * from lxml import etree import poly_point_isect import copy import inkex from inkex.paths import Path, CubicSuperPath from inkex import Style, Color, Circle class ContourScanner(inkex.EffectExtension): def add_arguments(self, pars): pars.add_argument("--main_tabs") pars.add_argument("--breakapart", type=inkex.Boolean, default=False, help="Break apart selection into single contours") pars.add_argument("--removefillsetstroke", type=inkex.Boolean, default=False, help="Remove fill and define stroke") pars.add_argument("--strokewidth", type=float, default=1.0, help="Stroke width (px)") pars.add_argument("--highlight_opened", type=inkex.Boolean, default=True, help="Highlight opened contours") pars.add_argument("--color_opened", type=Color, default='4012452351', help="Color opened contours") pars.add_argument("--highlight_closed", type=inkex.Boolean, default=True, help="Highlight closed contours") pars.add_argument("--color_closed", type=Color, default='2330080511', help="Color closed contours") pars.add_argument("--highlight_selfintersecting", type=inkex.Boolean, default=True, help="Highlight self-intersecting contours") pars.add_argument("--highlight_intersectionpoints", type=inkex.Boolean, default=True, help="Highlight self-intersecting points") pars.add_argument("--color_selfintersecting", type=Color, default='1923076095', help="Color closed contours") pars.add_argument("--color_intersectionpoints", type=Color, default='4239343359', help="self-intersecting points") pars.add_argument("--addlines", type=inkex.Boolean, default=True, help="Add closing lines for self-crossing contours") pars.add_argument("--polypaths", type=inkex.Boolean, default=True, help="Add polypath outline for self-crossing contours") pars.add_argument("--dotsize", type=int, default=10, help="Dot size (px) for self-intersecting points") pars.add_argument("--remove_opened", type=inkex.Boolean, default=False, help="Remove opened contours") pars.add_argument("--remove_closed", type=inkex.Boolean, default=False, help="Remove closed contours") pars.add_argument("--remove_selfintersecting", type=inkex.Boolean, default=False, help="Remove self-intersecting contours") pars.add_argument("--show_debug", type=inkex.Boolean, default=False, help="Show debug info") #function to refine the style of the lines def adjustStyle(self, node): if node.attrib.has_key('style'): style = node.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' node.set('style', ';'.join(declarations) + ';stroke:#000000;stroke-opacity:1.0') else: node.set('style', 'stroke:#000000;stroke-opacity:1.0') #get polyline from path def getPolyline(self, node): if node.tag == inkex.addNS('path','svg'): polypath = [] i = 0 for x, y in node.path.end_points: if i == 0: polypath.append(['M', [x,y]]) else: polypath.append(['L', [x,y]]) if i == 1 and polypath[len(polypath)-2][1] == polypath[len(polypath)-1][1]: polypath.pop(len(polypath)-1) #special handling for the second point after M command elif polypath[len(polypath)-2] == polypath[len(polypath)-1]: #get the previous point polypath.pop(len(polypath)-1) i += 1 return Path(polypath) #split combined contours into single contours if enabled - this is exactly the same as "Path -> Break Apart" replacedNodes = [] def breakContours(self, node): #this does the same as "CTRL + SHIFT + K" if node.tag == inkex.addNS('path','svg'): parent = node.getparent() idx = parent.index(node) idSuffix = 0 #raw = Path(node.get("d")).to_arrays() #raw = node.path.transform(node.composed_transform()).to_superpath() raw = node.path.transform(node.composed_transform()).to_arrays() 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:]) for subpath in subPaths: replacedNode = copy.copy(node) oldId = replacedNode.get('id') replacedNode.set('d', CubicSuperPath(subpath)) replacedNode.set('id', oldId + str(idSuffix).zfill(5)) parent.insert(idx, replacedNode) idSuffix += 1 self.replacedNodes.append(replacedNode) node.delete() for child in node: self.breakContours(child) def scanContours(self, node): if node.tag == inkex.addNS('path','svg'): if self.options.removefillsetstroke: self.adjustStyle(node) intersectionGroup = node.getparent().add(inkex.Group()) #raw = (Path(node.get('d')).to_arrays()) raw = node.path.transform(node.composed_transform()).to_arrays() 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:]) for simpath in subPaths: closed = False if simpath[-1][0] == 'Z' or \ (simpath[-1][0] == 'L' and simpath[0][1] == simpath[-1][1]) or \ (simpath[-1][0] == 'C' and simpath[0][1] == [simpath[-1][1][-2], simpath[-1][1][-1]]) : #if first is last point the path is also closed. The "Z" command is not required closed = True if simpath[-2][0] == 'L': simpath[-1][1] = simpath[0][1] else: simpath.pop() points = [] for i in range(len(simpath)): if simpath[i][0] == 'V': # vertical and horizontal lines only have one point in args, but 2 are required simpath[i][0]='L' #overwrite V with regular L command add=simpath[i-1][1][0] #read the X value from previous segment simpath[i][1].append(simpath[i][1][0]) #add the second (missing) argument by taking argument from previous segment simpath[i][1][0]=add #replace with recent X after Y was appended if simpath[i][0] == 'H': # vertical and horizontal lines only have one point in args, but 2 are required simpath[i][0]='L' #overwrite H with regular L command simpath[i][1].append(simpath[i-1][1][1]) #add the second (missing) argument by taking argument from previous segment points.append(simpath[i][1][-2:]) if points[0] == points[-1]: #if first is last point the path is also closed. The "Z" command is not required closed = True if closed == False: if self.options.highlight_opened: style = {'stroke-linejoin': 'miter', 'stroke-width': str(self.svg.unittouu(str(self.options.strokewidth) +"px")), 'stroke-opacity': '1.0', 'fill-opacity': '1.0', 'stroke': self.options.color_opened, 'stroke-linecap': 'butt', 'fill': 'none'} node.attrib['style'] = Style(style).to_str() if self.options.remove_opened: try: node.delete() except AttributeError: pass #we ignore that parent can be None if closed == True: if self.options.highlight_closed: style = {'stroke-linejoin': 'miter', 'stroke-width': str(self.svg.unittouu(str(self.options.strokewidth) +"px")), 'stroke-opacity': '1.0', 'fill-opacity': '1.0', 'stroke': self.options.color_closed, 'stroke-linecap': 'butt', 'fill': 'none'} node.attrib['style'] = Style(style).to_str() if self.options.remove_closed: try: node.delete() except AttributeError: pass #we ignore that parent can be None #if one of the options is activated we also check for self-intersecting if self.options.highlight_selfintersecting or self.options.highlight_intersectionpoints: #Style definitions closingLineStyle = Style({'stroke-linejoin': 'miter', 'stroke-width': str(self.svg.unittouu(str(self.options.strokewidth) +"px")), 'stroke-opacity': '1.0', 'fill-opacity': '1.0', 'stroke': self.options.color_intersectionpoints, 'stroke-linecap': 'butt', 'fill': 'none'}).to_str() intersectionPointStyle = Style({'stroke': 'none', 'fill': self.options.color_intersectionpoints}).to_str() intersectionStyle = Style({'stroke-linejoin': 'miter', 'stroke-width': str(self.svg.unittouu(str(self.options.strokewidth) +"px")), 'stroke-opacity': '1.0', 'fill-opacity': '1.0', 'stroke': self.options.color_selfintersecting, 'stroke-linecap': 'butt', 'fill': 'none'}).to_str() try: if len(points) > 2: #try to find self-intersecting /overlapping polygons. We need at least 3 points to detect for intersections (only possible if first points matched last point) isect = poly_point_isect.isect_polygon(points, validate=True) if len(isect) > 0: if closed == False and self.options.addlines == True: #if contour is open and we found intersection points those points might be not relevant closingLine = intersectionGroup.add(inkex.PathElement()) closingLine.set('id', self.svg.get_unique_id('closingline-')) closingLine.path = [ ['M', [points[0][0],points[0][1]]], ['L', [points[-1][0],points[-1][1]]], ['Z', []] ] closingLine.attrib['style'] = closingLineStyle #draw polylines if option is enabled if self.options.polypaths == True: polyNode = intersectionGroup.add(inkex.PathElement()) polyNode.set('id', self.svg.get_unique_id('polypath-')) polyNode.set('d', str(self.getPolyline(node))) polyNode.attrib['style'] = closingLineStyle #make dot markings at the intersection points if self.options.highlight_intersectionpoints: for xy in isect: #Add a dot label for this path element intersectionPoint = intersectionGroup.add(Circle(cx=str(xy[0]), cy=str(xy[1]), r=str(self.svg.unittouu(str(self.options.dotsize/2) + "px")))) intersectionPoint.set('id', self.svg.get_unique_id('intersectionpoint-')) intersectionPoint.style = intersectionPointStyle if self.options.highlight_selfintersecting: node.attrib['style'] = intersectionStyle if self.options.remove_selfintersecting: if node.getparent() is not None: #might be already been deleted by previously checked settings so check again node.delete() #draw intersections segment lines - useless at the moment. We could use this information to cut the original polyline to get a new curve path which included the intersection points #isectSegs = poly_point_isect.isect_polygon_include_segments(points) #for seg in isectSegs: # isectSegsPath = [] # isecX = seg[0][0] #the intersection point - X # isecY = seg[0][1] #the intersection point - Y # isecSeg1X = seg[1][0][0][0] #the first intersection point segment - X # isecSeg1Y = seg[1][0][0][1] #the first intersection point segment - Y # isecSeg2X = seg[1][1][0][0] #the second intersection point segment - X # isecSeg2Y = seg[1][1][0][1] #the second intersection point segment - Y # isectSegsPath.append(['L', [isecSeg2X, isecSeg2Y]]) # isectSegsPath.append(['L', [isecX, isecY]]) # isectSegsPath.append(['L', [isecSeg1X, isecSeg1Y]]) # #fix the really first point. Has to be an 'M' command instead of 'L' # isectSegsPath[0][0] = 'M' # polySegsNode = intersectionGroup.add(inkex.PathElement()) # polySegsNode.set('id', self.svg.get_unique_id('intersectsegments-')) # polySegsNode.set('d', str(Path(isectSegsPath))) # polySegsNode.attrib['style'] = closingLineStyle except AssertionError as e: # we skip AssertionError if self.options.show_debug is True: inkex.utils.debug("AssertionError at " + node.get('id')) continue except IndexError as i: # we skip IndexError if self.options.show_debug is True: inkex.utils.debug("IndexError at " + node.get('id')) continue #if the intersectionGroup was created but nothing attached we delete it again to prevent messing the SVG XML tree if len(intersectionGroup.getchildren()) == 0: intersectionGroupParent = intersectionGroup.getparent() if intersectionGroupParent is not None: intersectionGroup.delete() #put the node into the intersectionGroup to bundle the path with it's error markers. If removal is selected we need to avoid intersectionGroup.insert(), because it will break the removal elif self.options.remove_selfintersecting == False: intersectionGroup.insert(0, node) children = node.getchildren() if children is not None: for child in children: self.scanContours(child) def effect(self): if self.options.breakapart is True: if len(self.svg.selected) == 0: self.breakContours(self.document.getroot()) self.scanContours(self.document.getroot()) else: newContourSet = [] for element in self.svg.selected.values(): self.breakContours(element) for newContours in self.replacedNodes: self.scanContours(newContours) else: if len(self.svg.selected) == 0: self.scanContours(self.document.getroot()) else: for element in self.svg.selected.values(): self.scanContours(element) if __name__ == '__main__': ContourScanner().run()