OSDN Git Service

save_ora: fix translation of rendered background
[mypaint-anime/master.git] / lib / document.py
1 # This file is part of MyPaint.
2 # Copyright (C) 2007-2008 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 as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8
9 import os, zipfile, tempfile, time, traceback
10 join = os.path.join
11 from cStringIO import StringIO
12 import xml.etree.ElementTree as ET
13 from gtk import gdk
14 import gobject, numpy
15 from gettext import gettext as _
16
17 import helpers, tiledsurface, pixbufsurface, backgroundsurface, mypaintlib
18 import command, stroke, layer
19 import brush
20
21 N = tiledsurface.N
22 LOAD_CHUNK_SIZE = 64*1024
23
24 class SaveLoadError(Exception):
25     """Expected errors on loading or saving, like missing permissions or non-existing files."""
26     pass
27
28 class Document():
29     """
30     This is the "model" in the Model-View-Controller design.
31     (The "view" would be ../gui/tileddrawwidget.py.)
32     It represents everything that the user would want to save.
33
34
35     The "controller" mostly in drawwindow.py.
36     It is possible to use it without any GUI attached (see ../tests/)
37     """
38     # Please note the following difficulty with the undo stack:
39     #
40     #   Most of the time there is an unfinished (but already rendered)
41     #   stroke pending, which has to be turned into a command.Action
42     #   or discarded as empty before any other action is possible.
43     #   (split_stroke)
44
45     def __init__(self, brushinfo=None):
46         if not brushinfo:
47             brushinfo = brush.BrushInfo()
48             brushinfo.load_defaults()
49         self.brush = brush.Brush(brushinfo)
50         self.stroke = None
51         self.canvas_observers = []
52         self.stroke_observers = [] # callback arguments: stroke, brush (brush is a temporary read-only convenience object)
53         self.doc_observers = []
54         self.frame_observers = []
55         self.clear(True)
56
57         self._frame = [0, 0, 0, 0]
58         self._frame_enabled = False
59         # Used by move_frame() to accumulate values
60         self._frame_dx = 0.0
61         self._frame_dy = 0.0
62
63     def get_frame(self):
64         return self._frame
65
66     def move_frame(self, dx=0.0, dy=0.0):
67         """Move the frame. Accumulates changes and moves the frame once
68         the accumulated change reaches the minimum move step."""
69         # FIXME: Should be 1 (pixel aligned), not tile aligned
70         # This is due to PNG saving having to be tile aligned
71         min_step = N
72
73         def round_to_n(value, n):
74             return int(round(value/n)*n)
75
76         x, y, w, h = self.get_frame()
77
78         self._frame_dx += dx
79         self._frame_dy += dy
80         step_x = round_to_n(self._frame_dx, min_step)
81         step_y = round_to_n(self._frame_dy, min_step)
82
83         if step_x:
84             self.set_frame(x=x+step_x)
85             self._frame_dx -= step_x
86
87         if step_y:
88             self.set_frame(y=y+step_y)
89             self._frame_dy -= step_y
90
91     def set_frame(self, x=None, y=None, width=None, height=None):
92         """Set the size of the frame. Pass None to indicate no-change."""
93
94         for i, var in enumerate([x, y, width, height]):
95             if not var is None:
96                 # FIXME: must be aligned to tile size due to PNG saving
97                 assert not var % N, "Frame size must be aligned to tile size"
98                 self._frame[i] = var
99
100         for f in self.frame_observers: f()
101
102     def get_frame_enabled(self):
103         return self._frame_enabled
104
105     def set_frame_enabled(self, enabled):
106         self._frame_enabled = enabled
107         for f in self.frame_observers: f()
108     frame_enabled = property(get_frame_enabled)
109
110     def call_doc_observers(self):
111         for f in self.doc_observers:
112             f(self)
113         return True
114
115     def clear(self, init=False):
116         self.split_stroke()
117         if not init:
118             bbox = self.get_bbox()
119         # throw everything away, including undo stack
120         self.command_stack = command.CommandStack()
121         self.set_background((255, 255, 255))
122         self.layers = []
123         self.layer_idx = None
124         self.add_layer(0)
125         # disallow undo of the first layer
126         self.command_stack.clear()
127         self.unsaved_painting_time = 0.0
128
129         if not init:
130             for f in self.canvas_observers:
131                 f(*bbox)
132
133         self.call_doc_observers()
134
135     def get_current_layer(self):
136         return self.layers[self.layer_idx]
137     layer = property(get_current_layer)
138
139     def split_stroke(self):
140         if not self.stroke: return
141         self.stroke.stop_recording()
142         if not self.stroke.empty:
143             self.command_stack.do(command.Stroke(self, self.stroke, self.snapshot_before_stroke))
144             del self.snapshot_before_stroke
145             self.unsaved_painting_time += self.stroke.total_painting_time
146             for f in self.stroke_observers:
147                 f(self.stroke, self.brush)
148         self.stroke = None
149
150     def select_layer(self, idx):
151         self.do(command.SelectLayer(self, idx))
152
153     def move_layer(self, was_idx, new_idx):
154         self.do(command.MoveLayer(self, was_idx, new_idx))
155
156     def clear_layer(self):
157         if not self.layer.surface.is_empty():
158             self.do(command.ClearLayer(self))
159
160     def stroke_to(self, dtime, x, y, pressure, xtilt,ytilt):
161         if not self.stroke:
162             self.stroke = stroke.Stroke()
163             self.stroke.start_recording(self.brush)
164             self.snapshot_before_stroke = self.layer.save_snapshot()
165         self.stroke.record_event(dtime, x, y, pressure, xtilt,ytilt)
166
167         l = self.layer
168         l.surface.begin_atomic()
169         split = self.brush.stroke_to (l.surface, x, y, pressure, xtilt,ytilt, dtime)
170         l.surface.end_atomic()
171
172         if split:
173             self.split_stroke()
174
175     def straight_line(self, src, dst):
176         self.split_stroke()
177         self.brush.reset() # reset dynamic states (eg. filtered velocity)
178
179         duration = 3.0
180         pressure = 0.3
181         N = 1000
182         x = numpy.linspace(src[0], dst[0], N)
183         y = numpy.linspace(src[1], dst[1], N)
184         # rest the brush in src for a minute, to avoid interpolation
185         # from the upper left corner (states are zero) (FIXME: the
186         # brush should handle this on its own, maybe?)
187         self.stroke_to(60.0, x[0], y[0], 0.0, 0.0, 0.0)
188         for i in xrange(N):
189             self.stroke_to(duration/N, x[i], y[i], pressure, 0.0, 0.0)
190         self.split_stroke()
191         self.brush.reset()
192
193
194     def layer_modified_cb(self, *args):
195         # for now, any layer modification is assumed to be visible
196         for f in self.canvas_observers:
197             f(*args)
198
199     def invalidate_all(self):
200         for f in self.canvas_observers:
201             f(0, 0, 0, 0)
202
203     def undo(self):
204         self.split_stroke()
205         while 1:
206             cmd = self.command_stack.undo()
207             if not cmd or not cmd.automatic_undo:
208                 return cmd
209
210     def redo(self):
211         self.split_stroke()
212         while 1:
213             cmd = self.command_stack.redo()
214             if not cmd or not cmd.automatic_undo:
215                 return cmd
216
217     def do(self, cmd):
218         self.split_stroke()
219         self.command_stack.do(cmd)
220
221     def get_last_command(self):
222         self.split_stroke()
223         return self.command_stack.get_last_command()
224
225     def get_bbox(self):
226         res = helpers.Rect()
227         for layer in self.layers:
228             # OPTIMIZE: only visible layers...
229             # careful: currently saving assumes that all layers are included
230             bbox = layer.surface.get_bbox()
231             res.expandToIncludeRect(bbox)
232         return res
233
234     def get_effective_bbox(self):
235         """Return the effective bounding box of the document.
236         If the frame is enabled, this is the bounding box of the frame, 
237         else the (dynamic) bounding box of the document."""
238         return self.get_frame() if self.frame_enabled else self.get_bbox()
239
240     def blit_tile_into(self, dst_8bit, tx, ty, mipmap_level=0, layers=None, background=None):
241         if layers is None:
242             layers = self.layers
243         if background is None:
244             background = self.background
245
246         assert dst_8bit.dtype == 'uint8'
247         dst = numpy.empty((N, N, 3), dtype='uint16')
248
249         background.blit_tile_into(dst, tx, ty, mipmap_level)
250
251         for layer in layers:
252             surface = layer.surface
253             surface.composite_tile_over(dst, tx, ty, mipmap_level=mipmap_level, opacity=layer.effective_opacity)
254
255         mypaintlib.tile_convert_rgb16_to_rgb8(dst, dst_8bit)
256
257     def add_layer(self, insert_idx=None, after=None, name=''):
258         self.do(command.AddLayer(self, insert_idx, after, name))
259
260     def remove_layer(self,layer=None):
261         if len(self.layers) > 1:
262             self.do(command.RemoveLayer(self,layer))
263         else:
264             self.clear_layer()
265
266     def merge_layer_down(self):
267         dst_idx = self.layer_idx - 1
268         if dst_idx < 0:
269             return False
270         self.do(command.MergeLayer(self, dst_idx))
271         return True
272
273     def load_layer_from_pixbuf(self, pixbuf, x=0, y=0):
274         arr = helpers.gdkpixbuf2numpy(pixbuf)
275         self.do(command.LoadLayer(self, arr, x, y))
276
277     def set_layer_visibility(self, visible, layer):
278         cmd = self.get_last_command()
279         if isinstance(cmd, command.SetLayerVisibility) and cmd.layer is layer:
280             self.undo()
281         self.do(command.SetLayerVisibility(self, visible, layer))
282
283     def set_layer_locked(self, locked, layer):
284         cmd = self.get_last_command()
285         if isinstance(cmd, command.SetLayerLocked) and cmd.layer is layer:
286             self.undo()
287         self.do(command.SetLayerLocked(self, locked, layer))
288
289     def set_layer_opacity(self, opacity, layer=None):
290         """Sets the opacity of a layer. If layer=None, works on the current layer"""
291         cmd = self.get_last_command()
292         if isinstance(cmd, command.SetLayerOpacity):
293             self.undo()
294         self.do(command.SetLayerOpacity(self, opacity, layer))
295
296     def set_background(self, obj):
297         # This is not an undoable action. One reason is that dragging
298         # on the color chooser would get tons of undo steps.
299
300         if not isinstance(obj, backgroundsurface.Background):
301             obj = backgroundsurface.Background(obj)
302         self.background = obj
303
304         self.invalidate_all()
305
306     def load_from_pixbuf(self, pixbuf):
307         """Load a document from a pixbuf."""
308         self.clear()
309         self.load_layer_from_pixbuf(pixbuf)
310         self.set_frame(*self.get_bbox())
311
312     def is_layered(self):
313         count = 0
314         for l in self.layers:
315             if not l.surface.is_empty():
316                 count += 1
317         return count > 1
318
319     def is_empty(self):
320         return len(self.layers) == 1 and self.layer.surface.is_empty()
321
322     def save(self, filename, **kwargs):
323         self.split_stroke()
324         trash, ext = os.path.splitext(filename)
325         ext = ext.lower().replace('.', '')
326         save = getattr(self, 'save_' + ext, self.unsupported)
327         try:        
328             save(filename, **kwargs)
329         except gobject.GError, e:
330             traceback.print_exc()
331             if e.code == 5:
332                 #add a hint due to a very consfusing error message when there is no space left on device
333                 raise SaveLoadError, _('Unable to save: %s\nDo you have enough space left on the device?') % e.message
334             else:
335                 raise SaveLoadError, _('Unable to save: %s') % e.message
336         except IOError, e:
337             traceback.print_exc()
338             raise SaveLoadError, _('Unable to save: %s') % e.strerror
339         self.unsaved_painting_time = 0.0
340
341     def load(self, filename, **kwargs):
342         if not os.path.isfile(filename):
343             raise SaveLoadError, _('File does not exist: %s') % repr(filename)
344         if not os.access(filename,os.R_OK):
345             raise SaveLoadError, _('You do not have the necessary permissions to open file: %s') % repr(filename)
346         trash, ext = os.path.splitext(filename)
347         ext = ext.lower().replace('.', '')
348         load = getattr(self, 'load_' + ext, self.unsupported)
349         try:
350             load(filename, **kwargs)
351         except gobject.GError, e:
352             traceback.print_exc()
353             raise SaveLoadError, _('Error while loading: GError %s') % e
354         except IOError, e:
355             traceback.print_exc()
356             raise SaveLoadError, _('Error while loading: IOError %s') % e
357         self.command_stack.clear()
358         self.unsaved_painting_time = 0.0
359         self.call_doc_observers()
360
361     def unsupported(self, filename, *args, **kwargs):
362         raise SaveLoadError, _('Unknown file format extension: %s') % repr(filename)
363
364     def render_as_pixbuf(self, *args, **kwargs):
365         return pixbufsurface.render_as_pixbuf(self, *args, **kwargs)
366
367     def render_thumbnail(self):
368         t0 = time.time()
369         x, y, w, h = self.get_effective_bbox()
370         mipmap_level = 0
371         while mipmap_level < tiledsurface.MAX_MIPMAP_LEVEL and max(w, h) >= 512:
372             mipmap_level += 1
373             x, y, w, h = x/2, y/2, w/2, h/2
374
375         pixbuf = self.render_as_pixbuf(x, y, w, h, mipmap_level=mipmap_level)
376         assert pixbuf.get_width() == w and pixbuf.get_height() == h
377         pixbuf = helpers.scale_proportionally(pixbuf, 256, 256)
378         print 'Rendered thumbnail in', time.time() - t0, 'seconds.'
379         return pixbuf
380
381     def save_png(self, filename, alpha=False, multifile=False, **kwargs):
382         doc_bbox = self.get_effective_bbox()
383         if multifile:
384             self.save_multifile_png(filename, **kwargs)
385         else:
386             if alpha:
387                 tmp_layer = layer.Layer()
388                 for l in self.layers:
389                     l.merge_into(tmp_layer)
390                 tmp_layer.surface.save(filename, *doc_bbox)
391             else:
392                 pixbufsurface.save_as_png(self, filename, *doc_bbox, alpha=False, **kwargs)
393
394     def save_multifile_png(self, filename, alpha=False, **kwargs):
395         prefix, ext = os.path.splitext(filename)
396         # if we have a number already, strip it
397         l = prefix.rsplit('.', 1)
398         if l[-1].isdigit():
399             prefix = l[0]
400         doc_bbox = self.get_effective_bbox()
401         for i, l in enumerate(self.layers):
402             filename = '%s.%03d%s' % (prefix, i+1, ext)
403             l.surface.save(filename, *doc_bbox, **kwargs)
404
405     @staticmethod
406     def _pixbuf_from_stream(fp, feedback_cb=None):
407         loader = gdk.PixbufLoader()
408         while True:
409             if feedback_cb is not None:
410                 feedback_cb()
411             buf = fp.read(LOAD_CHUNK_SIZE)
412             if buf == '':
413                 break
414             loader.write(buf)
415         loader.close()
416         return loader.get_pixbuf()
417
418     def load_from_pixbuf_file(self, filename, feedback_cb=None):
419         fp = open(filename, 'rb')
420         pixbuf = self._pixbuf_from_stream(fp, feedback_cb)
421         fp.close()
422         self.load_from_pixbuf(pixbuf)
423
424     load_png = load_from_pixbuf_file
425     load_jpg = load_from_pixbuf_file
426     load_jpeg = load_from_pixbuf_file
427
428     def save_jpg(self, filename, quality=90, **kwargs):
429         doc_bbox = self.get_effective_bbox()
430         pixbuf = self.render_as_pixbuf(*doc_bbox, **kwargs)
431         pixbuf.save(filename, 'jpeg', options={'quality':str(quality)})
432
433     save_jpeg = save_jpg
434
435     def save_ora(self, filename, options=None, **kwargs):
436         print 'save_ora:'
437         t0 = time.time()
438         tempdir = tempfile.mkdtemp('mypaint')
439         # use .tmp extension, so we don't overwrite a valid file if there is an exception
440         z = zipfile.ZipFile(filename + '.tmpsave', 'w', compression=zipfile.ZIP_STORED)
441         # work around a permission bug in the zipfile library: http://bugs.python.org/issue3394
442         def write_file_str(filename, data):
443             zi = zipfile.ZipInfo(filename)
444             zi.external_attr = 0100644 << 16
445             z.writestr(zi, data)
446         write_file_str('mimetype', 'image/openraster') # must be the first file
447         image = ET.Element('image')
448         stack = ET.SubElement(image, 'stack')
449         x0, y0, w0, h0 = self.get_effective_bbox()
450         a = image.attrib
451         a['w'] = str(w0)
452         a['h'] = str(h0)
453
454         def store_pixbuf(pixbuf, name):
455             tmp = join(tempdir, 'tmp.png')
456             t1 = time.time()
457             pixbuf.save(tmp, 'png')
458             print '  %.3fs pixbuf saving %s' % (time.time() - t1, name)
459             z.write(tmp, name)
460             os.remove(tmp)
461
462         def store_surface(surface, name, rect=[]):
463             tmp = join(tempdir, 'tmp.png')
464             t1 = time.time()
465             surface.save(tmp, *rect, **kwargs)
466             print '  %.3fs surface saving %s' % (time.time() - t1, name)
467             z.write(tmp, name)
468             os.remove(tmp)
469
470         def add_layer(x, y, opac, surface, name, layer_name, visible=True, rect=[]):
471             layer = ET.Element('layer')
472             stack.append(layer)
473             store_surface(surface, name, rect)
474             a = layer.attrib
475             if layer_name:
476                 a['name'] = layer_name
477             a['src'] = name
478             a['x'] = str(x)
479             a['y'] = str(y)
480             a['opacity'] = str(opac)
481             if visible:
482                 a['visibility'] = 'visible'
483             else:
484                 a['visibility'] = 'hidden'
485             return layer
486
487         for idx, l in enumerate(reversed(self.layers)):
488             if l.surface.is_empty():
489                 continue
490             opac = l.opacity
491             x, y, w, h = l.surface.get_bbox()
492             el = add_layer(x-x0, y-y0, opac, l.surface, 'data/layer%03d.png' % idx, l.name, l.visible, rect=(x, y, w, h))
493             # strokemap
494             sio = StringIO()
495             l.save_strokemap_to_file(sio, -x, -y)
496             data = sio.getvalue(); sio.close()
497             name = 'data/layer%03d_strokemap.dat' % idx
498             el.attrib['mypaint_strokemap_v2'] = name
499             write_file_str(name, data)
500
501         # save background as layer (solid color or tiled)
502         bg = self.background
503         # save as fully rendered layer
504         x, y, w, h = self.get_bbox()
505         l = add_layer(x-x0, y-y0, 1.0, bg, 'data/background.png', 'background', rect=(x,y,w,h))
506         x, y, w, h = bg.get_pattern_bbox()
507         # save as single pattern (with corrected origin)
508         store_surface(bg, 'data/background_tile.png', rect=(x+x0, y+y0, w, h))
509         l.attrib['background_tile'] = 'data/background_tile.png'
510
511         # preview (256x256)
512         t2 = time.time()
513         print '  starting to render full image for thumbnail...'
514
515         thumbnail_pixbuf = self.render_thumbnail()
516         store_pixbuf(thumbnail_pixbuf, 'Thumbnails/thumbnail.png')
517         print '  total %.3fs spent on thumbnail' % (time.time() - t2)
518
519         helpers.indent_etree(image)
520         xml = ET.tostring(image, encoding='UTF-8')
521
522         write_file_str('stack.xml', xml)
523         z.close()
524         os.rmdir(tempdir)
525         if os.path.exists(filename):
526             os.remove(filename) # windows needs that
527         os.rename(filename + '.tmpsave', filename)
528
529         print '%.3fs save_ora total' % (time.time() - t0)
530
531         return thumbnail_pixbuf
532
533     def load_ora(self, filename, feedback_cb=None):
534         """Loads from an OpenRaster file"""
535         print 'load_ora:'
536         t0 = time.time()
537         z = zipfile.ZipFile(filename)
538         print 'mimetype:', z.read('mimetype').strip()
539         xml = z.read('stack.xml')
540         image = ET.fromstring(xml)
541         stack = image.find('stack')
542
543         w = int(image.attrib['w'])
544         h = int(image.attrib['h'])
545
546         def round_up_to_n(value, n):
547             assert value >= 0, "function undefined for negative numbers"
548
549             residual = value % n
550             if residual:
551                 value = value - residual + n
552             return int(value)
553
554         def get_pixbuf(filename):
555             t1 = time.time()
556
557             try:
558                 fp = z.open(filename, mode='r')
559             except KeyError:
560                 # support for bad zip files (saved by old versions of the GIMP ORA plugin)
561                 fp = z.open(filename.encode('utf-8'), mode='r')
562                 print 'WARNING: bad OpenRaster ZIP file. There is an utf-8 encoded filename that does not have the utf-8 flag set:', repr(filename)
563
564             res = self._pixbuf_from_stream(fp, feedback_cb)
565             fp.close()
566             print '  %.3fs loading %s' % (time.time() - t1, filename)
567             return res
568
569         def get_layers_list(root, x=0,y=0):
570             res = []
571             for item in root:
572                 if item.tag == 'layer':
573                     if 'x' in item.attrib:
574                         item.attrib['x'] = int(item.attrib['x']) + x
575                     if 'y' in item.attrib:
576                         item.attrib['y'] = int(item.attrib['y']) + y
577                     res.append(item)
578                 elif item.tag == 'stack':
579                     stack_x = int( item.attrib.get('x', 0) )
580                     stack_y = int( item.attrib.get('y', 0) )
581                     res += get_layers_list(item, stack_x, stack_y)
582                 else:
583                     print 'Warning: ignoring unsupported tag:', item.tag
584             return res
585
586         self.clear() # this leaves one empty layer
587         no_background = True
588         # FIXME: don't require tile alignment for frame
589         self.set_frame(width=round_up_to_n(w, N), height=round_up_to_n(h, N))
590
591         for layer in get_layers_list(stack):
592             a = layer.attrib
593
594             if 'background_tile' in a:
595                 assert no_background
596                 try:
597                     print a['background_tile']
598                     self.set_background(get_pixbuf(a['background_tile']))
599                     no_background = False
600                     continue
601                 except backgroundsurface.BackgroundError, e:
602                     print 'ORA background tile not usable:', e
603
604             src = a.get('src', '')
605             if not src.lower().endswith('.png'):
606                 print 'Warning: ignoring non-png layer'
607                 continue
608             pixbuf = get_pixbuf(src)
609             name = a.get('name', '')
610             x = int(a.get('x', '0'))
611             y = int(a.get('y', '0'))
612             opac = float(a.get('opacity', '1.0'))
613             visible = not 'hidden' in a.get('visibility', 'visible')
614             self.add_layer(insert_idx=0, name=name)
615             last_pixbuf = pixbuf
616             t1 = time.time()
617             self.load_layer_from_pixbuf(pixbuf, x, y)
618             layer = self.layers[0]
619
620             self.set_layer_opacity(helpers.clamp(opac, 0.0, 1.0), layer)
621             self.set_layer_visibility(visible, layer)
622             print '  %.3fs converting pixbuf to layer format' % (time.time() - t1)
623             # strokemap
624             fname = a.get('mypaint_strokemap_v2', None)
625             if fname:
626                 if x % N or y % N:
627                     print 'Warning: dropping non-aligned strokemap'
628                 else:
629                     sio = StringIO(z.read(fname))
630                     layer.load_strokemap_from_file(sio, x, y)
631                     sio.close()
632
633         if len(self.layers) == 1:
634             raise ValueError, 'Could not load any layer.'
635
636         if no_background:
637             # recognize solid or tiled background layers, at least those that mypaint <= 0.7.1 saves
638             t1 = time.time()
639             p = last_pixbuf
640             if not p.get_has_alpha() and p.get_width() % N == 0 and p.get_height() % N == 0:
641                 tiles = self.layers[0].surface.tiledict.values()
642                 if len(tiles) > 1:
643                     all_equal = True
644                     for tile in tiles[1:]:
645                         if (tile.rgba != tiles[0].rgba).any():
646                             all_equal = False
647                             break
648                     if all_equal:
649                         arr = helpers.gdkpixbuf2numpy(p)
650                         tile = arr[0:N,0:N,:]
651                         self.set_background(tile.copy())
652                         self.select_layer(0)
653                         self.remove_layer()
654             print '  %.3fs recognizing tiled background' % (time.time() - t1)
655
656         if len(self.layers) > 1:
657             # remove the still present initial empty top layer
658             self.select_layer(len(self.layers)-1)
659             self.remove_layer()
660             # this leaves the topmost layer selected
661
662         print '%.3fs load_ora total' % (time.time() - t0)