import os.path, json, time, traceback
import random # import random only so art scripts don't have to
import numpy as np

from edit_command import CommandStack, EntireArtCommand
from image_export import write_thumbnail

# X, Y, Z
VERT_LENGTH = 3
# 4 verts in a quad
VERT_STRIDE = 4 * VERT_LENGTH
# elements: 3 verts per tri * 2 tris in a quad
ELEM_STRIDE = 6
# UVs: 2 floats per vert * 4 verts in a quad
UV_STRIDE = 2 * 4

# starting document defaults
DEFAULT_CHARSET = 'c64_petscii'
DEFAULT_PALETTE = 'c64_original'
DEFAULT_WIDTH, DEFAULT_HEIGHT = 40, 25
DEFAULT_ART_FILENAME = 'new'

DEFAULT_FRAME_DELAY = 0.1
DEFAULT_LAYER_Z = 0
DEFAULT_LAYER_Z_OFFSET = 0.5

ART_DIR = 'art/'
ART_FILE_EXTENSION = 'psci'

THUMBNAIL_CACHE_DIR = 'thumbnails/'

ART_SCRIPT_DIR = 'artscripts/'
SCRIPT_FILE_EXTENSION = 'arsc'

# flip/rotate UV constants
UV_NORMAL = 0
UV_ROTATE90 = 1
UV_ROTATE180 = 2
UV_ROTATE270 = 3
UV_FLIPX = 4
UV_FLIPY = 5
# (flip X & Y is identical to rotate 180)
# not sure what to call these, but - results for X and Y flip of rotated chars:
UV_FLIP90 = 6
UV_FLIP270 = 7

uv_names = {
    UV_NORMAL: 'Normal',
    UV_ROTATE90: 'Rotate 90',
    UV_ROTATE180: 'Rotate 180',
    UV_ROTATE270: 'Rotate 270',
    UV_FLIPX: 'Flip X',
    UV_FLIPY: 'Flip Y',
    UV_FLIP90: 'Flipped90',
    UV_FLIP270: 'Flipped270'
}

uv_types = {
    UV_NORMAL:    (0, 0, 1, 0, 0, 1, 1, 1),
    UV_ROTATE90:  (0, 1, 0, 0, 1, 1, 1, 0),
    UV_ROTATE180: (1, 1, 0, 1, 1, 0, 0, 0),
    UV_ROTATE270: (1, 0, 1, 1, 0, 0, 0, 1),
    UV_FLIPX:     (1, 0, 0, 0, 1, 1, 0, 1),
    UV_FLIPY:     (0, 1, 1, 1, 0, 0, 1, 0),
    UV_FLIP90:  (0, 0, 0, 1, 1, 0, 1, 1),
    UV_FLIP270:  (1, 1, 1, 0, 0, 1, 0, 0)
}

# reverse dict for easy (+ fast?) lookup in eg get_char_transform_at
uv_types_reverse = {
    uv_types[UV_NORMAL]: UV_NORMAL,
    uv_types[UV_ROTATE90]: UV_ROTATE90,
    uv_types[UV_ROTATE180]: UV_ROTATE180,
    uv_types[UV_ROTATE270]: UV_ROTATE270,
    uv_types[UV_FLIPX]: UV_FLIPX,
    uv_types[UV_FLIPY]: UV_FLIPY,
    uv_types[UV_FLIP90]: UV_FLIP90,
    uv_types[UV_FLIP270]: UV_FLIP270
}


