#!/usr/bin/env python ''' Copyright (C) 2017 , Pierre-Antoine Delsart This file is part of InkscapeShapeReco. InkscapeShapeReco 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 3 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 InkscapeShapeReco; if not, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA Quick description: This extension uses all selected path, ignoring all other selected objects. It tries to regularize hand drawn paths BY : - evaluating if the path is a full circle or ellipse - else finding sequences of aligned points and replacing them by a simple segment. - changing the segments angles to the closest remarkable angle (pi/2, pi/3, pi/6, etc...) - eqalizing all segments lengths which are close to each other - replacing 4 segments paths by a rectangle object if this makes sens (giving the correct rotation to the rectangle). Requires numpy. ''' import sys sys.path.append('/usr/share/inkscape/extensions') import inkex import simplepath import gettext _ = gettext.gettext import numpy numpy.set_printoptions(precision=3) # ************************************************************* # a list of geometric helper functions def toArray(parsedList): """Interprets a list of [(command, args),...] where command is a letter coding for a svg path command args are the argument of the command """ interpretCommand = { 'C' : lambda x, prevL : x[-2:], # bezier curve. Ignore the curve. 'L' : lambda x, prevL : x[0:2], 'M' : lambda x, prevL : x[0:2], 'Z' : lambda x, prevL : prevL[0], } points =[] for i,(c, arg) in enumerate(parsedList): #debug('toArray ', i, c , arg) newp = interpretCommand[c](arg, points) points.append( newp) a=numpy.array( points ) # Some times we have points *very* close to each other # these do not bring any meaning full info, so we remove them # x,y, w,h = computeBox(a) sizeC = 0.5*(w+h) #deltas = numpy.zeros((len(a),2) ) deltas = a[1:] - a[:-1] #deltas[-1] = a[0] - a[-1] deltaD = numpy.sqrt(numpy.sum( deltas**2, 1 )) sortedDind = numpy.argsort(deltaD) # expand longuest segments nexp = int(len(deltaD)*0.9) newpoints=[ None ]*len(a) medDelta = deltaD[sortedDind[len(deltaD)/2] ] for i,ind in enumerate(sortedDind): if deltaD[ind]/sizeC<0.005: continue if i>nexp: np = int(deltaD[ind]/medDelta) pL = [a[ind]] #print i,'=',ind,'adding ', np,' _ ', deltaD[ind], a[ind], a[ind+1] for j in range(np-1): f = float(j+1)/np #print '------> ', (1-f)*a[ind]+f*a[ind+1] pL.append( (1-f)*a[ind]+f*a[ind+1] ) newpoints[ind] = pL else: newpoints[ind]=[a[ind]] if(D(a[0],a[-1])/sizeC > 0.005 ) : newpoints[-1]=[a[-1]] points = numpy.concatenate([p for p in newpoints if p!=None] ) ## print ' medDelta ', medDelta, deltaD[sortedDind[-1]] ## print len(a) ,' ------> ', len(points) rel_norms = numpy.sqrt(numpy.sum( deltas**2, 1 )) / sizeC keep = numpy.concatenate([numpy.where( rel_norms >0.005 )[0],numpy.array([len(a)-1])]) #return a[keep] , [ parsedList[i] for i in keep] #print len(a),' ',len(points) return points , [] rotMat = numpy.matrix( [[1,-1],[1,1]] )/numpy.sqrt(2) unrotMat = numpy.matrix( [[1,1],[-1,1]] )/numpy.sqrt(2) def setupKnownAngles(): pi = numpy.pi #l = [ i*pi/8 for i in range(0, 9)] +[ i*pi/6 for i in [1,2,4,5,] ] l = [ i*pi/8 for i in range(0, 9)] +[ i*pi/6 for i in [1,2,4,5,] ] + [i*pi/12 for i in (1,5,7,11)] knownAngle = numpy.array( l ) return numpy.concatenate( [-knownAngle[:0:-1], knownAngle ]) knownAngle = setupKnownAngles() _twopi = 2*numpy.pi _pi = numpy.pi def deltaAngle(a1,a2): d = a1 - a2 return d if d > -_pi else d+_twopi def closeAngleAbs(a1,a2): d = abs(a1 - a2 ) return min( abs(d-_pi), abs( _twopi - d) , d) def deltaAngleAbs(a1,a2): return abs(in_mPi_pPi(a1 - a2 )) def in_mPi_pPi(a): if(a>_pi): return a-_twopi if(a<-_pi): return a+_twopi return a vec_in_mPi_pPi = numpy.vectorize(in_mPi_pPi) from numpy import sqrt def D2(p1, p2): return ((p1-p2)**2).sum() def D(p1, p2): return sqrt(D2(p1,p2) ) def norm(p): return sqrt( (p**2).sum() ) def computeBox(a): """returns the bounding box enclosing the array of points a in the form (x,y, width, height) """ xmin , ymin = a[:,0].min(), a[:,1].min() xmax , ymax = a[:,0].max(), a[:,1].max() return xmin, ymin, xmax-xmin, ymax-ymin def dirAndLength(p1,p2): #l = max(D(p1, p2) ,1e-4) l = D(p1,p2) uv = (p1-p2)/l return l,uv def length(p1,p2): return sqrt( D2(p1,p2) ) def barycenter(points): """ """ return points.sum(axis=0)/len(points) # ************************************************************* # debugging def void(*l): pass def debug_on(*l): sys.stderr.write(' '.join(str(i) for i in l) +'\n') debug = void #debug = debug_on # ************************************************************* # Internal Objects class Path(object): """Private representation of a sequence of points. A SVG node of type 'path' is splitted in several of these Path objects. """ next = None # next Path in the sequence of path corresponding to a SVG node prev = None # previous Path in the sequence of path corresponding to a SVG node sourcepoints = None # the full list of points from which this path is a subset normalv = None # normal vector to this Path def __init__(self, points): """points an array of points """ self.points = points self.init() def init(self): self.effectiveNPoints = len(self.points) if self.effectiveNPoints>1: self.length , self.univ = dirAndLength(self.points[0], self.points[-1]) else: self.length , self.univ = 0, numpy.array([0,0]) if self.effectiveNPoints>0: self.pointN=self.points[-1] self.point1=self.points[0] def isSegment(self): return False def quality(self): return 1000 def dump(self): n = len(self.points) if n>0: return 'path at '+str(self.points[0])+ ' to '+ str(self.points[-1])+' npoints=%d / %d (eff)'%(n,self.effectiveNPoints) else: return 'path Void !' def setNewLength(self, l): self.newLength = l def removeLastPoints(self,n): self.points = self.points[:-n] self.init() def removeFirstPoints(self,n): self.points = self.points[n:] self.init() def costheta(self,seg): return self.unitv.dot(seg.unitv) def translate(self, tr): """Translate this path by tr""" self.points = self.points + tr def asSVGCommand(self, firstP=False): svgCommands = [] com = 'M' if firstP else 'L' for p in self.points: svgCommands.append( [com, [p[0], p[1]] ] ) com='L' return svgCommands def setIntersectWithNext(self, next=None): pass def mergedWithNext(self, newPath=None): """ Returns the combination of self and self.next. sourcepoints has to be set """ if newPath is None: newPath = Path( numpy.concatenate([self.points, self.next.points]) ) newPath.sourcepoints = self.sourcepoints newPath.prev = self.prev if self.prev : newPath.prev.next = newPath newPath.next = self.next.next if newPath.next: newPath.next.prev = newPath return newPath # ************************************************************* # class Segment(Path): """ A segment. Defined by its line equation ax+by+c=0 and the points from orignal paths it is ensured that a**2+b**2 = 1 """ QUALITYCUT = 0.9 newAngle = None # temporary angle set during the "parralelization" step newLength = None # temporary lenght set during the "parralelization" step # Segment Builders @staticmethod def from2Points( p1, p2, refPoints = None): dirV = p2-p1 center = 0.5*(p2+p1) return Segment.fromCenterAndDir(center, dirV, refPoints) @staticmethod def fromCenterAndDir( center, dirV, refPoints=None): b = dirV[0] a = -dirV[1] c = - (a*center[0]+b*center[1]) if refPoints is None: refPoints = numpy.array([ center-0.5*dirV, center+0.5*dirV] ) s = Segment( a, b, c, refPoints) return s def __init__(self, a,b,c, points, doinit=True): """a,b,c: the line parameters. points : the array of 2D points represented by this Segment doinit : if true will compute additionnal parameters to this Segment (first/last points, unit vector,...) """ self.a = a self.b = b self.c = c self.points = points d = numpy.sqrt(a**2+b**2) if d != 1. : self.a /= d self.b /= d self.c /= d if doinit : self.init() def init(self): a,b,c = self.a, self.b, self.c x,y = self.points[0] self.point1 = numpy.array( [ b*(x*b-y*a) - c*a, a*(y*a-x*b) - c*b ] ) x,y = self.points[-1] self.pointN = numpy.array( [ b*(x*b-y*a) - c*a, a*(y*a-x*b) - c*b ] ) uv = self.computeDirLength() self.distancesToLine = self.computeDistancesToLine(self.points) self.normalv = numpy.array( [ a, b ]) self.angle = numpy.arccos( uv[0] )*numpy.sign(uv[1] ) def computeDirLength(self): """re-compute and set unit vector and length """ self.length , uv = dirAndLength(self.pointN, self.point1) self.unitv = uv return uv def isSegment(self): return True def recomputeEndPoints(self): a,b,c = self.a, self.b, self.c x,y = self.points[0] self.point1 = numpy.array( [ b*(x*b-y*a) - c*a, a*(y*a-x*b) - c*b ] ) x,y = self.points[-1] self.length = numpy.sqrt( D2(self.pointN, self.point1) ) def projectPoint(self,p): """ return the point projection of p onto this segment""" a,b,c = self.a, self.b, self.c x,y = p return numpy.array( [ b*(x*b-y*a) - c*a, a*(y*a-x*b) - c*b ] ) def intersect(self, seg): """Returns the intersection of this line with the line seg""" nu, nv = self.normalv, seg.normalv u = numpy.array([[-self.c],[-seg.c]]) doRotation = min(nu.min(),nv.min()) <1e-4 if doRotation: # rotate to avoid numerical issues nu = numpy.array(rotMat.dot(nu))[0] nv = numpy.array(rotMat.dot(nv))[0] m = numpy.matrix( (nu, nv) ) i = (m**-1).dot(u) i=numpy.array( i).swapaxes(0,1)[0] debug(' intersection ' ,nu, nv, self.angle, seg.angle, ' --> ',i) if doRotation: i = unrotMat.dot(i).A1 debug(' ' ,i) return i def setIntersectWithNext(self, next=None): """Modify self such as self.pointN is the intersection with next segment """ if next is None: next = self.next if next and next.isSegment(): if abs(self.normalv.dot(next.unitv)) < 1e-3: return debug(' Intersect',self, next, ' from ', self.point1, self.pointN, ' to ' ,next.point1, next.pointN,) inter = self.intersect(next) debug(' --> ', inter, ' d=', D(self.pointN, inter) ) next.point1 = inter self.pointN = inter self.computeDirLength() next.computeDirLength() def computeDistancesToLine(self, points): """points: array of points. returns the array of distances to this segment""" return abs(self.a*points[:,0]+self.b*points[:,1]+self.c) def distanceTo(self,point): return abs(self.a*point[0]+self.b*point[1]+self.c) def inverse(self): """swap all x and y values. """ def inv(v): v[0], v[1] = v[1] , v[0] for v in [self.point1 , self.pointN , self.unitv, self.normalv]: inv(v) self.points = numpy.roll(self.points,1,axis=1) self.a, self.b = self.b, self.a self.angle = numpy.arccos( self.unitv[0] )*numpy.sign(self.unitv[1] ) return def dumpShort(self): return 'seg '+' '+str(self.point1 )+'to '+str(self.pointN)+ ' npoints=%d | angle,offset=(%.2f,%.2f )'%(len(self.points),self.angle, self.c)+' ',self.normalv def dump(self): v = self.variance() n = len(self.points) return 'seg '+str(self.point1 )+' , '+str(self.pointN)+ ' v/l=%.2f / %.2f = %.2f r*sqrt(n)=%.2f npoints=%d | angle,offset=(%.2f,%.2f )'%(v, self.length, v/self.length,v/self.length*numpy.sqrt(n) ,n , self.angle, self.c) def variance(self): d = self.distancesToLine return numpy.sqrt( (d**2).sum()/len(d) ) def quality(self): n = len(self.points) return min(self.variance()/self.length*numpy.sqrt(n) , 1000) def formatedSegment(self, firstP=False): return self.asSVGCommand(firstP) def asSVGCommand(self, firstP=False): if firstP: segment = [ ['M',[self.point1[0],self.point1[1] ] ], ['L',[self.pointN[0],self.pointN[1] ] ] ] else: segment = [ ['L',[self.pointN[0],self.pointN[1] ] ] ] #debug("Segment, format : ", segment) return segment def replaceInList(self, startPos, fullList): code0 = fullList[startPos][0] segment = [ [code0,[self.point1[0],self.point1[1] ] ], ['L',[self.pointN[0],self.pointN[1] ] ] ] l = fullList[:startPos]+segment+fullList[startPos+len(self.points):] return l def mergedWithNext(self, doRefit=True): """ Returns the combination of self and self.next. sourcepoints has to be set """ spoints = numpy.concatenate([self.points,self.next.points]) if doRefit: newSeg = fitSingleSegment(spoints) else: newSeg = Segment.fromCenterAndDir(barycenter(spoints), self.unitv, spoints) newSeg = Path.mergedWithNext(self, newSeg) return newSeg def center(self): return 0.5*(self.point1+self.pointN) def box(self): return computeBox(self.points) def translate(self, tr): """Translate this segment by tr """ c = self.c -self.a*tr[0] -self.b*tr[1] self.c =c self.pointN = self.pointN+tr self.point1 = self.point1+tr self.points +=tr def adjustToNewAngle(self): """reset all parameters so that self.angle is change to self.newAngle """ self.a,self.b,self.c = parametersFromPointAngle( 0.5*(self.point1+self.pointN), self.newAngle) #print 'adjustToNewAngle ', self, self.angle, self.newAngle self.angle = self.newAngle self.normalv = numpy.array( [ self.a, self.b ]) self.unitv = numpy.array( [ self.b, -self.a ]) if abs(self.angle) > numpy.pi/2 : if self.b > 0: self.unitv *= -1 elif self.b<0 : self.unitv *= -1 self.point1 = self.projectPoint(self.point1) # reset point1 if self.next is None or not self.next.isSegment(): # move the last point (no intersect with next) pN = self.projectPoint(self.pointN) dirN = pN - self.point1 lN = length(pN, self.point1) self.pointN = dirN/lN*self.length + self.point1 #print ' ... adjusting last seg angle ',p.dump() , ' normalv=', p.normalv, 'unitv ', p.unitv else: self.setIntersectWithNext() def adjustToNewDistance(self): self.pointN = self.newLength* self.unitv + self.point1 self.length = self.newLength def tempLength(self): if self.newLength : return self.newLength else : return self.length def tempAngle(self): if self.newAngle: return self.newAngle return self.angle # ************************************************************* # ************************************************************* # Groups of Path # class PathGroup(object): """A group of Path representing one SVG node. - a list of Path - a list of SVG commands describe the full node (=SVG path element) - a reference to the inkscape node object """ listOfPaths = [] refSVGPathList = [] isClosing = False refNode = None def __init__(self, listOfPaths, refSVGPathList, refNode=None, isClosing=False): self.refNode = refNode self.listOfPaths = listOfPaths self.refSVGPathList = refSVGPathList self.isClosing=isClosing def addToNode(self, node): newList = reformatList( self.listOfPaths) ele = addPath( newList , node) debug("PathGroup ", newList) return ele def setNodeStyle(self,ele, node): style = node.get('style') ele.set('style', style) @staticmethod def toSegments(points, refSVGPathList, refNode, isClosing=False): """ """ segs = [ Segment.from2Points(p, points[i+1], points[i:i+2] ) for (i,p) in enumerate(points[:-1]) ] resetPrevNextSegment(segs) return PathGroup( segs, refSVGPathList, refNode , isClosing) class TangentEnvelop(PathGroup): """Specialization where the Path objects are all Segments and represent tangents to a curve """ def addToNode(self, node): newList = [ ] for s in self.listOfPaths: newList += s.asSVGCommand(firstP=True) debug("TangentEnvelop ", newList) ele = addPath( newList , node) return ele def setNodeStyle(self,ele, node): style = node.get('style')+';marker-end:url(#Arrow1Lend)' style ele.set('style', style) class Circle(PathGroup): """Specialization where the list of Path objects is to be replaced by a Circle specified by a center and a radius. If an other radius 'rmax' is given than the object represents an ellipse. """ isClosing= True def __init__(self, center, rad, refNode=None, rmax=None, angle=0.): self.listOfPaths = [] self.refNode = refNode self.center = numpy.array(center) self.radius = rad if rmax: self.type ='ellipse' else: self.type = 'circle' self.rmax = rmax self.angle = angle def addToNode(self, refnode): """Add a node in the xml structure corresponding to this rect refnode : xml node used as a reference, new point will be inserted a same level""" ele = inkex.etree.Element('{http://www.w3.org/2000/svg}'+self.type) ele.set('cx',str(self.center[0])) ele.set('cy',str(self.center[1])) if self.rmax: ele.set('ry',str(self.radius)) ele.set('rx',str(self.rmax)) ele.set('transform', 'rotate(%3.2f,%f,%f)'%(numpy.degrees(self.angle),self.center[0],self.center[1])) else: ele.set('r',str(self.radius)) refnode.xpath('..')[0].append(ele) return ele class Rectangle(PathGroup): """Specialization where the list of Path objects is to be replaced by a Rectangle specified by a center and size (w,h) and a rotation angle. """ def __init__(self, center, size, angle, listOfPaths, refNode=None): self.listOfPaths = listOfPaths self.refNode = refNode self.center = center self.size = size self.bbox = size self.angle = angle pos = self.center - numpy.array( size )/2 if angle != 0. : cosa = numpy.cos(angle) sina = numpy.sin(angle) self.rotMat = numpy.matrix( [ [ cosa, sina], [-sina, cosa] ] ) self.rotMatstr = 'matrix(%1.7f,%1.7f,%1.7f,%1.7f,0,0)'%(cosa, sina, -sina, cosa) #debug(' !!!!! Rotated rectangle !!', self.size, self.bbox, ' angles ', a, self.angle ,' center',self.center) else : self.rotMatstr = None self.pos = pos debug(' !!!!! Rectangle !!', self.size, self.bbox, ' angles ', self.angle ,' center',self.center) def addToNode(self, refnode): """Add a node in the xml structure corresponding to this rect refnode : xml node used as a reference, new point will be inserted a same level""" ele = inkex.etree.Element('{http://www.w3.org/2000/svg}rect') self.fill(ele) refnode.xpath('..')[0].append(ele) return ele def fill(self,ele): w, h = self.size ele.set('width',str(w)) ele.set('height',str(h)) w, h = self.bbox ele.set('x',str(self.pos[0])) ele.set('y',str(self.pos[1])) if self.rotMatstr: ele.set('transform', 'rotate(%3.2f,%f,%f)'%(numpy.degrees(self.angle),self.center[0],self.center[1])) #ele.set('transform', self.rotMatstr) @staticmethod def isRectangle( pathGroup): """Check if the segments in pathGroups can form a rectangle. Returns a Rectangle or None""" #print 'xxxxxxxx isRectangle',pathGroups if isinstance(pathGroup, Circle ): return None segmentList = [p for p in pathGroup.listOfPaths if p.isSegment() ]#or p.effectiveNPoints >0] if len(segmentList) != 4: debug( 'rectangle Failed at length ', len(segmentList)) return None a,b,c,d = segmentList if length(a.point1, d.pointN)> 0.2*(a.length+d.length)*0.5: debug('rectangle test failed closing ', length(a.point1, d.pointN), a.length, d.length) return None Aac , Abd = closeAngleAbs(a.angle,c.angle), closeAngleAbs(b.angle , d.angle) if min(Aac,Abd) > 0.07 or max(Aac, Abd) >0.27 : debug( 'rectangle Failed at angles', Aac, Abd) return None notsimilarL = lambda d1,d2: abs(d1-d2)>0.20*min(d1,d2) pi , twopi = numpy.pi,2*numpy.pi angles = numpy.array( [p.angle for p in segmentList] ) minAngleInd = numpy.argmin( numpy.minimum( abs(angles), abs( abs(angles)-pi), abs( abs(angles)-twopi) ) ) rotAngle = angles[minAngleInd] width = (segmentList[minAngleInd].length + segmentList[(minAngleInd+2)%4].length)*0.5 height = (segmentList[(minAngleInd+1)%4].length + segmentList[(minAngleInd+3)%4].length)*0.5 # set rectangle center as the bbox center x,y,w,h = computeBox( numpy.concatenate( [ p.points for p in segmentList]) ) r = Rectangle( numpy.array( [x+w/2, y+h/2]), (width, height), rotAngle, pathGroup.listOfPaths, pathGroup.refNode) debug( ' found a rectangle !! ', a.length, b.length, c.length, d.length ) return r # ************************************************************* # Object manipulation functions def toRemarkableShape( group ): """Test if PathGroup instance 'group' looks like a remarkable shape (ex: Rectangle). if so returns a new shape instance else returns group unchanged""" r = Rectangle.isRectangle( group ) if r : return r return group def resetPrevNextSegment(segs): for i, seg in enumerate(segs[:-1]): s = segs[i+1] seg.next = s s.prev = seg return segs def fitSingleSegment(a): xmin,ymin,w,h = computeBox(a) inverse = w ', tangents ) if averaged: # average over neighbours avTan = numpy.array(tangents) avTan[:-1] += tangents[1:] avTan[1:] += tangents[:-1] if isClosing: tangents[0]+=tangents[-1] tangents[1]+=tangents[0] avTan *= 1./3 if not isClosing: avTan[0] *=1.5 avTan[-1] *=1.5 return avTan def clusterAngles(array, dAng=0.15): """Cluster together consecutive angles with similar values (within 'dAng'). array : flat array of angles returns [ ..., (indi_0, indi_1),...] where each tuple are indices of cluster i """ N = len(array) closebyAng = numpy.zeros( (N,4) , dtype=int) for i,a in enumerate(array): cb = closebyAng[i] cb[0] =i cb[2]=i cb[3]=i c=i-1 # find number of angles within dAng in nearby positions while c>-1: # indices below i d=closeAngleAbs(a,array[c]) if d>dAng: break cb[1]+=1 cb[2]=c c-=1 c=i+1 while cdAng: break cb[1]+=1 cb[3]=c c+=1 closebyAng= closebyAng[numpy.argsort(closebyAng[:,1]) ] clusteredPos = numpy.zeros(N, dtype=int) clusters = [] for cb in reversed(closebyAng): if clusteredPos[cb[0]]==1: continue # try to build a cluster minI = cb[2] while clusteredPos[minI]==1: minI+=1 maxI = cb[3] while clusteredPos[maxI]==1: maxI-=1 for i in range(minI, maxI+1): clusteredPos[i] = 1 clusters.append( (minI, maxI) ) return clusters def adjustAllAngles(paths): for p in paths: if p.isSegment() and p.newAngle is not None: p.adjustToNewAngle() # next translate to fit end points tr = numpy.zeros(2) for p in paths[1:]: if p.isSegment() and p.prev.isSegment(): tr = p.prev.pointN - p.point1 debug(' translating ',p,' prev is', p.prev, ' ',tr, ) p.translate(tr) def adjustAllDistances(paths): for p in paths: if p.isSegment() and p.newLength is not None: p.adjustToNewDistance() # next translate to fit end points tr = numpy.zeros(2) for p in paths[1:]: if p.isSegment() and p.prev.isSegment(): tr = p.prev.pointN - p.point1 p.translate(tr) def mergeConsecutiveParralels(segments): ignoreNext=False newList=[] for s in segments: if ignoreNext: ignoreNext=False continue if not s.isSegment(): newList.append(s) continue if s.next is None: newList.append(s) continue if not s.next.isSegment(): newList.append(s) continue d = closeAngleAbs(s.angle ,s.next.angle) if d<0.001: debug("merging ", s.angle ,s.next.angle ) snew = s.mergedWithNext(doRefit=False) ignoreNext=True newList.append(snew) else: newList.append(s) if len(segments)>len(newList): debug("merged parallel ", segments , '-->', newList) return newList ##************************************** ## class SegmentExtender: """Extend Segments part of a list of Path by aggregating points from neighbouring Path objects. There are 2 concrete subclasses for extending forward and backward (due to technical reasons). """ def __init__(self, relD, fitQ): self.relD = relD self.fitQ = fitQ def nextPaths(self,seg): pL = [] p = self.getNext(seg) # prev or next while p : if p.isSegment(): break if p.mergedObj is None: break pL.append(p) p = self.getNext(p) if pL==[]: return [] return pL def extend(self,seg): nextPathL = self.nextPaths(seg) debug('extend ',self.extDir, seg , nextPathL, seg.length , len(nextPathL)) if nextPathL==[]: return seg pointsToTest = numpy.concatenate( [p.points for p in nextPathL] ) mergeD = seg.length*self.relD #print seg.point1 , seg.pointN, pointsToTest pointsToFit, addedPoints = self.pointsToFit(seg,pointsToTest , mergeD) if len(pointsToFit)==0: return seg newseg = fitSingleSegment(pointsToFit) if newseg.quality()>self.fitQ: # fit failed return seg debug( ' EXTENDING ! ', len(seg.points), len(addedPoints) ) self.removePath(seg, newseg, nextPathL, addedPoints ) newseg.points = pointsToFit seg.mergedObj= newseg newseg.sourcepoints = seg.sourcepoints return newseg @staticmethod def extendSegments(segmentList, relD=0.03, qual=0.5): """Perform Segment extension from list of Path segmentList returns the updated list of Path objects""" fwdExt = FwdExtender(relD, qual) bwdExt = BwdExtender(relD, qual) # tag all objects with an attribute pointing to the extended object for seg in segmentList: seg.mergedObj = seg # by default the extended object is self # extend each segments, starting by the longest for seg in sorted(segmentList, key = lambda s : s.length, reverse=True): if seg.isSegment(): newseg=fwdExt.extend(seg) seg.mergedObj = bwdExt.extend(newseg) # the extension procedure has marked as None the mergedObj # which have been swallowed by an extension. # filter them out : updatedSegs=[seg.mergedObj for seg in segmentList if seg.mergedObj] return updatedSegs class FwdExtender(SegmentExtender): extDir='Fwd' def getNext(self, seg): return seg.next def pointsToFit(self, seg, pointsToTest, mergeD): distancesToLine =abs(seg.a*pointsToTest[:,0]+seg.b*pointsToTest[:,1]+seg.c) goodInd=len(pointsToTest) for i,d in reversed(list(enumerate(distancesToLine))): if d tomerge ', len(mList)) if len(mList)<2: delattr(seg, 'merged') updatedSegs.append(seg) continue points= numpy.concatenate( [p.points for p in mList] ) newseg = fitSingleSegment(points) if newseg.quality()>q: delattr(seg, 'merged') updatedSegs.append(seg) continue for p in mList: setattr(seg, 'merged',True) newseg.sourcepoints = seg.sourcepoints debug(' --> post merge qual = ', newseg.quality() , seg.pointN, ' --> ', newseg.pointN, newseg.angle) newseg.prev = mList[0].prev newseg.next = mList[-1].next updatedSegs.append(newseg) if not hasattr(segList[-1], 'merged') : updatedSegs.append( segList[-1]) return updatedSegs def parametersFromPointAngle(point, angle): unitv = numpy.array([ numpy.cos(angle), numpy.sin(angle) ]) ortangle = angle+numpy.pi/2 normal = numpy.array([ numpy.cos(ortangle), numpy.sin(ortangle) ]) genOffset = -normal.dot(point) a, b = normal return a, b , genOffset def addPath(newList, refnode): """Add a node in the xml structure corresponding to the content of newList newList : list of Segment or Path refnode : xml node used as a reference, new point will be inserted a same level""" ele = inkex.etree.Element('{http://www.w3.org/2000/svg}path') ele.set('d', simplepath.formatPath(newList)) refnode.xpath('..')[0].append(ele) return ele def reformatList( listOfPaths): """ Returns a SVG paths list (same format as simplepath.parsePath) from a list of Path objects - Segments in paths are added in the new list - simple Path are retrieved from the original refSVGPathList and put in the new list (thus preserving original bezier curves) """ newList = [] first = True for seg in listOfPaths: newList += seg.asSVGCommand(first) first = False return newList def clusterValues( values, relS=0.1 , refScaleAbs='range' ): """form clusters of similar quantities from input 'values'. Clustered values are not necessarily contiguous in the input array. Clusters size (that is max-min) is < relS*cluster_average """ if len(values)==0: return [] if len(values.shape)==1: sortedV = numpy.stack([ values , numpy.arange(len(values))] ,1) else: # Assume value.shape = (N,2) and index are ok sortedV = values sortedV = sortedV[ numpy.argsort(sortedV[:,0]) ] sortedVV = sortedV[:,0] refScale = sortedVV[-1]-sortedVV[0] #sortedVV += 2*min(sortedVV)) # shift to avoid numerical issues around 0 #print sortedVV class Cluster: def __init__(self, delta, sum, indices): self.delta = delta self.sum = sum self.N=len(indices) self.indices = indices def size(self): return self.delta/refScale def combine(self, c): #print ' combine ', self.indices[0], c.indices[-1], ' -> ', sortedVV[c.indices[-1]] - sortedVV[self.indices[0]] newC = Cluster(sortedVV[c.indices[-1]] - sortedVV[self.indices[0]], self.sum+c.sum, self.indices+c.indices) return newC def originIndices(self): return tuple(int(sortedV[i][1]) for i in self.indices) def size_local(self): return self.delta / sum( sortedVV[i] for i in self.indices) *len(self.indices) def size_range(self): return self.delta/refScale def size_abs(self): return self.delta if refScaleAbs=='range': Cluster.size = size_range elif refScaleAbs=='local': Cluster.size = size_local elif refScaleAbs=='abs': Cluster.size = size_abs class ClusterPair: next=None prev=None def __init__(self, c1, c2 ): self.c1=c1 self.c2=c2 self.refresh() def refresh(self): self.potentialC =self.c1.combine(self.c2) self.size = self.potentialC.size() def setC1(self, c1): self.c1=c1 self.refresh() def setC2(self, c2): self.c2=c2 self.refresh() #ave = 0.5*(sortedVV[1:,0]+sortedV[:-1,0]) #deltaR = (sortedV[1:,0]-sortedV[:-1,0])/ave cList = [Cluster(0,v,(i,)) for (i,v) in enumerate(sortedVV) ] cpList = [ ClusterPair( c, cList[i+1] ) for (i,c) in enumerate(cList[:-1]) ] resetPrevNextSegment( cpList ) #print cpList def reduceCL( cList ): if len(cList)<=1: return cList cp = min(cList, key=lambda cp:cp.size) #print '==', cp.size , relS, cp.c1.indices , cp.c2.indices, cp.potentialC.indices while cp.size < relS: if cp.next: cp.next.setC1(cp.potentialC) cp.next.prev = cp.prev if cp.prev: cp.prev.setC2(cp.potentialC) cp.prev.next = cp.next cList.remove(cp) if len(cList)<2: break cp = min(cList, key=lambda cp:cp.size) #print ' -----> ', [ (cp.c1.indices , cp.c2.indices) for cp in cList] return cList cpList = reduceCL(cpList) if len(cpList)==1: cp = cpList[0] if cp.potentialC.size() remove !', p, p.length , len(p.points)) for p in paths: if len(p.points)==0 : remove(p) continue # select only path between 2 segments next, prev = p.next, p.prev if next is None: next = prev if prev is None: prev = next if not next.isSegment() or not prev.isSegment() : continue #diag = getdiag(p.points) diag ,w, h = getdiag(p.points) debug(p, p.pointN, ' removing edge diag = ', diag, p.length, ' l=',next.length+prev.length , 'totDim ', (wTot,hTot)) debug( ' ---> ',prev, next) # remove last or first very small in anycase doRemove = prev==next and (diag < 0.05*(wTot+hTot)*0.5 ) if not doRemove: # check if this small isLarge = diag > (next.length+prev.length)*0.1 # check size relative to neighbour isLarge = isLarge or w > 0.2*wTot or h > 0.2*hTot # check size w.r.t total size # is it the small side of a long rectangle ? dd = prev.distanceTo(next.pointN) rect = abs(prev.unitv.dot(next.unitv))>0.98 and diag > dd*0.5 doRemove = not( isLarge or rect ) if doRemove: remove(p) if next != prev: prev.setIntersectWithNext(next) debug('removed Segments ', removeSeg) for p in removeSeg: paths.remove(p) def prepareParrallelize(self,segs): """Group Segment by their angles (segments are grouped together if their deltAangle is within 0.15 rad) The 'newAngle' member of segments in a group are then set to the mean angle of the group (where angles are all considered in [-pi, pi]) segs : list of segments """ angles = numpy.array([s.angle for s in segs ]) angles[numpy.where(angles<0)] += _pi # we care about direction, not angle orientation clList = clusterValues(angles, 0.15, refScaleAbs='abs') for cl in clList: meanA = angles[list(cl)].mean() for i in cl: seg = segs[i] seg.newAngle = meanA if seg.angle>=0. else meanA-_pi def prepareDistanceEqualization(self,segs, relDelta=0.1): """ Input segments are grouped according to their length : - for each length L, find all other lengths within L*relDelta. of L. - Find the larger of such subgroup. - repeat the procedure on remaining lengths until none is left. Each length in a group is set to the mean length of the group segs : a list of segments relDelta : float, minimum relative distance. """ lengths = numpy.array( [x.tempLength() for x in segs] ) clusters = clusterValues(lengths) if len(clusters)==1: # deal with special case with low num of segments # --> don't let a single segment alone if len(clusters[0])+1==len(segs): clusters[0]=range(len(segs)) # all allDist = [] for cl in clusters: dmean = sum( lengths[i] for i in cl ) / len(cl) allDist.append(dmean) for i in cl: segs[i].setNewLength(dmean) debug( i,' set newLength ',dmean, segs[i].length, segs[i].dumpShort()) return allDist def prepareRadiusEqualization(self, circles, otherDists, relSize=0.2): """group circles radius and distances into cluster. Then set circles radius according to the mean of the clusters they belong to.""" ncircles = len(circles) lengths = numpy.array( [c.radius for c in circles]+otherDists ) indices = numpy.array( range(ncircles+len(otherDists) ) ) clusters = clusterValues(numpy.stack([ lengths, indices ],1 ), relSize, refScaleAbs='local' ) debug('prepareRadiusEqualization radius ', repr(lengths)) debug('prepareRadiusEqualization clusters ', clusters) allDist = [] for cl in clusters: dmean = sum( lengths[i] for i in cl ) / len(cl) #print cl , dmean , allDist.append(dmean) if len(cl)==1: continue for i in cl: if i< ncircles: circles[i].radius = dmean debug(' post radius ',[c.radius for c in circles] ) return allDist def centerCircOnSeg(self, circles, segments, relSize=0.18): """ move centers of circles onto the segments if close enough""" for circ in circles: circ.moved = False for seg in segments: for circ in circles: d = seg.distanceTo(circ.center) #debug( ' ', seg.projectPoint(circ.center)) if d < circ.radius*relSize and not circ.moved : circ.center = seg.projectPoint(circ.center) circ.moved = True def adjustToKnownAngle(self, paths): """ Check current angle against remarkable angles. If close enough, change it paths : a list of segments""" for seg in paths: a = seg.tempAngle() i = (abs(vec_in_mPi_pPi(knownAngle - a) )).argmin() seg.newAngle = knownAngle[i] debug( ' Known angle ', seg, seg.tempAngle(),' -> ', knownAngle[i]) ## if abs(knownAngle[i] - a) < 0.08: def checkForCircle(self, points, tangents): """Determine if the points and their tangents represent a circle The difficulty is to be able to recognize ellipse while avoiding paths small fluctuations a nd false positive due to badly drawn rectangle or non-convex closed curves. Method : we consider angle of tangent as function of lenght on path. For circles these are : angle = c1 x lenght + c0. (c1 ~1) We calculate dadl = d(angle)/d(length) and compare to c1. We use 3 criteria : * num(dadl > 6) : number of sharp angles * length(dadl<0.3)/totalLength : lengths of straight lines within the path. * totalLength/(2pi x radius) : fraction of lenght vs a plain circle Still failing to recognize elongated ellipses... """ if len(points)<10: return False, 0 if all(points[0]==points[-1]): # last exactly equals the first. # Ignore last point for this check points = points[:-1] tangents = tangents[:-1] #print 'Removed last ', points xmin,ymin, w, h = computeBox( points) diag2=(w*w+h*h) diag = sqrt(diag2)*0.5 norms = numpy.sqrt(numpy.sum( tangents**2, 1 )) angles = numpy.arctan2( tangents[:,1], tangents[:,0] ) #debug( 'angle = ', repr(angles)) N = len(angles) deltas = points[1:] - points[:-1] deltasD = numpy.concatenate([ [D(points[0],points[-1])/diag], numpy.sqrt(numpy.sum( deltas**2, 1 )) / diag] ) # locate and avoid the point when swicthing # from -pi to +pi. The point is around the minimum imin = numpy.argmin(angles) debug(' imin ',imin) angles = numpy.roll(angles, -imin) deltasD = numpy.roll(deltasD, -imin) n=int(N*0.1) # avoid fluctuations by removing points around the min angles=angles[n:-n] deltasD=deltasD[n:-n] deltasD = deltasD.cumsum() N = len(angles) # smooth angles to avoid artificial bumps angles = smoothArray(angles, n=max(int(N*0.03),2) ) deltaA = angles[1:] - angles[:-1] deltasDD = (deltasD[1:] -deltasD[:-1]) deltasDD[numpy.where(deltasDD==0.)] = 1e-5*deltasD[0] dAdD = abs(deltaA/deltasDD) belowT, count = True,0 for v in dAdD: if v>6 and belowT: count+=1 belowT = False belowT= (v<6) self.temp = (deltasD,angles, tangents, dAdD ) fracStraight = numpy.sum(deltasDD[numpy.where(dAdD<0.3)])/(deltasD[-1]-deltasD[0]) curveLength = deltasD[-1]/3.14 #print "SSS ",count , fracStraight if curveLength> 1.4 or fracStraight>0.4 or count > 6: isCircle =False else: isCircle= (count < 4 and fracStraight<=0.3) or \ (fracStraight<=0.1 and count<5) if not isCircle: return False, 0 # It's a circle ! radius = points - numpy.array([xmin+w*0.5,ymin+h*0.5]) radius_n = numpy.sqrt(numpy.sum( radius**2, 1 )) # normalize mini = numpy.argmin(radius_n) rmin = radius_n[mini] maxi = numpy.argmax(radius_n) rmax = radius_n[maxi] # void points around maxi and mini to make sure the 2nd max is found # on the "other" side n = len(radius_n) radius_n[maxi]=0 radius_n[mini]=0 for i in range(1,n/8+1): radius_n[(maxi+i)%n]=0 radius_n[(maxi-i)%n]=0 radius_n[(mini+i)%n]=0 radius_n[(mini-i)%n]=0 radius_n_2 = [ r for r in radius_n if r>0] rmax_2 = max(radius_n_2) rmin_2 = min(radius_n_2) # not good !! anglemax = numpy.arccos( radius[maxi][0]/rmax)*numpy.sign(radius[maxi][1]) return True, (xmin+w*0.5,ymin+h*0.5, 0.5*(rmin+rmin_2), 0.5*(rmax+rmax_2), anglemax) def tangentEnvelop(self, svgCommandsList, refNode): a, svgCommandsList = toArray(svgCommandsList) tangents = buildTangents(a) newSegs = [ Segment.fromCenterAndDir( p, t ) for (p,t) in zip(a,tangents) ] debug("build envelop ", newSegs[0].point1 , newSegs[0].pointN) clustersInd = clusterAngles( [s.angle for s in newSegs] ) debug("build envelop cluster: ", clustersInd) return TangentEnvelop( newSegs, svgCommandsList, refNode) def segsFromTangents(self,svgCommandsList, refNode): """Finds segments part in a list of points represented by svgCommandsList. The method is to build the (averaged) tangent vectors to the curve. Aligned points will have tangent with similar angle, so we cluster consecutive angles together to define segments. Then we extend segments to connected points not already part of other segments. Then we merge consecutive segments with similar angles. """ sourcepoints, svgCommandsList = toArray(svgCommandsList) d = D(sourcepoints[0],sourcepoints[-1]) x,y,wTot,hTot = computeBox(sourcepoints) aR = min(wTot/hTot, hTot/wTot) maxDim = max(wTot, hTot) isClosing = aR*0.2 > d/maxDim debug('isClosing ', isClosing, maxDim, d) if d==0: # then we remove the last point to avoid null distance # in other calculations sourcepoints = sourcepoints[:-1] svgCommandsList = svgCommandsList[:-1] if len(sourcepoints) < 4: return PathGroup.toSegments(sourcepoints, svgCommandsList, refNode, isClosing=isClosing) tangents = buildTangents(sourcepoints, isClosing=isClosing) # global quantities : # Check if circle ----------------------- if isClosing: if len(sourcepoints)<9: return PathGroup.toSegments(sourcepoints, svgCommandsList, refNode, isClosing=True) isCircle, res = self.checkForCircle( sourcepoints, tangents) debug("Is Circle = ", isCircle ) if isCircle: x,y,rmin, rmax,angle = res debug("Circle -> ", rmin, rmax,angle ) if rmin/rmax>0.7: circ = Circle((x,y),0.5*(rmin+rmax), refNode ) else: circ = Circle((x,y),rmin, refNode, rmax=rmax, angle=angle) circ.points = sourcepoints return circ # ----------------------- # cluster points by angle of their tangents ------------- tgSegs = [ Segment.fromCenterAndDir( p, t ) for (p,t) in zip(sourcepoints,tangents) ] clustersInd = clusterAngles( [s.angle for s in tgSegs] ) clustersInd.sort( ) debug("build envelop cluster: ", clustersInd) # build Segments from clusters newSegs = [] for imin, imax in clustersInd: if imin+1< imax: # consider clusters with more than 3 points seg = fitSingleSegment(sourcepoints[imin:imax+1]) elif imin+1==imax: # 2 point path : we build a segment seg = Segment.from2Points(sourcepoints[imin], sourcepoints[imax] , sourcepoints[imin:imax+1]) else: seg = Path( sourcepoints[imin:imax+1] ) seg.sourcepoints = sourcepoints newSegs.append( seg ) resetPrevNextSegment( newSegs ) debug(newSegs) # ----------------------- # ----------------------- # Merge consecutive Path objects updatedSegs=[] def toMerge(p): l=[p] setattr(p, 'merged', True) if p.next and not p.next.isSegment(): l += toMerge(p.next) return l for i,seg in enumerate(newSegs[:-1]): if seg.isSegment(): updatedSegs.append( seg) continue if hasattr(seg,'merged'): continue mergeList = toMerge(seg) debug('merging ', mergeList) p = Path(numpy.concatenate([ p.points for p in mergeList]) ) debug('merged == ', p.points) updatedSegs.append(p) if not hasattr(newSegs[-1],'merged'): updatedSegs.append( newSegs[-1]) debug("merged path", updatedSegs) newSegs = resetPrevNextSegment( updatedSegs ) # Extend segments ----------------------------------- if self.options.segExtensionEnable: newSegs = SegmentExtender.extendSegments( newSegs, self.options.segExtensionDtoSeg, self.options.segExtensionQual ) debug("extended segs", newSegs) newSegs = resetPrevNextSegment( newSegs ) debug("extended segs", newSegs) # ---------------------------------------- # --------------------------------------- # merge consecutive segments with close angle updatedSegs=[] if self.options.segAngleMergeEnable: newSegs = mergeConsecutiveCloseAngles( newSegs , mangle=0.2 ) newSegs=resetPrevNextSegment(newSegs) debug(' __ 2nd angle merge') newSegs = mergeConsecutiveCloseAngles( newSegs, mangle=0.35 ) # 2nd pass newSegs=resetPrevNextSegment(newSegs) debug('after merge ', len(newSegs), newSegs) # Check if first and last also have close angles. if isClosing and len(newSegs)>2 : first ,last = newSegs[0], newSegs[-1] if first.isSegment() and last.isSegment(): if closeAngleAbs( first.angle, last.angle) < 0.1: # force merge points= numpy.concatenate( [ last.points, first.points] ) newseg = fitSingleSegment(points) newseg.next = first.next last.prev.next = None newSegs[0]=newseg newSegs.pop() # ----------------------------------------------------- # remove negligible Path/Segments between 2 large Segments if self.options.segRemoveSmallEdge: self.removeSmallEdge(newSegs , wTot, hTot) newSegs=resetPrevNextSegment(newSegs) debug('after remove small ', len(newSegs),newSegs) # ----------------------------------------------------- # ----------------------------------------------------- # Extend segments to their intersections for p in newSegs: if p.isSegment() and p.next: p.setIntersectWithNext() # ----------------------------------------------------- return PathGroup(newSegs, svgCommandsList, refNode, isClosing) def extractShapesFromID( self, *nids, **options ): """for debugging purpose """ eList = [] for nid in nids: el = self.getElementById(nid) if el is None: print "Cant find ", nid return eList.append(el) class tmp: pass self.options = self.OptionParser.parse_args()[0] self.options._update_careful(options) nodes=self.extractShapes(eList) self.shape = nodes[0] def buildShape(self, node): def rotationAngle(tr): if tr and tr.startswith('rotate'): # retrieve the angle : return float(tr[7:-1].split(',')) else: return 0. if node.tag.endswith('path'): parsedSVGCommands = node.get('d') g = self.segsFromTangents(simplepath.parsePath(parsedSVGCommands), node) #g = self.tangentEnvelop(simplepath.parsePath(parsedSVGCommands), node) elif node.tag.endswith('rect'): tr = node.get('transform',None) if tr and tr.startswith('matrix'): return None # can't deal with scaling recSize = numpy.array([node.get('width'),node.get('height')]) recCenter = numpy.array([node.get('x'),node.get('y')]) + recSize/2 angle=rotationAngle(tr) g = Rectangle( recSize, recCenter, 0 , [], node) elif node.tag.endswith('circle'): g = Circle(node.get('cx'),node.get('cy'), node.get('r'), [], node ) elif node.tag.endswith('ellipse'): if tr and tr.startswith('matrix'): return None # can't deal with scaling angle=rotationAngle(tr) rx = node.get('rx') ry = node.get('ry') g = Circle(node.get('cx'),node.get('cy'), ry, rmax=rx , angle=angle, refNode=node ) return g def extractShapes( self, nodes ): """The main function. nodes : a list of nodes""" analyzedNodes = [] # convert nodes to list of segments (PathGroup) or Circle for n in nodes : g = self.buildShape(n) if g : analyzedNodes.append( g ) # uniformize shapes if self.options.doUniformization: analyzedNodes = self.uniformizeShapes(analyzedNodes) return analyzedNodes def uniformizeShapes(self, pathGroupList): allSegs = [ p for g in pathGroupList for p in g.listOfPaths if p.isSegment() ] if self.options.doParrallelize: self.prepareParrallelize(allSegs) if self.options.doKnownAngle: self.adjustToKnownAngle(allSegs) adjustAng = self.options.doKnownAngle or self.options.doParrallelize for group in pathGroupList: # first pass : independently per path if adjustAng: adjustAllAngles(group.listOfPaths) group.listOfPaths[:] = mergeConsecutiveParralels(group.listOfPaths) if self.options.doEqualizeDist: self.prepareDistanceEqualization([p for p in group.listOfPaths if p.isSegment()], 0.12) adjustAllDistances(group.listOfPaths) ## # then 2nd global pass, with tighter criteria if self.options.doEqualizeDist: allShapeDist=self.prepareDistanceEqualization(allSegs, 0.05) for group in pathGroupList: adjustAllDistances(group.listOfPaths) else: allShapeDist = [] for g in pathGroupList: if g.isClosing and not isinstance(g,Circle): debug('Closing intersec ', g.listOfPaths[0].point1, g.listOfPaths[0].pointN ) g.listOfPaths[-1].setIntersectWithNext(g.listOfPaths[0]) circles=[ group for group in pathGroupList if isinstance(group, Circle)] if self.options.doEqualizeRadius: self.prepareRadiusEqualization(circles, allShapeDist) if self.options.doCenterCircOnSeg: self.centerCircOnSeg(circles, allSegs) pathGroupList = [toRemarkableShape(g) for g in pathGroupList] return pathGroupList def addShapesToDoc(self, pathGroupList): for group in pathGroupList: debug("final ", group.listOfPaths, group.refNode ) # change to Rectangle if possible : #finalshape = toRemarkableShape( group ) ele = group.addToNode( group.refNode) group.setNodeStyle(ele, group.refNode) if not self.options.keepOrigin: group.refNode.xpath('..')[0].remove(group.refNode) if __name__ == '__main__': e = ShapeReco() e.affect()