#!/usr/bin/env python3 # # Copyright (C) 2020 Ellen Wasboe, ellen@wasbo.net # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. """ Remove duplicate nodes or interpolate nodes with distance less than specified. Optionally join start node with end node of each subpath if distance less than specified = close the subpath Optionally join separate subpaths if end nodes closer than a specified distance. Joining subpaths can be done either by interpolating or straight line segment. """ import inkex from inkex import bezier, PathElement, CubicSuperPath import numpy as np def joinTest(xdiff,ydiff,limDist,idsIncluded): joinFlag=False idJoin=-1 dist=np.sqrt(np.add(np.power(xdiff,2),np.power(ydiff,2))) minDist=np.amin(dist) if minDist < limDist: joinFlag=True idMins=np.where(dist==minDist) idMin=idMins[0] idJoin=idsIncluded[idMin[0]] return [joinFlag,idJoin] def revSub(subPath): subPath=subPath[::-1] for i, s in enumerate(subPath): subPath[i]=s[::-1] return subPath def joinSub(sub1,sub2, interpOrLine): if interpOrLine == "1": #interpolate end nodes p1=sub1[-1][-1] p2=sub2[0][0] joinNode=[0.5*(p1[0]+p2[0]),0.5*(p1[1]+p2[1])] #remove end/start + input join sub1[-1][1]=joinNode sub1[-1][2]=sub2[0][2] sub2.pop(0) newsub=sub1+sub2 return newsub class PurgeDuplicatePathNodes(inkex.EffectExtension): def add_arguments(self, pars): pars.add_argument("--tab", default="options") pars.add_argument("--minlength", default="0") pars.add_argument("--minUse", type=inkex.Boolean, default=False) pars.add_argument("--maxdist", default="0") pars.add_argument("--joinEnd", type=inkex.Boolean, default=False) pars.add_argument("--maxdist2", default="0") pars.add_argument("--joinEndSub", type=inkex.Boolean, default=False) pars.add_argument("--allowReverse", type=inkex.Boolean, default=True) pars.add_argument("--optionJoin", default="1") """Remove duplicate nodes""" def effect(self): if not self.svg.selected: raise inkex.AbortExtension("Please select an object.") for id, elem in self.svg.selection.id_dict().items(): minlength=float(self.options.minlength) maxdist=float(self.options.maxdist) maxdist2=float(self.options.maxdist2) if self.options.minUse == False: minlength=0 if self.options.joinEnd == False: maxdist=-1 if self.options.joinEndSub == False: maxdist2=-1 pp=elem.path.to_absolute() #register which subpaths are closed - to reset closing after missed in to_superpath dList=str(pp).upper().split(' M') closed=[] l=0 for sub in dList: if dList[l].find("Z") > -1: closed.append(" Z ") else: closed.append("") l+=1 new = [] nSub=len(closed) xStart=np.zeros(nSub)#x start - prepare for joining subpaths yStart=np.copy(xStart) xEnd=np.copy(xStart) yEnd=np.copy(xStart) s=0 for sub in pp.to_superpath(): new.append([sub[0]]) if maxdist2 > -1: xStart[s]=sub[0][0][0] yStart[s]=sub[0][0][1] xEnd[s]=sub[-1][-1][0] yEnd[s]=sub[-1][-1][1] #remove segment if segment length is less than minimum set, keep position i=1 lastCombined=False while i <= len(sub) - 1: length = bezier.cspseglength(new[-1][-1], sub[i]) #curve length if length >= minlength: new[-1].append(sub[i])#add as is lastCombined=False else: #keep including segments until total length > minlength summedlength=length proceed=True e=0 #extra segments finishedAdding=False while proceed and i+e +1 <= len(sub) -1: nextlength=bezier.cspseglength(sub[i+e], sub[i+e+1]) #curve length if nextlength >= minlength: #don't include the next segment proceed=False if lastCombined == False and i>1: #i.e. this is a small group between long segments then average over the group, first node already added (new -1) new[-1][-1][1][0]= 0.5*(new[-1][-1][1][0]+sub[i+e][1][0])#change position to average new[-1][-1][1][1]= 0.5*(new[-1][-1][1][1]+sub[i+e][1][1]) new[-1][-1][2]=sub[i+e][2]#change last controlpoint to that of the last node in group finishedAdding=True else: #end of group with many segments - average over all but last node (which is added separately) new[-1].append(sub[i])#add as is new[-1][-1][1][0]= 0.5*(new[-1][-1][1][0]+sub[i+e-1][1][0])#change position to average first/last new[-1][-1][1][1]= 0.5*(new[-1][-1][1][1]+sub[i+e-1][1][1]) new[-1][-1][2]=sub[i+e-1][2]#change last controlpoint to that of the last node in group new[-1].append(sub[i+e])#add as is finishedAdding=True lastCombined=True else: summedlength=summedlength+nextlength if summedlength >= minlength: proceed=False e=e+1 if finishedAdding == False: if i == 1:# if first segment keep position of first node, direction of last in group new[-1][-1][2][0]= sub[i+e][2][0] new[-1][-1][2][1]= sub[i+e][2][1] elif i + e == len(sub)-1:#if last segment included keep position of last node, direction of previous new[-1].append(sub[i])#add first node in group if e > 0 : new[-1].append(sub[i+e])#add last node new[-1][-1][0]= sub[i+1][0]#get first controlpoint from i+1 else: #average position over first/last in group and keep direction (controlpoint) of first/last node #group within sequence of many close nodes - add new without averaging on previous new[-1].append(sub[i])#add first node in group new[-1][-1][1][0]= 0.5*(new[-1][-1][1][0]+sub[i+e][1][0])#change position to average new[-1][-1][1][1]= 0.5*(new[-1][-1][1][1]+sub[i+e][1][1]) new[-1][-1][2]=sub[i+e][2]#change last controlpoint to that of the last node in group i=i+e i += 1 if closed[s]==" Z ": #if new[-1][-1][1]==new[-1][-2][1]:#not always precise new[-1].pop(-1)#for some reason tosuperpath adds an extra node for closed paths #close each subpath where start/end node is closer than maxdist set (if not already closed) if maxdist > -1: if closed[s] == "": #ignore already closed paths #calculate distance between first and last node, if <= maxdist set closed[i] to " Z " #last=new[-1][-1] length = bezier.cspseglength(new[-1][-1], sub[0]) if length < maxdist: newStartEnd=[0.5*(new[-1][-1][-1][0]+new[-1][0][0][0]),0.5*(new[-1][-1][-1][1]+new[-1][0][0][1])] new[-1][0][0]=newStartEnd new[-1][0][1]=newStartEnd new[-1][-1][1]=newStartEnd new[-1][-1][2]=newStartEnd closed[s]=" Z " s+=1 #join different subpaths? closed=np.array(closed) openPaths=np.where(closed=='') closedPaths=np.where(closed==' Z ') if maxdist2 > -1 and openPaths[0].size > 1: #calculate distance between end nodes of the subpaths. If distance < maxdist2 found - join joinStartToEnd=np.ones(nSub, dtype=bool) joinEndToStart=np.copy(joinStartToEnd) joinEndTo=np.full(nSub,-1) joinEndTo[closedPaths]=2*maxdist2#set higher than maxdist2 to avoid join to closedPaths joinStartTo=np.copy(joinEndTo) #join end node of current subpath to startnode of any other or start node of current to end node of other (no reverse) s=0 while s < nSub: #end of current to start of other if joinEndTo[s]==-1: idsTest=np.where(joinStartTo==-1)#find available start nodes id2Test=np.delete(idsTest[0],np.where(idsTest[0] == s))#avoid join to self if id2Test.size > 0: diff_x=np.subtract(xStart[id2Test],xEnd[s])#calculate distances in x direction diff_y=np.subtract(yStart[id2Test],yEnd[s])#calculate distances in y direction res=joinTest(diff_x,diff_y,maxdist2,id2Test)#find shortest distance if less than minimum if res[0] == True:#if match found flag end of this with id of other and flag start of match to end of this joinEndTo[s]=res[1] joinStartTo[res[1]]=s #start of current to end of other if joinStartTo[s]==-1: idsTest=np.where(joinEndTo==-1) id2Test=np.delete(idsTest[0],np.where(idsTest[0] == s)) if id2Test.size > 0: diff_x=np.subtract(xEnd[id2Test],xStart[s]) diff_y=np.subtract(yEnd[id2Test],yStart[s]) res=joinTest(diff_x,diff_y,maxdist2,id2Test) if res[0] == True: joinStartTo[s]=res[1] joinEndTo[res[1]]=s if self.options.allowReverse==True: #start to start - if match reverse (reverseSub[s]=True) if joinStartTo[s]==-1: idsTest=np.where(joinStartTo==-1) id2Test=np.delete(idsTest[0],np.where(idsTest[0] == s)) if id2Test.size > 0: diff_x=np.subtract(xStart[id2Test],xStart[s]) diff_y=np.subtract(yStart[id2Test],yStart[s]) res=joinTest(diff_x,diff_y,maxdist2,id2Test) if res[0] == True: jID=res[1] joinStartTo[s]=jID joinStartTo[jID]=s joinStartToEnd[s]=False #false means reverse joinStartToEnd[jID]=False #end to end if joinEndTo[s]==-1: idsTest=np.where(joinEndTo==-1) id2Test=np.delete(idsTest[0],np.where(idsTest[0] == s)) if id2Test.size > 0: diff_x=np.subtract(xEnd[id2Test],xEnd[s]) diff_y=np.subtract(yEnd[id2Test],yEnd[s]) res=joinTest(diff_x,diff_y,maxdist2,id2Test) if res[0] == True: jID=res[1] joinEndTo[s]=jID joinEndTo[jID]=s joinEndToStart[s]=False joinEndToStart[jID]=False s+=1 old=new new=[] s=0 movedTo=np.arange(nSub) newClosed=[] joinEndTo[closedPaths]=-1#avoid joining to other paths if already closed joinStartTo[closedPaths]=-1 for s in range(0,nSub): if movedTo[s] == s:#not joined yet if joinEndTo[s] > -1 or joinStartTo[s] > -1:#any join scheduled thisSub=[] closedThis="" if joinEndTo[s] > -1:# join one by one until -1 or back to s (closed) jID=joinEndTo[s] sub1=old[s] sub2=old[jID] rev=True if joinEndToStart[s] == False else False sub2=revSub(sub2) if rev == True else sub2 thisSub=joinSub(sub1,sub2,self.options.optionJoin) movedTo[jID]=s prev=s #continue if sub2 joined to more if joinEndTo[jID] > -1 and joinStartTo[jID] > -1:#already joined so both joined if continue proceed=1 while proceed == 1: nID=joinEndTo[jID] if joinEndTo[jID] != prev else joinStartTo[jID] if movedTo[nID] == s: closedThis=" Z " proceed=0 else: sub2=old[nID] if (nID == joinEndTo[jID] and joinStartTo[nID] == jID) or (nID == joinStartTo[jID] and joinEndTo[nID] == jID): pass else: rev = not rev sub2=revSub(sub2) if rev == True else sub2 thisSub=joinSub(thisSub,sub2,self.options.optionJoin) movedTo[nID]=s if joinEndTo[nID] > -1 and joinStartTo[nID] > -1: prev=jID jID=nID else: proceed=0 if joinStartTo[s] > -1 and closedThis =="": jID=joinStartTo[s] sub1=old[jID] rev=True if joinStartToEnd[s] == False else False sub1=revSub(sub1) if rev == True else sub1 sub2=thisSub if len(thisSub) > 0 else old[s] thisSub=joinSub(sub1,sub2,self.options.optionJoin) movedTo[jID]=s prev=s #continue if sub1 joined to more if joinEndTo[jID] > -1 and joinStartTo[jID] > -1: proceed=1 while proceed == 1: nID=joinStartTo[jID] if joinStartTo[jID] != prev else joinEndTo[jID] if movedTo[nID] == s: closedThis=" Z " proceed=0 else: sub1=old[nID] if (nID == joinEndTo[jID] and joinStartTo[nID] == jID) or (nID == joinStartTo[jID] and joinEndTo[nID] == jID): pass else: rev = not rev sub1=revSub(sub1) if rev == True else sub1 thisSub=joinSub(sub1,thisSub,self.options.optionJoin) movedTo[nID]=s if joinEndTo[nID] > -1 and joinStartTo[nID] > -1: prev=jID jID=nID else: proceed=0 new.append(thisSub) newClosed.append(closedThis) else: new.append(old[s]) newClosed.append(closed[s]) closed=newClosed elem.path = CubicSuperPath(new).to_path(curves_only=True) #reset z to the originally closed paths (z lost in cubicsuperpath) temppath=str(elem.path.to_absolute()).split('M ') temppath.pop(0) newPath='' l=0 for sub in temppath: newPath=newPath+'M '+temppath[l]+closed[l] l+=1 elem.path=newPath if __name__ == '__main__': PurgeDuplicatePathNodes().run()