class Art:
    """
    Art asset:
    Contains the data that is modified by user edits and gets saved to disk.
    Data stored as arrays that TileRenderables use to populate their buffers.
    
    assumptions:
    - an Art contains 1 or more frames
    - each frame contains 1 or more layers
    - each layer is a rectangular grid of Width * Height tiles
    - each tile has: character, foreground color, background color, & transform
    - char/color tile values are expressed as indices into charset / palette
    - all layers in an Art are the same dimensions
    """
    quad_width,quad_height = 1.0, 1.0
    "size of each tile in world space"
    log_size_changes = False
    recalc_quad_height = True
    log_creation = False
    
    def __init__(self, filename, app, charset, palette, width, height):
        "Creates a new, blank document with given parameters."
        self.valid = False
        if filename and not filename.endswith('.%s' % ART_FILE_EXTENSION):
            filename += '.%s' % ART_FILE_EXTENSION
        self.filename = filename
        self.app = app
        # save "time loaded" for menu sorting
        self.time_loaded = 0
        self.charset, self.palette = charset, palette
        self.command_stack = CommandStack(self)
        self.unsaved_changes = False
        self.width, self.height = width, height
        # selected char/fg/bg/xform
        self.selected_char = self.charset.get_char_index('A') or 2
        self.selected_fg_color = self.palette.lightest_index
        self.selected_bg_color = self.palette.darkest_index
        self.selected_xform = UV_NORMAL
        # camera position - updated in Art.update, saved in .psci
        self.update_saved_camera(self.app.camera)
        # set True when camera "zoom extents" toggles on
        self.camera_zoomed_extents = False
        # cached camera position from before "zoom extents" invoked
        self.non_extents_camera_x = self.non_extents_camera_y = 0
        self.non_extents_camera_z = self.app.camera.start_zoom
        # list of char/fg/bg arrays, one for each frame
        self.chars, self.fg_colors, self.bg_colors = [], [], []
        # char transforms: UV coords, plus map (unused by renderer) for fast access
        self.uv_mods, self.uv_maps = [], []
        # table of {frame_number: bool} changed frames, processed each update()
        self.char_changed_frames, self.uv_changed_frames = {}, {}
        self.fg_changed_frames, self.bg_changed_frames = {}, {}
        self.renderables = []
        "List of TileRenderables using us - each new Renderable adds itself"
        self.instances = []
        "List of ArtInstances using us as their source"
        # init frames and layers - ArtFromDisk has its own logic for this
        self.init_layers()
        self.init_frames()
        # support non-square characters:
        # derive quad_height from chars aspect; quad_width always 1.0
        if self.recalc_quad_height:
            self.quad_height *= self.charset.char_height / self.charset.char_width
        # running scripts and timing info
        self.scripts = []
        self.script_rates = []
        self.scripts_next_exec_time = []
        # tell renderables to rebind vert and element buffers next update
        self.geo_changed = True
        # run update once before renderables initialize so they have
        # something to bind
        self.first_update()
        if self.log_creation and not self.app.game_mode:
            self.log_init()
        self.valid = True
    
    def log_init(self):
        self.app.log('created new document:')
        self.app.log('  character set: %s' % self.charset.name)
        self.app.log('  palette: %s' % self.palette.name)
        self.app.log('  width/height: %s x %s' % (self.width, self.height))
        self.app.log('  frames: %s' % self.frames)
        self.app.log('  layers: %s' % self.layers)
    
    def init_layers(self):
        self.layers = 1
        # current layer being edited
        self.active_layer = 0
        # lists of layer Z values and names
        self.layers_z = [DEFAULT_LAYER_Z]
        self.layers_visibility = [True]
        self.layer_names = ['Layer 1']
    
    def init_frames(self):
        self.frames = 0
        # current frame being edited
        self.active_frame = 0
        # list of frame delays
        self.frame_delays = []
        # add one frame to start
        self.add_frame_to_end(DEFAULT_FRAME_DELAY, False)
        # clear our single layer to a sensible BG color
        self.clear_frame_layer(0, 0, bg_color=self.palette.darkest_index)
    
    def first_update(self):
        self.update()
    
    def insert_frame_before_index(self, index, delay=DEFAULT_FRAME_DELAY, log=True):
        "Add a blank frame at the specified index (len+1 to add to end)."
        self.frames += 1
        self.frame_delays.insert(index, delay)
        tiles = self.layers * self.width * self.height
        shape = (self.layers, self.height, self.width, 4)
        fg, bg = 0, 0
        if self.app.ui:
            fg = self.app.ui.selected_fg_color
            bg = self.app.ui.selected_bg_color
        new_char = np.zeros(shape, dtype=np.float32)
        new_fg = np.full(shape, fg, dtype=np.float32)
        new_bg = np.full(shape, bg, dtype=np.float32)
        new_uv = np.full(shape, UV_NORMAL, dtype=np.uint32)
        self.chars.insert(index, new_char)
        self.fg_colors.insert(index, new_fg)
        self.bg_colors.insert(index, new_bg)
        self.uv_maps.insert(index, new_uv)
        # UV init is more complex than just all zeroes
        self.uv_mods.insert(index, self.new_uv_layers(self.layers))
        # all but lowest layer = transparent
        for l in range(1, self.layers):
            self.clear_frame_layer(index, l, 0, fg)
        self.mark_all_frames_changed()
        # set new frame as active
        if self.app.ui and self is self.app.ui.active_art:
            self.app.ui.set_active_frame(index)
        if log:
            self.app.log('Created new frame at index %s' % str(index))
    
    def add_frame_to_end(self, delay=DEFAULT_FRAME_DELAY, log=True):
        "Add a blank frame at the end of the current animation."
        self.insert_frame_before_index(self.frames, delay, log)
    
    def duplicate_frame(self, src_frame_index, dest_frame_index=None, delay=None):
        "Create a duplicate of given frame at given index."
        # stick new frame at end if no destination index given
        dest_frame_index = dest_frame_index if dest_frame_index is not None else self.frames
        # copy source frame's delay if none given
        delay = delay or self.frame_delays[src_frame_index]
        self.frames += 1
        self.frame_delays.insert(dest_frame_index, delay)
        # copy source frame's char/color arrays
        self.chars.insert(dest_frame_index, self.chars[src_frame_index].copy())
        self.uv_mods.insert(dest_frame_index, self.uv_mods[src_frame_index].copy())
        self.uv_maps.insert(dest_frame_index, self.uv_maps[src_frame_index].copy())
        self.fg_colors.insert(dest_frame_index, self.fg_colors[src_frame_index].copy())
        self.bg_colors.insert(dest_frame_index, self.bg_colors[src_frame_index].copy())
        self.mark_all_frames_changed()
        # set new frame as active
        if self is self.app.ui.active_art:
            self.app.ui.set_active_frame(dest_frame_index-1)
        self.app.log('Duplicated frame %s at frame %s' % (src_frame_index+1, dest_frame_index))
    
    def delete_frame_at(self, index):
        "Delete frame at given index."
        self.chars.pop(index)
        self.fg_colors.pop(index)
        self.bg_colors.pop(index)
        self.uv_mods.pop(index)
        self.uv_maps.pop(index)
        self.frames -= 1
        self.mark_all_frames_changed()
        if self is self.app.ui.active_art:
            self.app.ui.set_active_frame(index)
    
    def move_frame_to_index(self, src_index, dest_index):
        "Move frame at given index to new given index."
        char_data = self.chars.pop(src_index)
        fg_data = self.fg_colors.pop(src_index)
        bg_data = self.bg_colors.pop(src_index)
        uv_data = self.uv_mods.pop(src_index)
        uv_map_data = self.uv_maps.pop(src_index)
        self.chars.insert(dest_index, char_data)
        self.fg_colors.insert(dest_index, fg_data)
        self.bg_colors.insert(dest_index, bg_data)
        self.uv_mods.insert(dest_index, uv_data)
        self.uv_maps.insert(dest_index, uv_map_data)
        self.mark_all_frames_changed()
    
    def add_layer(self, z=None, name=None):
        "Add a new layer with given Z with given name."
        # offset Z from last layer's Z if none given
        z = z if z is not None else self.layers_z[-1] + DEFAULT_LAYER_Z_OFFSET
        # index isn't user-facing, z is what matters
        index = self.layers - 1
        # duplicate_layer increases self.layers by 1
        self.duplicate_layer(index, z, name)
        for frame in range(self.frames):
            self.clear_frame_layer(frame, self.layers-1, 0)
        # set new layer as active
        if self is self.app.ui.active_art:
            self.app.ui.set_active_layer(index+1)
    
    def duplicate_layer(self, src_index, z=None, new_name=None):
        "Duplicate layer with given index. Duplicate uses given Z and name."
        def duplicate_layer_array(array):
            src_data = np.array([array[src_index]])
            return np.append(array, src_data, 0)
        for frame in range(self.frames):
            self.chars[frame] = duplicate_layer_array(self.chars[frame])
            self.fg_colors[frame] = duplicate_layer_array(self.fg_colors[frame])
            self.bg_colors[frame] = duplicate_layer_array(self.bg_colors[frame])
            self.uv_mods[frame] = duplicate_layer_array(self.uv_mods[frame])
            self.uv_maps[frame] = duplicate_layer_array(self.uv_maps[frame])
        self.layers += 1
        z = z if z is not None else self.layers_z[src_index]
        self.layers_z.append(z)
        self.layers_visibility.append(True)
        new_name = new_name or 'Copy of %s' % self.layer_names[src_index]
        self.layer_names.append(new_name)
        # rebuild geo with added verts for new layer
        self.geo_changed = True
        # set new layer as active
        if self is self.app.ui.active_art:
            self.app.ui.set_active_layer(self.layers - 1)
        # don't log new layers created on the fly in game mode
        if not self.app.game_mode:
            self.app.log('Added new layer %s' % new_name)
        self.set_unsaved_changes(True)
    
    def clear_frame_layer(self, frame, layer, bg_color=0, fg_color=None):
        "Clear given layer of given frame to transparent BG + no characters."
        # "clear" UVs to UV_NORMAL
        for y in range(self.height):
            for x in range(self.width):
                self.uv_mods[frame][layer][y][x] = uv_types[UV_NORMAL]
                self.uv_maps[frame][layer][y][x] = UV_NORMAL
                self.chars[frame][layer][y][x] = 0
                self.fg_colors[frame][layer][y][x] = fg_color or 0
                self.bg_colors[frame][layer][y][x] = bg_color
        # tell this frame to update
        self.char_changed_frames[frame] = True
        self.fg_changed_frames[frame] = True
        self.bg_changed_frames[frame] = True
        self.uv_changed_frames[frame] = True
    
    def delete_layer(self, index):
        "Delete layer at given index."
        for frame in range(self.frames):
            self.chars[frame] = np.delete(self.chars[frame], index, 0)
            self.fg_colors[frame] = np.delete(self.fg_colors[frame], index, 0)
            self.bg_colors[frame] = np.delete(self.bg_colors[frame], index, 0)
            self.uv_mods[frame] = np.delete(self.uv_mods[frame], index, 0)
            self.uv_maps[frame] = np.delete(self.uv_maps[frame], index, 0)
        self.layers_z.pop(index)
        self.layers_visibility.pop(index)
        self.layer_names.pop(index)
        self.layers -= 1
        self.geo_changed = True
        self.mark_all_frames_changed()
        if self.active_layer > self.layers - 1:
            self.app.ui.set_active_layer(self.layers - 1)
        self.set_unsaved_changes(True)
    
    def set_charset(self, new_charset, log=False):
        "Set Art to use given character set (referenced by object, not name)."
        if new_charset is self.charset:
            return
        self.charset = new_charset
        if self.recalc_quad_height:
            self.quad_width = 1.0
            self.quad_height = 1.0 * (self.charset.char_height / self.charset.char_width)
        self.set_unsaved_changes(True)
        self.geo_changed = True
        if log:
            self.app.ui.message_line.post_line('Character set changed to %s' % self.charset.name)
    
    def set_charset_by_name(self, new_charset_name):
        charset = self.app.load_charset(new_charset_name)
        self.set_charset(charset)
        self.app.ui.popup.set_active_charset(charset)
    
    def set_palette(self, new_palette, log=False):
        "Set Art to use given color palette (referenced by object, not name)."
        if new_palette is self.palette:
            return
        self.palette = new_palette
        self.set_unsaved_changes(True)
        if log:
            self.app.ui.message_line.post_line('Color palette changed to %s' % self.palette.name)
    
    def set_palette_by_name(self, new_palette_name):
        palette = self.app.load_palette(new_palette_name)
        self.set_palette(palette)
        self.app.ui.popup.set_active_palette(palette)
    
    def set_active_frame(self, new_frame):
        "Set frame at given index for active editing in Art Mode."
        new_frame %= self.frames
        # bail if frame is still the same, eg we only have 1 frame
        if new_frame == self.active_frame:
            # return whether or not we actually changed frames
            return False
        self.active_frame = new_frame
        # update our renderables
        for r in self.renderables:
            r.set_frame(self.active_frame)
        return True
    
    def set_active_layer(self, new_layer):
        "Set layer at given index for active editing in Art Mode."
        self.active_layer = min(max(0, new_layer), self.layers-1)
    
    def crop(self, new_width, new_height, origin_x=0, origin_y=0):
        x0, y0 = origin_x, origin_y
        x1, y1 = x0 + new_width, y0 + new_height
        crop_x = new_width < self.width
        crop_y = new_height < self.height
        for frame in range(self.frames):
            for array in [self.chars, self.fg_colors, self.bg_colors,
                          self.uv_mods, self.uv_maps]:
                if crop_x:
                    array[frame] = array[frame].take(range(x0, x1), axis=2)
                if crop_y:
                    array[frame] = array[frame].take(range(y0, y1), axis=1)
    
    def expand(self, new_width, new_height, bg_fill):
        x_add = new_width - self.width
        y_add = new_height - self.height
        #print('%s expand: %sw + %s = %s, %sh + %s = %s' % (self.filename,
        #    self.width, x_add, new_width, self.height, y_add, new_height))
        def expand_array(array, fill_value, stride):
            # add columns (increasing width)
            if x_add > 0:
                # before height has changed, take care not to append
                # incorrectly sized columns
                h = new_height if new_height < self.height else self.height
                add_shape = (self.layers, h, x_add, stride)
                add = np.full(add_shape, fill_value, dtype=np.float32)
                array = np.append(array, add, 2)
            # add rows (increasing height)
            if y_add > 0:
                add_shape = (self.layers, y_add, new_width, stride)
                add = np.full(add_shape, fill_value, dtype=np.float32)
                array = np.append(array, add, 1)
            # can't modify passed array in-place
            return array
        for frame in range(self.frames):
            self.chars[frame] = expand_array(self.chars[frame], 0, 4)
            fg, bg = 0, 0
            if self.app.ui:
                fg = self.app.ui.selected_fg_color
                # fill with BG color? ResizeArtDialog can specify this
                # if not, blank bg for all new tiles (original default behavior)
                if bg_fill:
                    bg = self.app.ui.selected_bg_color
            self.fg_colors[frame] = expand_array(self.fg_colors[frame], fg, 4)
            self.bg_colors[frame] = expand_array(self.bg_colors[frame], bg, 4)
            self.uv_mods[frame] = expand_array(self.uv_mods[frame], uv_types[UV_NORMAL], UV_STRIDE)
            self.uv_maps[frame] = expand_array(self.uv_maps[frame], UV_NORMAL, 4)
    
    def mark_frame_changed(self, frame):
        "Given frame at given index as changed for next render."
        self.char_changed_frames[frame] = True
        self.fg_changed_frames[frame] = True
        self.bg_changed_frames[frame] = True
        self.uv_changed_frames[frame] = True
    
    def mark_all_frames_changed(self):
        "Mark all frames as changed for next render."
        for frame in range(self.frames):
            self.mark_frame_changed(frame)
    
    def resize(self, new_width, new_height, origin_x=0, origin_y=0, bg_fill=False):
        """
        Crop and/or expand Art to new given dimensions, with optional new
        top left tile if cropping. Calls crop() and expand(), so no need to
        call those directly.
        """
        if new_width < self.width or new_height < self.height:
            self.crop(new_width, new_height, origin_x, origin_y)
        if new_width > self.width or new_height > self.height:
            self.expand(new_width, new_height, bg_fill)
        self.width, self.height = new_width, new_height
        # tell all frames they've changed, rebind buffers
        self.geo_changed = True
        self.mark_all_frames_changed()
    
    def build_geo(self):
        """
        (Re)build the vertex and element arrays used by all layers.
        Run if the Art has untracked changes to size or layer count.
        """
        shape = (self.layers, self.height, self.width, VERT_STRIDE)
        self.vert_array = np.empty(shape, dtype=np.float32)
        all_elems_size = self.layers * self.width * self.height * ELEM_STRIDE
        self.elem_array = np.empty(shape=all_elems_size, dtype=np.uint32)
        # generate geo according to art size
        # vert_index corresponds to # of verts, loc_index to position in array
        # (given that each vert has 3 components)
        vert_index = 0
        elem_index = 0
        for layer in range(self.layers):
            for tile_y in range(self.height):
                for tile_x in range(self.width):
                    # vertices
                    left_x = tile_x * self.quad_width
                    top_y = tile_y * -self.quad_height
                    right_x = left_x + self.quad_width
                    bottom_y = top_y - self.quad_height
                    x0,y0 = left_x, top_y
                    x1,y1 = right_x, top_y
                    x2,y2 = left_x, bottom_y
                    x3,y3 = right_x, bottom_y
                    # Z of all layers is 0, layer Z set in shader
                    verts = [x0, y0, 0]
                    verts += [x1, y1, 0]
                    verts += [x2, y2, 0]
                    verts += [x3, y3, 0]
                    self.vert_array[layer][tile_y][tile_x] = verts
                    # vertex elements
                    elements = [vert_index, vert_index+1, vert_index+2]
                    elements += [vert_index+1, vert_index+2, vert_index+3]
                    self.elem_array[elem_index:elem_index+ELEM_STRIDE] = elements
                    elem_index += ELEM_STRIDE
                    # 4 verts in a quad
                    vert_index += 4
    
    def new_uv_layers(self, layers):
        "Return given # of layer's worth of vanilla UV array data."
        shape = (layers, self.height, self.width, UV_STRIDE)
        array = np.zeros(shape, dtype=np.float32)
        # default new layer of UVs to "normal" transform
        uvs = uv_types[UV_NORMAL]
        for layer in range(layers):
            for y in range(self.height):
                for x in range(self.width):
                    array[layer][y][x] = uvs
        return array
    
    def is_tile_inside(self, x, y):
        "Return True if given x,y tile coord is within our bounds."
        return 0 <= x < self.width and 0 <= y < self.height
    
    # get methods
    def get_char_index_at(self, frame, layer, x, y):
        "Return character index for given frame/layer/x,y tile."
        return int(self.chars[frame][layer][y][x][0])
    
    def get_fg_color_index_at(self, frame, layer, x, y):
        "Return foreground color index for given frame/layer/x,y tile."
        return int(self.fg_colors[frame][layer][y][x][0])
    
    def get_bg_color_index_at(self, frame, layer, x, y):
        "Return background color index for given frame/layer/x,y tile."
        return int(self.bg_colors[frame][layer][y][x][0])
    
    def get_char_transform_at(self, frame, layer, x, y):
        "Return character transform enum for given frame/layer/x,y tile."
        # read from mapping, rather than casting raw UV coords for tuple key
        return self.uv_maps[frame][layer][y][x][0]
    
    def get_tile_at(self, frame, layer, x, y):
        """
        Return (char index, fg color index, bg color index, transform) tuple
        for given frame/layer/x,y tile.
        """
        char = self.get_char_index_at(frame, layer, x, y)
        fg = self.get_fg_color_index_at(frame, layer, x, y)
        bg = self.get_bg_color_index_at(frame, layer, x, y)
        xform = self.get_char_transform_at(frame, layer, x, y)
        return char, fg, bg, xform
    
    # set methods
    def set_char_index_at(self, frame, layer, x, y, char_index):
        "Set character index for given frame/layer/x,y tile."
        self.chars[frame][layer][y][x] = char_index
        # next update, tell renderables on the changed frame to update buffers
        self.char_changed_frames[frame] = True
    
    def set_color_at(self, frame, layer, x, y, color_index, fg=True):
        """
        Set (fg or bg) color index for given frame/layer/x,y tile.
        Foreground or background specified with "fg" boolean.
        """
        if color_index is None: return
        # modulo to resolve any negative indices
        if 0 < color_index >= len(self.palette.colors):
            color_index %= len(self.palette.colors)
        # no functional differences between fg and bg color update,
        # so use the same code path with different parameters
        update_array = self.fg_colors[frame] if fg else self.bg_colors[frame]
        update_array[layer][y][x] = color_index
        self.fg_changed_frames[frame] = True
        self.bg_changed_frames[frame] = True
    
    def set_all_non_transparent_colors(self, new_color_index):
        """
        Set color index for all non-transparent (index 0) colors on all tiles
        on all frames and layers.
        """
        for frame, layer, x, y in TileIter(self):
            # non-transparent color could be FG or BG
            char, fg, bg, xform = self.get_tile_at(frame, layer, x, y)
            change_fg = bg == 0
            self.set_color_at(frame, layer, x, y, new_color_index, change_fg)
    
    def set_all_bg_colors(self, new_color_index, exclude_layers=[]):
        "Set background color index for all tiles on all frames and layers."
        for frame, layer, x, y in TileIter(self):
            # exclude all layers named in list
            if self.layer_names[layer] in exclude_layers:
                continue
            self.set_color_at(frame, layer, x, y, new_color_index, fg=False)
    
    def set_char_transform_at(self, frame, layer, x, y, transform):
        """
        Set character transform (X/Y flip, 0/90/180/270 rotate) for given
        frame/layer/x,y tile.
        """
        self.uv_mods[frame][layer][y][x] = uv_types[transform]
        # keep mapping, used only for quick access, in sync
        self.uv_maps[frame][layer][y][x] = transform
        self.uv_changed_frames[frame] = True
    
    def set_tile_at(self, frame, layer, x, y, char_index=None, fg=None, bg=None,
                    transform=None, set_all=False):
        """
        Convenience function for setting all tile attributes (character index,
        foreground and background color, and transofmr) at once.
        """
        # speedier path if we know all tile attributes will change,
        # eg command undo/apply
        if set_all:
            self.set_char_index_at(frame, layer, x, y, char_index)
            self.set_color_at(frame, layer, x, y, fg, True)
            self.set_color_at(frame, layer, x, y, bg, False)
            self.set_char_transform_at(frame, layer, x, y, transform)
            return
        if char_index is not None:
            self.set_char_index_at(frame, layer, x, y, char_index)
        if fg is not None:
            self.set_color_at(frame, layer, x, y, fg, True)
        if bg is not None:
            self.set_color_at(frame, layer, x, y, bg, False)
        if transform is not None:
            self.set_char_transform_at(frame, layer, x, y, transform)
    
    def flip_all_xforms(self, flip_dict):
        # kinda ugly brute force approach: individually set all tiles in all
        # layers in all frames :/
        for frame in range(self.frames):
            for layer in range(self.layers):
                for y in range(self.height):
                    for x in range(self.width):
                        self.set_char_transform_at(frame, layer, x, y, flip_dict[self.get_char_transform_at(frame, layer, x, y)])
    
    def flip_horizontal(self, frame, layer):
        "Mirrors Art left-to-right."
        command = EntireArtCommand(self)
        command.save_tiles(before=True)
        for a in [self.chars, self.fg_colors, self.bg_colors, self.uv_mods, self.uv_maps]:
            a[frame][layer] = np.fliplr(a[frame][layer])
        if self.app.ui.flip_affects_xforms:
            flips = {
                UV_NORMAL: UV_FLIPX,
                UV_FLIPX: UV_NORMAL,
                UV_FLIPY: UV_ROTATE180,
                UV_ROTATE180: UV_FLIPY,
                UV_ROTATE90: UV_FLIP90,
                UV_FLIP90: UV_ROTATE90,
                UV_ROTATE270: UV_FLIP270,
                UV_FLIP270: UV_ROTATE270
            }
            self.flip_all_xforms(flips)
        self.mark_frame_changed(frame)
        self.set_unsaved_changes(True)
        command.save_tiles(before=False)
        self.command_stack.commit_commands([command])
    
    def flip_vertical(self, frame, layer):
        "Flips Art upside down."
        command = EntireArtCommand(self)
        command.save_tiles(before=True)
        for a in [self.chars, self.fg_colors, self.bg_colors, self.uv_mods, self.uv_maps]:
            a[frame][layer] = np.flipud(a[frame][layer])
        if self.app.ui.flip_affects_xforms:
            flips = {
                UV_NORMAL: UV_FLIPY,
                UV_FLIPY: UV_NORMAL,
                UV_FLIPX: UV_ROTATE180,
                UV_ROTATE180: UV_FLIPX,
                # vertical flip of rotated characters: use inverted (180) "flip"
                UV_ROTATE90: UV_FLIP270,
                UV_FLIP270: UV_ROTATE90,
                UV_ROTATE270: UV_FLIP90,
                UV_FLIP90: UV_ROTATE270
            }
            self.flip_all_xforms(flips)
        self.mark_frame_changed(frame)
        self.set_unsaved_changes(True)
        command.save_tiles(before=False)
        self.command_stack.commit_commands([command])
    
    def shift(self, frame, layer, amount_x, amount_y):
        "Shift + wrap art on given frame and layer by given amount in X and Y."
        for a in [self.chars, self.fg_colors, self.bg_colors, self.uv_mods, self.uv_maps]:
            a[frame][layer] = np.roll(a[frame][layer], amount_x, 1)
            a[frame][layer] = np.roll(a[frame][layer], amount_y, 0)
        self.mark_frame_changed(frame)
    
    def shift_all_frames(self, amount_x, amount_y):
        "Shift + wrap art in X and Y on all frames and layers."
        for frame in range(self.frames):
            for layer in range(self.layers):
                self.shift(frame, layer, amount_x, amount_y)
    
    def update_saved_camera(self, camera):
        self.camera_x, self.camera_y, self.camera_z = camera.x, camera.y, camera.z
    
    def update_selected_tile_attributes(self):
        self.selected_char = self.app.ui.selected_char
        self.selected_fg_color = self.app.ui.selected_fg_color
        self.selected_bg_color = self.app.ui.selected_bg_color
        self.selected_xform = self.app.ui.selected_xform
    
    def changed_this_frame(self):
        return self.geo_changed or \
            True in self.char_changed_frames.values() or \
            True in self.fg_changed_frames.values() or \
            True in self.bg_changed_frames.values() or \
            True in self.uv_changed_frames.values()
    
    def update(self):
        self.update_scripts()
        # update our camera if we're active
        if not self.app.game_mode and self.app.ui and self.app.ui.active_art is self:
            self.update_saved_camera(self.app.camera)
            self.update_selected_tile_attributes()
        # update our renderables if they're on a frame whose char/colors changed
        if self.geo_changed:
            self.build_geo()
        for r in self.renderables:
            if self.geo_changed:
                r.update_geo_buffers()
                self.geo_changed = False
            do_char = self.char_changed_frames[r.frame]
            do_uvs = self.uv_changed_frames[r.frame]
            do_fg = self.fg_changed_frames[r.frame]
            do_bg = self.bg_changed_frames[r.frame]
            if do_char or do_fg or do_bg or do_uvs:
                r.update_tile_buffers(do_char, do_uvs, do_fg, do_bg)
        # update instances if we chaned
        if self.changed_this_frame() and self.instances:
            for instance in self.instances:
                if instance.update_when_source_changes:
                    instance.restore_from_source()
        # empty table of changed frames
        for f in range(self.frames):
            self.char_changed_frames[f] = False
            self.fg_changed_frames[f] = False
            self.bg_changed_frames[f] = False
            self.uv_changed_frames[f] = False
        self.updated_this_tick = True
    
    def save_to_file(self):
        """
        Write this Art to disk.
        Build a dict serializing all this art's data and write it to a file.
        """
        # if trying to save to a dir that doesn't exist, switch file to the
        # Art Mode documents dir, which we should assume does exist
        filedir = os.path.dirname(self.filename)
        if not os.path.exists(filedir):
            #self.app.log('Tried to save to directory %s which does not exist!' % filedir, error=True)
            new_path = self.app.documents_dir + ART_DIR + os.path.basename(self.filename)
            self.set_filename(new_path)
        start_time = time.time()
        # cursor might be hovering, undo any preview changes
        for edit in self.app.cursor.preview_edits:
            edit.undo()
        d = {'width': self.width, 'height': self.height,
             'charset': self.charset.name, 'palette': self.palette.name,
             'active_frame': self.active_frame,
             'active_layer': self.active_layer,
             'camera': (self.camera_x, self.camera_y, self.camera_z),
             'selected_char': int(self.selected_char),
             'selected_fg_color': int(self.selected_fg_color),
             'selected_bg_color': int(self.selected_bg_color),
             'selected_xform': int(self.selected_xform)
        }
        # preferred character set and palette, default used if not found
        # remember camera location
        # frames and layers are dicts w/ lists of their data + a few properties
        frames = []
        for frame_index in range(self.frames):
            frame = { 'delay': self.frame_delays[frame_index] }
            layers = []
            for layer_index in range(self.layers):
                layer = {'z': self.layers_z[layer_index],
                         'visible': int(self.layers_visibility[layer_index]),
                         'name': self.layer_names[layer_index]
                }
                tiles = []
                for y in range(self.height):
                    for x in range(self.width):
                        char = int(self.chars[frame_index][layer_index][y][x][0])
                        fg = int(self.fg_colors[frame_index][layer_index][y][x][0])
                        bg = int(self.bg_colors[frame_index][layer_index][y][x][0])
                        # use get method for transform, data's not simply an int
                        xform = int(self.get_char_transform_at(frame_index, layer_index, x, y))
                        tiles.append({'char': char, 'fg': fg, 'bg': bg, 'xform': xform})
                layer['tiles'] = tiles
                layers.append(layer)
            frame['layers'] = layers
            frames.append(frame)
        d['frames'] = frames
        # MAYBE-TODO: below gives not-so-pretty-printing, find out way to control
        # formatting for better output
        json.dump(d, open(self.filename, 'w'), sort_keys=True, indent=1)
        end_time = time.time()
        self.set_unsaved_changes(False)
        #self.app.log('saved %s to disk in %.5f seconds' % (self.filename, end_time - start_time))
        self.app.log('saved %s' % self.filename)
        # remove old thumbnail
        thumb_dir = self.app.cache_dir + THUMBNAIL_CACHE_DIR
        if os.path.exists(self.filename):
            old_thumb_filename = thumb_dir + self.app.get_file_hash(self.filename) + '.png'
            if os.path.exists(old_thumb_filename):
                os.remove(old_thumb_filename)
        # write thumbnail
        new_thumb_filename = thumb_dir + self.app.get_file_hash(self.filename) + '.png'
        write_thumbnail(self.app, self.filename, new_thumb_filename)
        # thumbnail write process actually sets active frame! set it back
        for r in self.renderables:
            r.set_frame(self.active_frame)
    
    def ALT_save_to_file(self):
        # TEST alt save format research
        # main idea: save flat lists of char/fg/bg/xform per-frame-per-layer
        # savetest.psci: 500x300x5, 38s vs 8.6s save, 63MB vs 33MB (vs 11MB w/ indent=None)
        # TODO:
        # - test read perf too
        # - save version checking (if none found in PSCI, assume v1)
        # - support multiple save+load code paths for different save versions
        def get_flat_int_list(layer_array):
            return list(map(int, layer_array.flatten()))[::4]
        start_time = time.time()
        d = {'width': self.width, 'height': self.height,
             'charset': self.charset.name, 'palette': self.palette.name,
             'active_frame': self.active_frame,
             'active_layer': self.active_layer,
             'camera': (self.camera_x, self.camera_y, self.camera_z)
        }
        frames = []
        for frame_index in range(self.frames):
            frame = { 'delay': self.frame_delays[frame_index] }
            layers = []
            for layer_index in range(self.layers):
                layer = {'z': self.layers_z[layer_index],
                         'visible': int(self.layers_visibility[layer_index]),
                         'name': self.layer_names[layer_index]
                }
                # compile lists-of-ints for chars, colors, xforms
                layer['chars'] = get_flat_int_list(self.chars[frame_index][layer_index])
                layer['fgs'] = get_flat_int_list(self.fg_colors[frame_index][layer_index])
                layer['bgs'] = get_flat_int_list(self.bg_colors[frame_index][layer_index])
                layer['xforms'] = get_flat_int_list(self.uv_maps[frame_index][layer_index])
                layers.append(layer)
            frame['layers'] = layers
            frames.append(frame)
        d['frames'] = frames
        json.dump(d, open(self.filename + '2', 'w'), sort_keys=True, indent=None)
        end_time = time.time()
        self.app.log('ALT saved %s to disk in %.5f seconds' % (self.filename, end_time - start_time))
    
    def set_unsaved_changes(self, new_status):
        "Mark this Art as having unsaved changes in Art Mode."
        if new_status == self.unsaved_changes:
            return
        self.unsaved_changes = new_status
        self.app.update_window_title()
    
    def set_filename(self, new_filename):
        "Change Art's filename to new given string."
        # append extension if missing
        if not new_filename.endswith('.' + ART_FILE_EXTENSION):
            new_filename += '.' + ART_FILE_EXTENSION
        # if no dir given, assume documents/art/ dir
        if os.path.basename(new_filename) == new_filename:
            new_dir = self.app.documents_dir
            # documents/game/art if game loaded
            if self.app.gw.game_dir is not None:
                new_dir = self.app.gw.game_dir
            new_dir += ART_DIR
            new_filename = new_dir + new_filename
        # TODO: check if file already exists? warn user?
        # (probably better to do this in new art / save as
        self.filename = new_filename
    
    def run_script(self, script_filename, log=True, allow_undo=True):
        """
        Run a script on this Art. Scripts contain arbitrary python expressions,
        executed within Art's scope. Don't run art scripts you don't trust!
        """
        script_filename = self.get_valid_script_filename(script_filename)
        if not script_filename:
            return
        # remove any cursor-hover changes to art in memory
        for edit in self.app.cursor.preview_edits:
            edit.undo()
        # create a command for undo/redo stack
        if allow_undo:
            command = EntireArtCommand(self)
            command.save_tiles(before=True)
        # catch and log any exception
        try:
            # run script
            exec(open(script_filename).read())
            # (assume script changed art)
            self.unsaved_changes = True
            logline = 'Executed %s' % script_filename
            if log: self.app.log(logline)
            error = False
        except Exception as e:
            error = True
            logline = 'Error executing %s:' % script_filename
            self.app.log(logline)
            # skip first 3 lines of callstack before artscript exec
            for line in traceback.format_exc().split('\n')[3:]:
                if line.strip():
                    self.app.log(line.rstrip())
        # write "after" state of command and commit
        if allow_undo:
            command.save_tiles(before=False)
            self.command_stack.commit_commands([command])
        self.app.ui.message_line.post_line(logline, error=error)
    
    def is_script_running(self, script_filename):
        "Return True if script with given filename is currently running."
        script_filename = self.get_valid_script_filename(script_filename)
        return script_filename and script_filename in self.scripts
    
    def get_valid_script_filename(self, script_filename):
        if not type(script_filename) is str: return None
        return self.app.find_filename_path(script_filename, ART_SCRIPT_DIR,
                                           SCRIPT_FILE_EXTENSION)
    
    def run_script_every(self, script_filename, rate=0.1):
        "Start a script running on this Art at a regular rate."
        script_filename = self.get_valid_script_filename(script_filename)
        if not script_filename:
            return
        if script_filename in self.scripts:
            self.app.log('script %s is already running.' % script_filename)
            return
        # add to "scripts currently running" list
        self.scripts.append(script_filename)
        self.script_rates.append(rate)
        # set next time
        next_run = (self.app.get_elapsed_time() / 1000) + rate
        self.scripts_next_exec_time.append(next_run)
    
    def stop_script(self, script_filename):
        "Halt this Art's execution of script with given filename."
        # remove from running scripts, rate list, next_exec list
        script_filename = self.get_valid_script_filename(script_filename)
        if not script_filename:
            return
        if not script_filename in self.scripts:
            self.app.log("script %s exists but isn't running." % script_filename)
            return
        script_index = self.scripts.index(script_filename)
        self.scripts.pop(script_index)
        self.script_rates.pop(script_index)
        self.scripts_next_exec_time.pop(script_index)
    
    def stop_all_scripts(self):
        "Halt all art scripts executing on this Art."
        for script_filename in self.scripts:
            self.stop_script(script_filename)
    
    def update_scripts(self):
        if len(self.scripts) == 0:
            return
        # don't run on game art while paused
        if self.app.game_mode and self.app.gw.paused:
            return
        for i,script in enumerate(self.scripts):
            if (self.app.get_elapsed_time() / 1000) > self.scripts_next_exec_time[i]:
                # execute script directly; don't use formal safeguards of run_script
                exec(open(script).read())
                self.unsaved_changes = True
                self.scripts_next_exec_time[i] += self.script_rates[i]
    
    def clear_line(self, frame, layer, line_y, fg_color_index=None,
                   bg_color_index=None):
        "Clear characters on given horizontal line, to optional given colors."
        # TODO: use numpy slicing to do this much more quickly!
        for x in range(self.width):
            self.set_char_index_at(frame, layer, x, line_y, 0)
            if fg_color_index:
                self.set_color_at(frame, layer, x, line_y, fg_color_index)
            if bg_color_index:
                self.set_color_at(frame, layer, x, line_y, bg_color_index, False)
    
    def write_string(self, frame, layer, x, y, text, fg_color_index=None,
                     bg_color_index=None, right_justify=False):
        """
        Write given string starting at given frame/layer/x,y tile, with
        optional given colors, left-justified by default.
        Any characters not in character set's mapping data are ignored.
        """
        if y >= self.height:
            return
        x %= self.width
        if right_justify:
            x_offset = -len(text)
        else:
            x_offset = 0
        # never let string drawing go out of bounds
        text = text[:self.width - (x+x_offset)]
        for char in text:
            idx = self.charset.get_char_index(char)
            self.set_char_index_at(frame, layer, x+x_offset, y, idx)
            if fg_color_index is not None:
                self.set_color_at(frame, layer, x+x_offset, y, fg_color_index, True)
            if bg_color_index is not None:
                self.set_color_at(frame, layer, x+x_offset, y, bg_color_index, False)
            x_offset += 1
    
    def composite_to(self, src_frame, src_layer, src_x, src_y, width, height,
                     dest_art, dest_frame, dest_layer, dest_x, dest_y):
        for y in range(src_y, src_y + height):
            for x in range(src_x, src_x + width):
                # never try to write out of bounds on dest art; let user be lazy
                if x < 0 or x >= dest_art.width or y < 0 or y >= dest_art.height:
                    continue
                # only consider non-blank characters
                if self.get_char_index_at(src_frame, src_layer, x, y) == 0:
                    continue
                # skip tiles with transparent FG
                # TODO: decide if there's a reasonable behavior here
                if self.get_fg_color_index_at(src_frame, src_layer, x, y) == 0:
                    continue
                ch, fg, bg, xform = self.get_tile_at(src_frame, src_layer, x, y)
                # get coords in dest art space
                dx = dest_x + (x - src_x)
                dy = dest_y + (y - src_y)
                # transparent bg -> keep dest bg, else use entire src tile
                if self.get_bg_color_index_at(src_frame, src_layer, x, y) == 0:
                    bg = dest_art.get_bg_color_index_at(dest_frame, dest_layer,
                                                        dx, dy)
                dest_art.set_tile_at(dest_frame, dest_layer, dx, dy,
                                     ch, fg, bg, xform)
    
    def composite_from(self, src_art, src_frame, src_layer, src_x, src_y,
                       width, height, dest_frame, dest_layer, dest_x, dest_y):
        src_art.composite_to(src_frame, src_layer, src_x, src_y, width, height,
                             self, dest_frame, dest_layer, dest_x, dest_y)
    
    def get_filtered_tiles(self, frame, layer, char_value, invert_filter=False):
        "Return list of (x,y) tile coords that match (or don't) a char value."
        tiles = []
        for y in range(self.height):
            for x in range(self.width):
                char = self.get_char_index_at(frame, layer, x, y)
                if (not invert_filter and char == char_value) or \
                   (invert_filter and char != char_value):
                    tiles.append((x, y))
        return tiles
    
    def get_blank_tiles(self, frame, layer):
        "Return a list of (x,y) tile coords whose character is blank (0)."
        return self.get_filtered_tiles(frame, layer, 0)
    
    def get_nonblank_tiles(self, frame, layer):
        "Return a list of (x,y) tile coords whose character is NOT blank (0)."
        return self.get_filtered_tiles(frame, layer, 0, invert_filter=True)
    
    def get_simple_name(self):
        return os.path.splitext(os.path.basename(self.filename))[0]


