diff --git a/overviewer_core/tileset.py b/overviewer_core/tileset.py index de4824d..9dfae89 100644 --- a/overviewer_core/tileset.py +++ b/overviewer_core/tileset.py @@ -18,8 +18,9 @@ import os.path from collections import namedtuple import logging import shutil +import itertools -from . import util +from .util import iterate_base4, convert_coords """ @@ -237,7 +238,7 @@ class TileSet(object): for c_x, c_z, _ in self.regionset.iterate_chunks(): # Convert these coordinates to row/col - col, row = util.convert_coords(c_x, c_z) + col, row = convert_coords(c_x, c_z) minrow = min(minrow, row) maxrow = max(maxrow, row) @@ -360,3 +361,309 @@ def get_dirdepth(outputdir): return depth +class DirtyTiles(object): + """This tree holds which tiles need rendering. + Each instance is a node, and the root of a subtree. + + Each node knows its "level", which corresponds to the zoom level where 0 is + the inner-most (most zoomed in) tiles. + + Instances hold the clean/dirty state of their children. Leaf nodes are + images and do not physically exist in the tree, level 1 nodes keep track of + leaf image state. Level 2 nodes keep track of level 1 state, and so fourth. + + In attempt to keep things memory efficient, subtrees that are completely + dirty are collapsed + + """ + __slots__ = ("depth", "children") + def __init__(self, depth): + """Initialize a new tree with the specified depth. This actually + initializes a node, which is the root of a subtree, with `depth` levels + beneath it. + + """ + # Stores the depth of the tree according to this node. This is not the + # depth of this node, but rather the number of levels below this node + # (including this node). + self.depth = depth + + # the self.children array holds the 4 children of this node. This + # follows the same quadtree convention as elsewhere: children 0, 1, 2, + # 3 are the upper-left, upper-right, lower-left, and lower-right + # respectively + # Values are: + # False + # All children down this subtree are clean + # True + # All children down this subtree are dirty + # A DirtyTiles instance + # the instance defines which children down that subtree are + # clean/dirty. + # A node with depth=1 cannot have a DirtyTiles instance in its + # children since its leaves are images, not more tree + self.children = [False] * 4 + + def set_dirty(self, path): + """Marks the requested leaf node as "dirty". + + Path is an iterable of integers representing the path to the leaf node + that is requested to be marked as dirty. + + """ + path = list(path) + assert len(path) == self.depth + path.reverse() + self._set_dirty_helper(path) + + def _set_dirty_helper(self, path): + """Recursive call for set_dirty() + + Expects path to be a list in reversed order + + If *all* the nodes below this one are dirty, this function returns + true. Otherwise, returns None. + + """ + + if self.depth == 1: + # Base case + self.children[path[0]] = True + + # Check to see if all children are dirty + if all(self.children): + return True + else: + # Recursive case + + childnum = path.pop() + child = self.children[childnum] + + if child == False: + # Create a new node + child = self.__class__(self.depth-1) + child._set_dirty_helper(path) + self.children[childnum] = child + elif child == True: + # Every child is already dirty. Nothing to do. + return + else: + # subtree is mixed clean/dirty. Recurse + ret = child._set_dirty_helper(path) + if ret: + # Child says it's completely dirty, so we can purge the + # subtree and mark it as dirty. The subtree will be garbage + # collected when this method exits. + self.children[childnum] = True + + # Since we've marked an entire sub-tree as dirty, we may be + # able to signal to our parent + if all(x is True for x in self.children): + return True + + def iterate_dirty(self, level=None): + """Returns an iterator over every dirty tile in this subtree. Each item + yielded is a sequence of integers representing the quadtree path to the + dirty tile. Yielded sequences are of length self.depth. + + If level is None, iterates over tiles of the highest level, i.e. + worldtiles. If level is a value between 0 and the depth of this tree, + this method iterates over tiles at that level. Zoom level 0 is zoomed + all the way out, zoom level `depth` is all the way in. + + In other words, specifying level causes the tree to be iterated as if + it was only that depth. + + """ + if level is None: + todepth = 1 + else: + if not (level > 0 and level <= self.depth): + raise ValueError("Level parameter must be between 1 and %s" % self.depth) + todepth = self.depth - level + 1 + + return (tuple(reversed(rpath)) for rpath in self._iterate_dirty_helper(todepth)) + + def _iterate_dirty_helper(self, todepth): + if self.depth == todepth: + # Base case + if self.children[0]: yield [0] + if self.children[1]: yield [1] + if self.children[2]: yield [2] + if self.children[3]: yield [3] + + else: + # Higher levels: + for c, child in enumerate(self.children): + if child == True: + # All dirty down this subtree, iterate over every leaf + for x in iterate_base4(self.depth-todepth): + x = list(x) + x.append(c) + yield x + elif child != False: + # Mixed dirty/clean down this subtree, recurse + for path in child._iterate_dirty_helper(todepth): + path.append(c) + yield path + + def query_path(self, path): + """Queries for the state of the given tile in the tree. + + Returns False for "clean", True for "dirty" + + """ + # Traverse the tree down the given path. If the tree has been + # collapsed, then just return what the subtree is. Otherwise, if we + # find the specific DirtyTree requested, return its state using the + # __nonzero__ call. + treenode = self + for pathelement in path: + treenode = treenode.children[pathelement] + if not isinstance(treenode, DirtyTiles): + return treenode + + # If the method has not returned at this point, treenode is the + # requested node, but it is an inner node with possibly mixed state + # subtrees. If any of the children are True return True. This call + # relies on the __nonzero__ method + return bool(treenode) + + def __nonzero__(self): + """Returns the boolean context of this particular node. If any + descendent of this node is True return True. Otherwise, False. + + """ + # Any chilren that are True or are DirtyTiles that evaluate to True + # IDEA: look at all children for True before recursing + # Better idea: every node except the root /must/ have a dirty + # descendent or it wouldn't exist. This assumption is only valid as + # long as an unset_dirty() method or similar does not exist. + return any(self.children) + + def count(self): + """Returns the total number of dirty leaf nodes. + + """ + # TODO: Make this more efficient (although for even the largest trees, + # this takes only seconds) + c = 0 + for _ in self.iterate_dirty(): + c += 1 + return c + +class Tile(object): + """A simple container class that represents a single render-tile. + + A render-tile is a tile that is rendered, not a tile composed of other + tiles (composite-tile). + + """ + __slots__ = ("col", "row", "path") + def __init__(self, col, row, path): + """Initialize the tile obj with the given parameters. It's probably + better to use one of the other constructors though + + """ + self.col = col + self.row = row + self.path = tuple(path) + + def __repr__(self): + return "%s(%r,%r,%r)" % (self.__class__.__name__, self.col, self.row, self.path) + + def __eq__(self,other): + return self.col == other.col and self.row == other.row and tuple(self.path) == tuple(other.path) + + def __ne__(self, other): + return not self == other + + def get_filepath(self, tiledir, imgformat): + """Returns the path to this file given the directory to the tiles + + """ + # os.path.join would be the proper way to do this path concatenation, + # but it is surprisingly slow, probably because it checks each path + # element if it begins with a slash. Since we know these components are + # all relative, just concatinate with os.path.sep + pathcomponents = [tiledir] + pathcomponents.extend(str(x) for x in self.path) + path = os.path.sep.join(pathcomponents) + imgpath = ".".join((path, imgformat)) + return imgpath + + @classmethod + def from_path(cls, path): + """Constructor that takes a path and computes the col,row address of + the tile and constructs a new tile object. + + """ + path = tuple(path) + + depth = len(path) + + # Radius of the world in chunk cols/rows + # (Diameter in X is 2**depth, divided by 2 for a radius, multiplied by + # 2 for 2 chunks per tile. Similarly for Y) + xradius = 2**depth + yradius = 2*2**depth + + col = -xradius + row = -yradius + xsize = xradius + ysize = yradius + + for p in path: + if p in (1,3): + col += xsize + if p in (2,3): + row += ysize + xsize //= 2 + ysize //= 2 + + return cls(col, row, path) + + @classmethod + def compute_path(cls, col, row, depth): + """Constructor that takes a col,row of a tile and computes the path. + + """ + assert col % 2 == 0 + assert row % 4 == 0 + + xradius = 2**depth + yradius = 2*2**depth + + colbounds = [-xradius, xradius] + rowbounds = [-yradius, yradius] + + path = [] + + for level in xrange(depth): + # Strategy: Find the midpoint of this level, and determine which + # quadrant this row/col is in. Then set the bounds to that level + # and repeat + + xmid = (colbounds[1] + colbounds[0]) // 2 + ymid = (rowbounds[1] + rowbounds[0]) // 2 + + if col < xmid: + if row < ymid: + path.append(0) + colbounds[1] = xmid + rowbounds[1] = ymid + else: + path.append(2) + colbounds[1] = xmid + rowbounds[0] = ymid + else: + if row < ymid: + path.append(1) + colbounds[0] = xmid + rowbounds[1] = ymid + else: + path.append(3) + colbounds[0] = xmid + rowbounds[0] = ymid + + return cls(col, row, path) diff --git a/overviewer_core/util.py b/overviewer_core/util.py index eb7830c..93cd7d6 100644 --- a/overviewer_core/util.py +++ b/overviewer_core/util.py @@ -26,7 +26,7 @@ import logging from cStringIO import StringIO import ctypes import platform -from itertools import cycle, islice +from itertools import cycle, islice, product def get_program_path(): if hasattr(sys, "frozen") or imp.is_frozen("__main__"): @@ -101,6 +101,11 @@ def roundrobin(iterables): pending -= 1 nexts = cycle(islice(nexts, pending)) +def iterate_base4(d): + """Iterates over a base 4 number with d digits""" + return product(xrange(4), repeat=d) + + def convert_coords(chunkx, chunkz): """Takes a coordinate (chunkx, chunkz) where chunkx and chunkz are in the chunk coordinate system, and figures out the row and column diff --git a/test/test_dirtytiles.py b/test/test_dirtytiles.py index 1b699f6..88bf0c2 100644 --- a/test/test_dirtytiles.py +++ b/test/test_dirtytiles.py @@ -1,6 +1,7 @@ import unittest -from overviewer_core.quadtree import DirtyTiles, iterate_base4 +from overviewer_core.tileset import DirtyTiles +from overviewer_core.util import iterate_base4 class DirtyTilesTest(unittest.TestCase): # If you change this definition, you must also change the hard-coded diff --git a/test/test_tileobj.py b/test/test_tileobj.py index 87277cf..3044651 100644 --- a/test/test_tileobj.py +++ b/test/test_tileobj.py @@ -1,7 +1,7 @@ import unittest -from overviewer_core import quadtree -from overviewer_core.quadtree import Tile +from overviewer_core.util import iterate_base4 +from overviewer_core.tileset import Tile items = [ ((-4,-8), (0,0)), @@ -28,7 +28,7 @@ class TileTest(unittest.TestCase): given to compute_path """ - for path in quadtree.iterate_base4(7): + for path in iterate_base4(7): t1 = Tile.from_path(path) col = t1.col row = t1.row