# Copyright 2021-2023 Ian Jauslin # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # main drawing class import sys import math from kivy.uix.widget import Widget from kivy.core.window import Window from kivy.graphics import Color,Line,Rectangle from point import Point from polyomino import Cross,Disk,Staircase from tools import remove_fromlist import colors # painter class class Painter(Widget): def __init__(self,app,**kwargs): # list of particles self.particles=[] # shape of particle to add next self.shape=Cross # color of particle to add next self.color=(0,0,1) # underlying lattice self.lattice=None # particle under mouse self.undermouse=None # list of selected particles self.selected=[] # complement self.unselected=[] # relative position of mouse when moving self.offset=Point(0,0) # reference particle from which positions should be computed self.reference=None # app is used to share information between widgets self.app=app # modifiers self.modifiers=[] # base size for all particles self.base_size=50 # init Widget super(Painter,self).__init__(**kwargs) # init keyboard self.keyboard = Window.request_keyboard(None,self,"text") self.keyboard.bind(on_key_down=self.on_key_down,on_key_up=self.on_key_up,on_textinput=self.on_textinput) # redraw on resize self.bind(size=lambda obj,value: self.draw()) def reset(self): self.particles=[] self.undermouse=None self.draw() # set lattice def set_lattice(self,lattice): self.lattice=lattice # draw self.draw() # snap all existing particles to grid for particle in self.particles: delta=self.lattice.nearest_delta(particle.elements[0].pos) if not self.check_interaction_any(particle,delta): particle.move(delta) # convert logical coordinates (normalized and centered) to the ones that are plotted def pos_tocoord_x(self,x): return self.width/2+x*self.base_size def pos_tocoord_y(self,y): return self.height/2+y*self.base_size def coord_topos_x(self,x): return (x-self.width/2)/self.base_size def coord_topos_y(self,y): return (y-self.height/2)/self.base_size # draw all particles def draw(self): self.canvas.clear() with self.canvas: # draw order: particles, then grids, then transparent particles for particle in self.particles: particle.draw(self) # draw lattice if self.lattice!=None: self.lattice.draw(self) # draw grids for particle in self.particles: if particle.grid>0: self.draw_grid(particle.elements[0].pos,particle.grid) # draw Voronoi cells if self.lattice!=None: for particle in self.particles: if particle.voronoi>0: self.draw_voronoi(particle) for particle in self.particles: particle.draw(self,alpha=0.5) # draw a grid around a particle def draw_grid(self,pos,mesh): # height offset due to status bar and command prompt height_offset=self.app.status_bar.height+self.app.command_prompt.height # vertical lines # lines right of pos xx=pos.x+mesh/2 while self.pos_tocoord_x(xx)0: Color(1,1,1) Line(points=(self.pos_tocoord_x(xx),height_offset,self.pos_tocoord_x(xx),self.height+height_offset)) xx-=mesh # lines above pos yy=pos.y+mesh/2 while self.pos_tocoord_y(yy)0: Color(1,1,1) Line(points=(0,self.pos_tocoord_y(yy),self.width,self.pos_tocoord_y(yy))) yy-=mesh # draw the discrete Voronoi cell of a particle def draw_voronoi(self,particle): # only works for lattices if self.lattice!=None: pos=particle.elements[0].pos # loop over all points xx=pos.x while self.pos_tocoord_x(xx)0: self.draw_voronoi_site(xx,yy,particle.color,self.is_in_voronoi(xx,yy,particle)) yy-=self.lattice.spacing xx+=self.lattice.spacing xx=pos.x-self.lattice.spacing while self.pos_tocoord_x(xx)>0: yy=pos.y while self.pos_tocoord_y(yy)0: self.draw_voronoi_site(xx,yy,particle.color,self.is_in_voronoi(xx,yy,particle)) yy-=self.lattice.spacing xx-=self.lattice.spacing # check whether a site is in the Voronoi cell of a particle def is_in_voronoi(self,x,y,particle): d_to_particle=self.lattice.distance_to_particle(x,y,particle) # count how many are in voronoi cell count=1 # TODO: start with a particle that is close to x,y for q in self.particles: dd=self.lattice.distance_to_particle(x,y,q) if q!=particle and dd0: self.reference=self.selected[0] else: self.reference=None self.app.status_bar.draw() def on_key_up(self, keyboard, keycode): if keycode[1]=="shift": if 's' in self.modifiers: # remove self.modifiers[self.modifiers.index('s')]=self.modifiers[len(self.modifiers)-1] self.modifiers=self.modifiers[:len(self.modifiers)-1] self.modifiers.sort() # respond to mouse down def on_touch_down(self,touch): # only respond to touch in drawing area if self.collide_point(*touch.pos): # convert to logical touchx=self.coord_topos_x(touch.x) touchy=self.coord_topos_y(touch.y) # create new particle if touch.button=="right": new=self.shape(touchx,touchy,color=self.color) # snap to lattice if self.lattice!=None: new.move(self.lattice.nearest_delta(new.elements[0].pos)) if not self.check_interaction_any(new,Point(0,0)): # add to list self.particles.append(new) # unselect all particles for sel in self.selected: sel.selected=False self.selected=[] self.unselected=self.particles self.draw() # select particle if touch.button=="left": # find particle under touch self.undermouse=self.find_particle(Point(touchx,touchy)) # record relative position of click with respect to reference if self.undermouse!=None: self.offset=Point(touchx,touchy)-self.undermouse.elements[0].pos # no modifiers if self.modifiers==[]: if self.undermouse==None: # unselect all particles for sel in self.selected: sel.selected=False self.selected=[] self.unselected=self.particles # select undermouse elif not self.undermouse in self.selected: for sel in self.selected: sel.selected=False self.selected=[self.undermouse] self.unselected=self.particles.copy() self.unselected=remove_fromlist(self.unselected,self.undermouse) self.undermouse.selected=True # shift-click elif self.modifiers==['s']: if self.undermouse!=None: if self.undermouse not in self.selected: self.selected.append(self.undermouse) self.undermouse.selected=True # remove from unselected self.unselected=remove_fromlist(self.unselected,self.undermouse) else: # remove self.selected=remove_fromlist(self.selected,self.undermouse) self.undermouse.selected=False # add to unselected self.unselected.append(self.undermouse) self.draw() # draw status bar self.app.status_bar.draw() # respond to drag def on_touch_move(self,touch): # only respond to touch in drawing area if self.collide_point(*touch.pos): # convert to logical touchx=self.coord_topos_x(touch.x) touchy=self.coord_topos_y(touch.y) # only move on left click if touch.button=="left" and self.modifiers==[] and self.undermouse!=None: # attempted move determined by the relative position to the relative position of click within self.undermouse delta=self.adjust_move(Point(touchx,touchy)-(self.offset+self.undermouse.elements[0].pos),0) # snap to lattice if self.lattice!=None: delta=self.lattice.nearest(delta) # check that the move is possible (which is not guaranteed after snapping to lattice) if not self.check_interaction_unselected_list(self.selected,delta): for particle in self.selected: particle.move(delta) # no lattice, move is guaranteed to be acceptable else: for particle in self.selected: particle.move(delta) # redraw self.draw() # draw status bar self.app.status_bar.draw() # find the particle at position pos def find_particle(self,pos): for particle in self.particles: if particle.in_support(pos): return particle # none found return None # check whether a candidate particle intersects with any of the particles def check_interaction_any(self,candidate,offset): for particle in self.particles: # do not check interaction if candidate=particle if candidate!=particle and particle.check_interaction(candidate,offset): return True return False # check whether shifting a list of particles by offset makes them interact with all particles def check_interaction_list(self,array,offset): for candidate in array: if self.check_interaction_any(candidate,offset): return True return False # check whether shifting a list of particles by offset makes them interact with the unselected particles def check_interaction_unselected_list(self,array,offset): for candidate in array: for particle in self.unselected: if particle.check_interaction(candidate,offset): return True return False # check whether a candidate particle element with any of the unselected particles def check_interaction_unselected_element(self,element,offset): for particle in self.unselected: for elt in particle.elements: # add offset element.pos+=offset if elt.check_interaction(element): # reset offset element.pos-=offset return True # reset offset element.pos-=offset return False # try to move all selected particles by delta, adjust if needed to avoid overlap with unselected particles # we only track whether these elements collide with unselected particles, not with each other def adjust_move(self,delta,recursion_depth): # actual_delta is the smallest (componentwise) of all the computed delta's actual_delta=Point(math.inf,math.inf) for particle in self.selected: for element in particle.elements: # compute adjustment move due to unselected obstacles adjusted_delta=self.adjust_move_element(delta,element,0) # only keep the smallest delta's (in absolute value) if abs(adjusted_delta.x)100: print("warning: recursion depth exceeded when adjusting move by delta=(",delta.x,",",delta.y,")",file=sys.stderr) return Point(0,0) else: return self.adjust_move(actual_delta,recursion_depth+1) # trying to move a single element by delta, adjust if needed to avoid overlap with unselected particles def adjust_move_element(self,delta,element,recursion_depth): # whether newpos is acceptable accept_newpos=True for other in self.unselected: for obstacle in other.elements: # move would make element overlap with obstacle element.pos+=delta if obstacle.check_interaction(element): element.pos-=delta accept_newpos=False # check if particle already touches obstacle if obstacle.check_touch(element): # move along obstacle while remaining stuck newdelta=obstacle.move_along(delta,element) else: newdelta=obstacle.move_on_line_to_stick(element,delta) if not self.check_interaction_unselected_element(element,newdelta): return newdelta else: # reset offset element.pos-=delta if accept_newpos: return delta else: # cannot move particle at all, try again # give up if tried too many times if recursion_depth>100: print("warning: recursion depth exceeded when adjusting move of element at (",element.pos.x,",",element.pos.y,") by delta=(",delta.x,",",delta.y,")",file=sys.stderr) return Point(0,0) else: return self.adjust_move_element(newdelta,element,recursion_depth+1) # set color of selected particles def set_color(self,color): # set color for next particles self.color=color # set color of selected particles for particle in self.selected: particle.color=color # redraw self.draw() # set grid for selected particles # set mesh to -1 to toggle on/off def set_grid(self,mesh): for particle in self.selected: if mesh==-1: if particle.grid==0: particle.grid=1 else: particle.grid=-particle.grid else: particle.grid=mesh # redraw self.draw() # set voronoi for selected particles def set_voronoi(self, onoff): for particle in self.selected: if onoff==0: particle.voronoi=False elif onoff==1: particle.voronoi=True elif onoff==-1: particle.voronoi=not particle.voronoi # redraw self.draw() # write configuration to file def write(self,file): ff=open(file,"w") # save state (particle shape, zoom, lattice) if self.shape==Cross: ff.write("%shape=cross\n") elif self.shape==Disk: ff.write("%shape=disk\n") elif self.shape==Staircase: ff.write("%shape=staircase\n") else: print("bug: unrecognized shape in write: '"+str(self.shape)+"'") ff.write("%zoom={:1.1f}\n".format(self.base_size/50)) ff.write("%color={:d},{:d},{:d}\n".format(self.color[0],self.color[1],self.color[2])) if self.lattice != None: ff.write("%lattice="+self.lattice.type+':'+str(self.lattice.spacing)+"\n") for particle in self.particles: if type(particle)==Cross: ff.write("{:d};".format(CROSS_INDEX)) elif type(particle)==Disk: ff.write("{:d};".format(DISK_INDEX)) elif type(particle)==Staircase: ff.write("{:d};".format(STAIRCASE_INDEX)) ff.write("{:05.2f},{:05.2f};{:3.1f},{:3.1f},{:3.1f}\n".format(particle.elements[0].pos.x,particle.elements[0].pos.y,particle.color[0],particle.color[1],particle.color[2])) ff.close() # read configuration from file def read(self,file): self.reset() try: ff=open(file,"r") except: self.app.command_prompt.message="error: could not read file '"+file+"' (this should not happen and is probably a bug)" return # counter i=0 try: lines=ff.readlines() except: self.app.command_prompt.message="error: could not read the contents of file '"+file+"'" return for line in lines: i+=1 # remove newline line=line[:len(line)-1] # ignore comments if '#' in line: line=line[:line.find('#')] # ignore empty lines if len(line)==0: continue # read options if line[0]=='%': # ignore empty line if len(line)==1: continue [key,val]=line[1:].split('=',1) if key=="shape": self.app.command_prompt.run_set_shape(["set","shape",val]) elif key=="zoom": self.app.command_prompt.run_set_zoom(["set","zoom",val]) elif key=="color": color_str=val.split(',') try: self.set_color((float(color_str[0]),float(color_str[1]),float(color_str[2]))) except: print("warning: ignoring line "+str(i)+" in file '"+file+"': color '"+color_str+"' cannot be read",file=sys.stderr) # lattice is handled by main function elif key=="lattice": continue else: print("warning: ignoring line "+str(i)+" in file '"+file+"': unrecognized option '"+key+"'",file=sys.stderr) continue entries=line.split(";") # skip line if improperly formatted if len(entries)>3: print("warning: ignoring line "+str(i)+" in file '"+file+"': more than three ';' separated entries in '"+line+"'",file=sys.stderr) if len(entries)<2: print("warning: ignoring line "+str(i)+" in file '"+file+"': fewer than two ';' separated entries in '"+line+"'",file=sys.stderr) continue # position pos_str=entries[1].split(",") # skip line if improperly formatted if len(pos_str)!=2: print("warning: ignoring line "+str(i)+" in file '"+file+"': position '"+entries[1]+"' does not have two components",file=sys.stderr) continue try: pos=Point(float(pos_str[0]),float(pos_str[1])) except: print("warning: ignoring line "+str(i)+" in file '"+file+"': position '"+entries[1]+"' cannot be read",file=sys.stderr) continue # color color=(0,0,1) if len(entries)==3: color_str=entries[2].split(",") # skip line if improperly formatted if len(color_str)!=3: print("warning: ignoring line "+str(i)+" in file '"+file+"': color '"+entries[2]+"' does not have three components",file=sys.stderr) continue try: color=(float(color_str[0]),float(color_str[1]),float(color_str[2])) except: print("warning: ignoring line "+str(i)+" in file '"+file+"': color '"+entries[2]+"' cannot be read",file=sys.stderr) continue # candidate particle try: particle_type=int(entries[0]) except: print("warning: ignoring line "+str(i)+" in file '"+file+"': particle type '"+entries[0]+"' is not an integer",file=sys.stderr) continue if particle_type==CROSS_INDEX: candidate=Cross(pos.x,pos.y,color=color) elif particle_type==DISK_INDEX: candidate=Disk(pos.x,pos.y,color=color) elif particle_type==STAIRCASE_INDEX: candidate=Staircase(pos.x,pos.y,color=color) else: print("warning: ignoring line "+str(i)+" in file '"+file+"': unrecognized particle type: '"+entries[0]+"'",file=sys.stderr) continue if not self.check_interaction_any(candidate,Point(0,0)): # add to list self.particles.append(candidate) self.unselected.append(candidate) else: print("warning: ignoring line "+str(i)+" in file '"+file+"': particle overlaps with existing particles",file=sys.stderr) ff.close() self.draw() # export to tikz def export_tikz(self,file): # abort if no particles if len(self.particles)==0: return ff=open(file,"w") # header ff.write("\documentclass{standalone}\n") ff.write("\n") ff.write("\\usepackage[svgnames]{xcolor}\n") ff.write("\\usepackage{tikz}\n") ff.write("\\usepackage{jam}\n") ff.write("\n") ff.write("\\begin{document}\n") ff.write("\\begin{tikzpicture}\n") ff.write("\n") # write position of particles for particle in self.particles: if type(particle)==Cross: ff.write("\cross{"+colors.closest_color(particle.color,colors.xcolor_names)+"}") ff.write("{{({:05.2f},{:05.2f})}};\n".format(particle.elements[0].pos.x-self.particles[0].elements[0].pos.x,particle.elements[0].pos.y-self.particles[0].elements[0].pos.y)) ff.write("\\end{tikzpicture}\n") ff.write("\\end{document}\n") ff.write("\n") ff.close() # set zoom level def set_zoom(self,level): self.base_size=level*50 self.draw() # global variables (used like precompiler variables) CROSS_INDEX=1 DISK_INDEX=2 STAIRCASE_INDEX=2