class ArtFromDisk(Art):
    "Subclass of Art that loads from a file. Main difference is initialization."
    def __init__(self, filename, app):
        self.valid = False
        try:
            d = json.load(open(filename))
        except:
            return
        width = d['width']
        height = d['height']
        charset = app.load_charset(d['charset'])
        if not charset:
            app.log('Character set %s not found!' % d['charset'])
            return
        palette = app.load_palette(d['palette'])
        if not palette:
            app.log('Palette %s not found!' % d['palette'])
            return
        # store loaded data for init_layers/frames
        self.loaded_data = d
        # base Art class initializes all vars, thereafter we just populate
        Art.__init__(self, filename, app, charset, palette,
                     width, height)
        # still loading...
        self.valid = False
        if not self.app.override_saved_camera:
            cam = d['camera']
            self.camera_x, self.camera_y, self.camera_z = cam[0], cam[1], cam[2]
        else:
            self.update_saved_camera(self.app.camera)
        # read saved tile attributes, which won't exist in pre-0.9.6 PSCI files
        if 'selected_char' in d:
            self.selected_char = d['selected_char']
        if 'selected_fg_color' in d:
            self.selected_fg_color = d['selected_fg_color']
        if 'selected_bg_color' in d:
            self.selected_bg_color = d['selected_bg_color']
        if 'selected_xform' in d:
            self.selected_xform = d['selected_xform']
        # update renderables with new data
        self.update()
        # signify to app that this file loaded successfully
        self.valid = True
    
    def log_init(self):
        self.app.log('Loaded %s from disk:' % filename)
        self.app.log('  character set: %s' % self.charset.name)
        self.app.log('  palette: %s' % self.palette.name)
        self.app.log('  width/height: %s x %s' % (self.width, self.height))
        self.app.log('  frames: %s' % self.frames)
        self.app.log('  layers: %s' % self.layers)
    
    def init_layers(self):
        frames = self.loaded_data['frames']
        # number of layers should be same for all frames
        self.layers = len(frames[0]['layers'])
        self.layers_z, self.layers_visibility, self.layer_names = [], [], []
        for i,layer in enumerate(frames[0]['layers']):
            self.layers_z.append(layer['z'])
            self.layers_visibility.append(bool(layer.get('visible', 1)))
            layer_num = str(i + 1)
            self.layer_names.append(layer.get('name', 'Layer %s' % layer_num))
        active_layer = self.loaded_data.get('active_layer', 0)
        self.set_active_layer(active_layer)
    
    def init_frames(self):
        frames = self.loaded_data['frames']
        self.frames = len(frames)
        self.active_frame = 0
        self.frame_delays = []
        # build tile data arrays from frame+layer lists
        shape = (self.layers, self.height, self.width, 4)
        for frame in frames:
            self.frame_delays.append(frame['delay'])
            chars = np.zeros(shape, dtype=np.float32)
            uvs = self.new_uv_layers(self.layers)
            uv_maps = np.zeros(shape, dtype=np.uint32)
            fg_colors = chars.copy()
            bg_colors = chars.copy()
            for layer_index,layer in enumerate(frame['layers']):
                x, y = 0, 0
                for tile in layer['tiles']:
                    chars[layer_index][y][x] = tile['char']
                    fg_colors[layer_index][y][x] = tile['fg']
                    bg_colors[layer_index][y][x] = tile['bg']
                    uvs[layer_index][y][x] = uv_types[tile.get('xform', UV_NORMAL)]
                    uv_maps[layer_index][y][x] = tile.get('xform', UV_NORMAL)
                    x += 1
                    if x >= self.width:
                        x = 0
                        y += 1
            self.chars.append(chars)
            self.fg_colors.append(fg_colors)
            self.bg_colors.append(bg_colors)
            self.uv_mods.append(uvs)
            self.uv_maps.append(uv_maps)
        # set active frame properly
        active_frame = self.loaded_data.get('active_frame', 0)
        self.set_active_frame(active_frame)
    
    def first_update(self):
        # do nothing on first update during Art.init; we update after loading
        pass


