OSDN Git Service

brushlib splitoff (moved some code from lib into brushlib)
[mypaint-anime/master.git] / lib / brush.py
1 # This file is part of MyPaint.
2 # Copyright (C) 2007 by Martin Renold <martinxyz@gmx.ch>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License.
6 # This program is distributed in the hope that it will be useful,
7 # but WITHOUT ANY WARRANTY. See the COPYING file for more details.
8
9 "interface to MyBrush; hiding some C implementation details"
10 # FIXME: bad file name, saying nothing about what's in here
11 # FIXME: should split brush_lowlevel into its own gtk-independent module
12 import mypaintlib
13 from brushlib import brushsettings
14 import gtk, string, os, colorsys
15 from helpers import clamp
16
17 preview_w = 128
18 preview_h = 128
19 thumb_w = 64
20 thumb_h = 64
21
22 current_brushfile_version = 2
23
24 def pixbuf_scale_nostretch_centered(src, dst):
25     scale_x = float(dst.get_width()) / src.get_width()
26     scale_y = float(dst.get_height()) / src.get_height()
27     offset_x = 0
28     offset_y = 0
29     if scale_x > scale_y: 
30         scale = scale_y
31         offset_x = (dst.get_width() - src.get_width() * scale) / 2
32     else:
33         scale = scale_x
34         offset_y = (dst.get_height() - src.get_height() * scale) / 2
35
36     src.scale(dst, 0, 0, dst.get_width(), dst.get_height(),
37               offset_x, offset_y, scale, scale,
38               gtk.gdk.INTERP_BILINEAR)
39
40 # points = [(x1, y1), (x2, y2), ...] (at least two points, or None)
41 class Setting:
42     "a specific setting for a specific brush"
43     def __init__(self, setting, parent_brush, observers):
44         self.setting = setting
45         self.brush = parent_brush
46         self.observers = observers
47         self.base_value = None
48         self.set_base_value(setting.default)
49         self.points = [[] for i in xrange(len(brushsettings.inputs))]
50         if setting.cname == 'opaque_multiply':
51             # make opaque depend on pressure by default
52             for i in brushsettings.inputs:
53                 if i.name == 'pressure': break
54             self.set_points(i, [(0.0, 0.0), (1.0, 1.0)])
55     def set_base_value(self, value):
56         if self.base_value == value: return
57         self.base_value = value
58         self.brush.set_base_value(self.setting.index, value)
59         for f in self.observers: f()
60     def has_only_base_value(self):
61         for i in brushsettings.inputs:
62             if self.has_input(i):
63                 return False
64         return True
65     def has_input(self, input):
66         return self.points[input.index]
67     def has_input_nonlinear(self, input):
68         points = self.points[input.index]
69         if not points: return False
70         if len(points) > 2: return True
71         # also if it is linear but the x-axis was changed (hm, bad function name)
72         if abs(points[0][0] - input.soft_min) > 0.001: return True
73         if abs(points[1][0] - input.soft_max) > 0.001: return True
74         return False
75
76     def set_points(self, input, points):
77         assert len(points) != 1
78         if self.points[input.index] == points: return
79         #if len(points) > 2:
80         #    print 'set_points[%s](%s, %s)' % (self.setting.cname, input.name, points)
81
82         self.brush.set_mapping_n(self.setting.index, input.index, len(points))
83         for i, (x, y) in enumerate(points):
84             self.brush.set_mapping_point(self.setting.index, input.index, i, x, y)
85
86         self.points[input.index] = points[:] # copy
87         for f in self.observers: f()
88
89     def copy_from(self, other):
90         error = self.load_from_string(other.save_to_string(), version=current_brushfile_version)
91         assert not error, error
92     def save_to_string(self):
93         s = str(self.base_value)
94         for i in brushsettings.inputs:
95             points = self.points[i.index]
96             if points:
97                 s += ' | ' + i.name + ' ' + ', '.join(['(%f %f)' % xy for xy in points])
98         return s
99     def load_from_string(self, s, version):
100         error = None
101         parts = s.split('|')
102         self.set_base_value(float(parts[0]))
103         for i in brushsettings.inputs:
104             self.set_points(i, [])
105         for part in parts[1:]:
106             command, args = part.strip().split(' ', 1)
107             if version <= 1 and command == 'speed': command = 'speed1'
108             i = brushsettings.inputs_dict.get(command)
109             if i:
110                 if version <= 1:
111                     points_old = [float(f) for f in args.split()]
112                     points = [(0, 0)]
113                     while points_old:
114                         x = points_old.pop(0)
115                         y = points_old.pop(0)
116                         if x == 0: break
117                         assert x > points[-1][0]
118                         points.append((x, y))
119                 else:
120                     points = []
121                     for s in args.split(', '):
122                         s = s.strip()
123                         if not (s.startswith('(') and s.endswith(')') and ' ' in s):
124                             return '(x y) expected, got "%s"' % s
125                         s = s[1:-1]
126                         try:
127                             x, y = [float(ss) for ss in s.split(' ')]
128                         except:
129                             print s
130                             raise
131                         points.append((x, y))
132                 assert len(points) >= 2
133                 self.set_points(i, points)
134             else:
135                 error = 'unknown input "%s"' % command
136         return error
137     def transform_y(self, func):
138         # useful for migration from a earlier version
139         self.set_base_value(func(self.base_value))
140         for i in brushsettings.inputs:
141             if not self.points[i.index]: continue
142             points = self.points[i.index]
143             points = [(x, func(y)) for x, y in points]
144             self.set_points(i, points)
145
146 class Brush_Lowlevel(mypaintlib.Brush):
147     def __init__(self):
148         mypaintlib.Brush.__init__(self)
149         self.settings_observers = []
150         self.settings_observers_hidden = []
151         self.settings = []
152         for s in brushsettings.settings:
153             self.settings.append(Setting(s, self, self.settings_observers))
154
155         self.saved_string = None
156         self.settings_observers.append(self.invalidate_saved_string)
157
158     def invalidate_saved_string(self):
159         self.saved_string = None
160
161     def begin_atomic(self):
162         self.settings_observers_hidden.append(self.settings_observers[:])
163         del self.settings_observers[:]
164
165     def end_atomic(self):
166         self.settings_observers[:] = self.settings_observers_hidden.pop()
167         for f in self.settings_observers: f()
168
169
170     def get_stroke_bbox(self):
171         bbox = self.stroke_bbox
172         return bbox.x, bbox.y, bbox.w, bbox.h
173
174     def setting_by_cname(self, cname):
175         s = brushsettings.settings_dict[cname]
176         return self.settings[s.index]
177
178     def save_to_string(self):
179         # OPTIMIZE: this cache could be more useful, the current "copy_settings_from()"
180         #           brush selection mechanism invalidates it at every brush change
181         if self.saved_string: return self.saved_string
182         res  = '# mypaint brush file\n'
183         res += '# you can edit this file and then select the brush in mypaint (again) to reload\n'
184         res += 'version %d\n' % current_brushfile_version
185         for s in brushsettings.settings:
186             res += s.cname + ' ' + self.settings[s.index].save_to_string() + '\n'
187         self.saved_string = res
188         return res
189
190     def load_from_string(self, s):
191         self.begin_atomic()
192         num_found = 0
193         errors = []
194         version = 1 # for files without a 'version' field
195         for line in s.split('\n'):
196             line = line.strip()
197             if line.startswith('#'): continue
198             if not line: continue
199             try:
200                 command, rest = line.split(' ', 1)
201                 error = None
202
203                 if command in brushsettings.settings_dict:
204                     setting = self.setting_by_cname(command)
205                     error = setting.load_from_string(rest, version)
206                 elif command in brushsettings.settings_migrate:
207                     command_new, transform_func = brushsettings.settings_migrate[command]
208                     setting = self.setting_by_cname(command_new)
209                     error = setting.load_from_string(rest, version)
210                     if transform_func:
211                         setting.transform_y(transform_func)
212                 elif command == 'version':
213                     version = int(rest)
214                     if version > current_brushfile_version:
215                         error = 'this brush was saved with a more recent version of mypaint'
216                 elif version <= 1 and command == 'color':
217                     self.set_color_rgb([int(s)/255.0 for s in rest.split()])
218                 elif version <= 1 and command == 'change_radius':
219                     if rest != '0.0': error = 'change_radius is not supported any more'
220                 elif version <= 2 and command == 'adapt_color_from_image':
221                     if rest != '0.0': error = 'adapt_color_from_image is obsolete, ignored; use smudge and smudge_length instead'
222                 elif version <= 1 and command == 'painting_time':
223                     pass
224                 else:
225                     error = 'unknown command, line ignored'
226
227                 if error:
228                     errors.append((line, error))
229
230             except Exception, e:
231                 errors.append((line, str(e)))
232             else:
233                 num_found += 1
234         if num_found == 0:
235             errors.append(('', 'there was only garbage in this file, using defaults'))
236         self.end_atomic()
237         return errors
238
239     def copy_settings_from(self, other):
240         self.begin_atomic()
241         for i, setting in enumerate(self.settings):
242             setting.copy_from(other.settings[i])
243         self.end_atomic()
244
245     def get_color_hsv(self):
246         h = self.setting_by_cname('color_h').base_value
247         s = self.setting_by_cname('color_s').base_value
248         v = self.setting_by_cname('color_v').base_value
249         return (h, s, v)
250
251     def set_color_hsv(self, hsv):
252         self.begin_atomic()
253         h, s, v = hsv
254         self.setting_by_cname('color_h').set_base_value(h)
255         self.setting_by_cname('color_s').set_base_value(s)
256         self.setting_by_cname('color_v').set_base_value(v)
257         self.end_atomic()
258
259     def set_color_rgb(self, rgb):
260         for i in range(3): assert rgb[i] <= 1.0
261         self.set_color_hsv(colorsys.rgb_to_hsv(*rgb))
262
263     def get_color_rgb(self):
264         hsv = self.get_color_hsv()
265         hsv = [clamp(x, 0.0, 1.0) for x in hsv]
266         return colorsys.hsv_to_rgb(*hsv)
267
268     def invert_color(self):
269         rgb = self.get_color_rgb()
270         rgb = [1-x for x in rgb]
271         self.set_color_rgb(rgb)
272
273
274 class Brush(Brush_Lowlevel):
275     def __init__(self, app):
276         Brush_Lowlevel.__init__(self)
277         self.app = app
278         self.preview = None
279         self.preview_thumb = None
280         self.name = None
281         self.preview_changed = True
282
283         self.settings_mtime = None
284         self.preview_mtime = None
285
286     def get_fileprefix(self, saving=False):
287         prefix = 'b'
288         if os.path.realpath(self.app.user_brushpath) == os.path.realpath(self.app.stock_brushpath):
289         #if os.path.samefile(self.app.user_brushpath, self.app.stock_brushpath):
290             # working directly on brush collection, use different prefix
291             prefix = 's'
292
293         if not self.name:
294             i = 0
295             while 1:
296                 self.name = '%s%03d' % (prefix, i)
297                 a = os.path.join(self.app.user_brushpath,self.name + '.myb')
298                 b = os.path.join(self.app.stock_brushpath,self.name + '.myb')
299                 if not os.path.isfile(a) and not os.path.isfile(b):
300                     break
301                 i += 1
302         prefix = os.path.join(self.app.user_brushpath, self.name)
303         if saving: 
304             return prefix
305         if not os.path.isfile(prefix + '.myb'):
306             prefix = os.path.join(self.app.stock_brushpath,self.name)
307         assert os.path.isfile(prefix + '.myb'), 'brush "' + self.name + '" not found'
308         return prefix
309
310     def delete_from_disk(self):
311         prefix = os.path.join(self.app.user_brushpath, self.name)
312         if os.path.isfile(prefix + '.myb'):
313             os.remove(prefix + '_prev.png')
314             os.remove(prefix + '.myb')
315
316         prefix = os.path.join(self.app.stock_brushpath, self.name)
317         if os.path.isfile(prefix + '.myb'):
318             # user wants to delete a stock brush
319             # cannot remove the file, manage blacklist instead
320             filename = os.path.join(self.app.user_brushpath, 'deleted.conf')
321             new = not os.path.isfile(filename)
322             f = open(filename, 'a')
323             if new:
324                 f.write('# list of stock brush names which you have deleted\n')
325                 f.write('# you can remove this file to get all of them back\n')
326             f.write(self.name + '\n')
327             f.close()
328
329     def remember_mtimes(self):
330         prefix = self.get_fileprefix()
331         self.preview_mtime = os.path.getmtime(prefix + '_prev.png')
332         self.settings_mtime = os.path.getmtime(prefix + '.myb')
333
334     def has_changed_on_disk(self):
335         prefix = self.get_fileprefix()
336         if self.preview_mtime != os.path.getmtime(prefix + '_prev.png'): return True
337         if self.settings_mtime != os.path.getmtime(prefix + '.myb'): return True
338         return False
339
340     def save(self):
341         prefix = self.get_fileprefix(saving=True)
342         if self.preview_changed:
343             self.preview.save(prefix + '_prev.png', 'png')
344             self.preview_changed = False
345         open(prefix + '.myb', 'w').write(self.save_to_string())
346         self.remember_mtimes()
347
348     def load(self, name):
349         self.name = name
350         prefix = self.get_fileprefix()
351
352         filename = prefix + '_prev.png'
353         pixbuf = gtk.gdk.pixbuf_new_from_file(filename)
354         self.update_preview(pixbuf)
355
356         if prefix.startswith(self.app.user_brushpath):
357             self.preview_changed = False
358         else:
359             # for saving, create the preview file even if not changed
360             self.preview_changed = True
361
362         filename = prefix + '.myb'
363         errors = self.load_from_string(open(filename).read())
364         if errors:
365             print '%s:' % filename
366             for line, reason in errors:
367                 print line
368                 print '==>', reason
369             print
370
371         self.remember_mtimes()
372
373     def reload_if_changed(self):
374         if self.settings_mtime is None: return
375         if self.preview_mtime is None: return
376         if not self.name: return
377         prefix = self.get_fileprefix()
378         if not self.has_changed_on_disk(): return False
379         print 'Brush "' + self.name + '" has changed on disk, reloading it.'
380         self.load(self.name)
381         return True
382
383     def update_preview(self, pixbuf):
384         self.preview = pixbuf
385         self.preview_thumb = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB, False, 8, thumb_w, thumb_h)
386         pixbuf_scale_nostretch_centered(src=pixbuf, dst=self.preview_thumb)
387         self.preview_changed = True
388