Jam/painter.py

464 lines
18 KiB
Python

import sys
import math
from kivy.uix.widget import Widget
from kivy.core.window import Window
from kivy.graphics import Color,Line
from point import Point
from polyomino import Cross
from polyomino import Square_element
from tools import remove_fromlist
# painter class
class Painter(Widget):
def __init__(self,app,**kwargs):
# list of particles
self.particles=[]
# 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=[]
# 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)
def reset(self):
self.particles=[]
self.undermouse=None
self.draw()
# 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()
# draw grids
for particle in self.particles:
if particle.grid>0:
self.draw_grid(particle.squares[0].pos,particle.grid)
for particle in self.particles:
particle.draw(alpha=0.5)
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
# offest wrt 0
offset=(pos.x-0.5)%mesh
for i in range(math.floor((self.width/Square_element.size-offset)/mesh)+1):
Color(1,1,1)
Line(points=((i*mesh+offset)*Square_element.size,height_offset,(i*mesh+offset)*Square_element.size,self.height+height_offset))
# horizontal lines
# offset wrt 0
offset=(pos.y-0.5)%1-height_offset/Square_element.size
for i in range(math.floor((self.height/Square_element.size-offset)/mesh)+1):
Color(1,1,1)
Line(points=(0,(i*mesh+offset)*Square_element.size+height_offset,self.width,(i*mesh+offset)*Square_element.size+height_offset))
# respond to keyboard
def on_key_down(self, keyboard, keycode, text, modifiers):
# check the command_prompt is not focused
if not self.app.command_prompt.insert:
if keycode[1]=="shift":
if not 's' in self.modifiers:
self.modifiers.append('s')
self.modifiers.sort()
# remove particles
elif keycode[1]=="backspace":
for particle in self.selected:
self.particles=remove_fromlist(self.particles,particle)
self.selected=[]
self.draw()
# respond to keyboard (text input is modifier-sensitive, i.e. one can use shift)
def on_textinput(self, window, text):
# check the command_prompt is not focused
if not self.app.command_prompt.insert:
# select all
if text=="a":
for particle in self.particles:
particle.selected=True
self.selected=self.particles.copy()
self.unselected=[]
self.draw()
# toggle grid
elif text=="g":
for particle in self.selected:
if particle.grid==0:
particle.grid=1
else:
particle.grid=-particle.grid
self.draw()
# zoom
elif text=="+":
# increment by 10%
self.set_zoom(Square_element.size/50*1.1)
elif text=="-":
# decrease by 10%
self.set_zoom(Square_element.size/50*0.9)
elif text=="=":
# reset
self.set_zoom(1)
# set reference
elif text=="r":
if len(self.selected)>0:
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):
# create new cross
if touch.button=="right":
new=Cross(touch.x/Square_element.size,touch.y/Square_element.size)
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(touch.x/Square_element.size,touch.y/Square_element.size))
# record relative position of click with respect to reference
if self.undermouse!=None:
self.offset=Point(touch.x/Square_element.size,touch.y/Square_element.size)-self.undermouse.squares[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):
# 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(touch.x/Square_element.size,touch.y/Square_element.size)-(self.offset+self.undermouse.squares[0].pos),0)
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 square in particle.squares:
if square.check_interaction(element.pos+offset):
return True
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.squares:
# 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)<abs(actual_delta.x):
actual_delta.x=adjusted_delta.x
if abs(adjusted_delta.y)<abs(actual_delta.y):
actual_delta.y=adjusted_delta.y
# try to move by actual_delta
if not self.check_interaction_unselected_list(self.selected,actual_delta):
return actual_delta
else:
# cannot move particles at all, try again
# give up if tried too many times
if recursion_depth>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.squares:
# move would make element overlap with obstacle
if obstacle.check_interaction(element.pos+delta):
accept_newpos=False
# check if particle already touches obstacle
if obstacle.check_touch(element.pos):
# move along obstacle while remaining stuck
newdelta=obstacle.move_along(delta,element.pos)
else:
newdelta=obstacle.move_on_line_to_stick(element.pos,delta)
if not self.check_interaction_unselected_element(element,newdelta):
return newdelta
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):
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()
# write configuration to file
def write(self,file):
ff=open(file,"w")
for particle in self.particles:
if type(particle)==Cross:
ff.write("{:d};".format(CROSS_INDEX))
ff.write("{:05.2f},{:05.2f};{:3.1f},{:3.1f},{:3.1f}\n".format(particle.squares[0].pos.x,particle.squares[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
entries=line.split(";")
# skip line if improperly formatted
if len(entries)>3:
print("warning: ignoring line "+str(i)+" in file '"+file+"': more than three ';' spearated entries in '"+line+"'",file=sys.stderr)
if len(entries)<2:
print("warning: ignoring line "+str(i)+" in file '"+file+"': fewer than two ';' spearated 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)
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()
# set zoom level
def set_zoom(self,level):
Square_element.size=level*50
self.draw()
# global variables (used like precompiler variables)
CROSS_INDEX=1