class ArtInstance(Art):
    """
    Deep copy / clone of a source Art that can hold unique changes and be
    restored to its source.
    """
    update_when_source_changes = True
    "Set False if you want to manually update this Art."
    def __init__(self, source):
        self.source = source
        # unique(?) filename
        self.filename = '%s_Instance%i' % (source.filename, time.time())
        self.app = source.app
        self.instances = None
        self.char_changed_frames, self.uv_changed_frames = {}, {}
        self.fg_changed_frames, self.bg_changed_frames = {}, {}
        # init lists that should be retained across refreshes
        self.scripts = []
        self.script_rates = []
        self.scripts_next_exec_time = []
        self.renderables = []
        # instances shouldn't have instances; cause user problems if attempted
        self.instances = None
        self.restore_from_source()
        self.source.instances.append(self)
    
    def set_unsaved_changes(self, new_status):
        pass
    
    def restore_from_source(self):
        "Restore ArtInstance to its source Art's new values."
        # copy common references/values
        for prop in ['app', 'width', 'height', 'charset', 'palette',
                     'quad_width', 'quad_height', 'layers', 'frames']:
            setattr(self, prop, getattr(self.source, prop))
        # copy lists
        self.layers_z = self.source.layers_z[:]
        self.layers_visibility = self.source.layers_visibility[:]
        self.layer_names = self.source.layer_names[:]
        self.frame_delays = self.source.frame_delays[:]
        # deep copy tile data lists
        self.chars, self.fg_colors, self.bg_colors = [], [], []
        self.uv_mods, self.uv_maps = [], []
        for frame_chars in self.source.chars:
            self.chars.append(frame_chars.copy())
        for frame_uvs in self.source.uv_mods:
            self.uv_mods.append(frame_uvs.copy())
        for frame_uv_maps in self.source.uv_maps:
            self.uv_maps.append(frame_uv_maps.copy())
        for frame_fg_colors in self.source.fg_colors:
            self.fg_colors.append(frame_fg_colors.copy())
        for frame_bg_colors in self.source.bg_colors:
            self.bg_colors.append(frame_bg_colors.copy())
        self.geo_changed = True
        self.mark_all_frames_changed()
        self.update()


class TileIter:
    "Iterator for iterating over all tiles in all layers and frames in an Art."
    def __init__(self, art):
        self.width, self.height = art.width, art.height
        self.frames, self.layers = art.frames, art.layers
    
    def __iter__(self):
        self.frame, self.layer = 0, 0
        self.x, self.y = -1, 0
        return self
    
    def __next__(self):
        self.x += 1
        if self.x >= self.width:
            self.x = 0
            self.y += 1
        if self.y >= self.height:
            self.y = 0
            self.layer += 1
        if self.layer >= self.layers:
            self.layer = 0
            self.frame += 1
        if self.frame >= self.frames:
            raise StopIteration
        return self.frame, self.layer, self.x, self.y
