diff --git a/extensions/fablabchemnitz/purge_duplicate_path_nodes/purge_duplicate_path_nodes.inx b/extensions/fablabchemnitz/purge_duplicate_path_nodes/purge_duplicate_path_nodes.inx index 970d39e8..458090f4 100644 --- a/extensions/fablabchemnitz/purge_duplicate_path_nodes/purge_duplicate_path_nodes.inx +++ b/extensions/fablabchemnitz/purge_duplicate_path_nodes/purge_duplicate_path_nodes.inx @@ -1,22 +1,45 @@ - Purge Duplicate Path Nodes - fablabchemnitz.de.purge_duplicate_path_nodes - - false - 0.01 - false - 0.01 - + Purge Duplicate Path Nodes + fablabchemnitz.de.purge_duplicate_path_nodes + + + + false + 0.01 + false + 0.01 + false + 0.01 + true + + + + + + + + + + path - + - + \ No newline at end of file diff --git a/extensions/fablabchemnitz/purge_duplicate_path_nodes/purge_duplicate_path_nodes.py b/extensions/fablabchemnitz/purge_duplicate_path_nodes/purge_duplicate_path_nodes.py index 7403ed53..415c806c 100644 --- a/extensions/fablabchemnitz/purge_duplicate_path_nodes/purge_duplicate_path_nodes.py +++ b/extensions/fablabchemnitz/purge_duplicate_path_nodes/purge_duplicate_path_nodes.py @@ -1,5 +1,4 @@ -#!/usr/bin/env python -# coding=utf-8 +#!/usr/bin/env python3 # # Copyright (C) 2020 Ellen Wasboe, ellen@wasbo.net # @@ -7,7 +6,7 @@ # 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 @@ -17,85 +16,360 @@ # 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 join nodes with distance less than specified. -Optionally join start node with end node of each subpath if distance less than specified. +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("--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) - - """Remove duplicate nodes""" - def effect(self): - for id, elem in self.svg.selected.items(): - minlength=float(self.options.minlength) - maxdist=float(self.options.maxdist) - if self.options.minUse == False: - minlength=0 - if self.options.joinEnd == False: - maxdist=-1 - - #register which subpaths are closed - dList=str(elem.path).upper().split(' M') - closed=[""] - l=0 - for sub in dList: - if dList[l].find("Z") > -1: - closed.append(" Z ") - else: - closed.append("") - l+=1 - closed.pop(0) - - new = [] - s=0 - for sub in elem.path.to_superpath(): - new.append([sub[0]]) - i = 1 - while i <= len(sub) - 1: - length = bezier.cspseglength(new[-1][-1], sub[i]) #curve length - if length >= minlength: - new[-1].append(sub[i]) - else: - #average last node xy with this node xy and set this further node to last - new[-1][-1][1][0]= 0.5*(new[-1][-1][1][0]+sub[i][1][0]) - new[-1][-1][1][1]= 0.5*(new[-1][-1][1][1]+sub[i][1][1]) - new[-1][-1][-1]=sub[i][-1] - i += 1 - - if maxdist > -1: - #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[0][0][0][0]),0.5*(new[-1][-1][-1][1]+new[0][0][0][1])] - new[0][0][0]=newStartEnd - new[0][0][1]=newStartEnd - new[-1][-1][1]=newStartEnd - new[-1][-1][2]=newStartEnd - closed[s]=" Z " - s+=1 - - 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 - + 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() -