Add preliminary graphics library with world generation
authorW. Trevor King <wking@drexel.edu>
Sat, 27 Mar 2010 20:51:19 +0000 (16:51 -0400)
committerW. Trevor King <wking@drexel.edu>
Sat, 27 Mar 2010 20:51:19 +0000 (16:51 -0400)
pyrisk/graphics.py [new file with mode: 0644]

diff --git a/pyrisk/graphics.py b/pyrisk/graphics.py
new file mode 100644 (file)
index 0000000..4a758cb
--- /dev/null
@@ -0,0 +1,443 @@
+# Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""Various PyRisk class -> graphic renderers.
+
+Creates SVG files by hand.  See the TemplateLibrary class for a
+description of map specification format.
+"""
+
+import operator
+import os
+import os.path
+
+from .base import ID_CmpMixin
+
+
+class Template (object):
+    """Setup regions for a particular world.
+    """
+    def __init__(self, name, regions):
+        self.name = name
+        self.regions = regions
+
+class TemplateLibrary (object):
+    """Create Templates on demand from a directory of template data.
+
+    TODO: explain template data format.
+    """
+    def __init__(self, template_dir='/usr/share/pyrisk/templates/'):
+        self.template_dir = os.path.abspath(os.path.expanduser(template_dir))
+    def get(self, name):
+        region_pointlists,route_pointlists = self._get_pointlists(name)
+        template = self._generate_template(region_pointlists, route_pointlists)
+        return template
+    def _get_pointlists(self, name):
+        dirname = os.path.join(self.template_dir, name.lower())
+        try:
+            files = os.listdir(dirname)
+        except IOError:
+            return None
+        region_pointlists = {}
+        route_pointlists = {}
+        for filename in files:
+            path = os.path.join(dirname, filename)
+            name,extension = filename.rsplit('.', 1)
+            if extension == 'reg':
+                region_pointlists[name] = self._read_file(path)
+            elif extension == 'rt':
+                route_pointlists[name] = self._read_file(path)
+        return (region_pointlists, route_pointlists)
+    def _read_file(self, filename):
+        pointlist = []
+        for line in open(filename, 'r'):
+            line = line.strip()
+            if len(line) == 0:
+                pointlist.append(None)
+                continue
+            fields = line.split('\t')
+            x = int(fields[0])
+            y = -int(fields[1])
+            if len(fields) == 3:
+                label = fields[2].strip()
+            else:
+                label = None
+            pointlist.append((x,y,label))
+        return pointlist
+    def _generate_template(self, region_pointlists, route_pointlists):
+        regions = []
+        all_boundaries = []
+        for name,pointlist in region_pointlists.items():
+            boundaries,head_to_tail = self._pointlist_to_array_of_boundaries(
+                all_boundaries, pointlist)
+            regions.append(Region(name, boundaries, head_to_tail))
+            r = regions[-1]
+        for name,pointlist in route_pointlists.items():
+            boundaries,head_to_tail = self._pointlist_to_array_of_boundaries(
+                all_boundaries, pointlist)
+            assert len(boundaries) == 1, boundaries
+            route = boundaries[0]
+            for terminal in [route[0], route[-1]]:
+                for r in regions:
+                    for point in r.outline:
+                        if hasattr(point, 'name') \
+                                and point.name == terminal.name:
+                            r.routes.append(route)
+                            r.route_head_to_tail.append(
+                                terminal == route[0])
+                            route.regions.append(r)
+        for r in regions:
+            r.locate_routes()
+        match_counts = [b.match_count for b in all_boundaries]
+        assert min(match_counts) in [0, 1], set(match_counts)
+        assert max(match_counts) == 1, set(match_counts)
+        return Template('template', regions)
+    def _pointlist_to_array_of_boundaries(self, all_boundaries, pointlist):
+        boundaries = []
+        head_to_tail = []
+        b_points = []
+        for i,point in enumerate(pointlist):
+            if point == None:
+                boundary,reverse = self._analyze(b_points)
+                boundary = self._insert_boundary(all_boundaries, boundary)
+                boundaries.append(boundary)
+                head_to_tail.append(not reverse)
+                b_points = []
+                continue
+            b_points.append(point)
+        if len(b_points) > 0:
+            boundary,reverse = self._analyze(b_points)
+            boundary = self._insert_boundary(all_boundaries, boundary)
+            boundaries.append(boundary)
+            head_to_tail.append(not reverse)
+        return boundaries, head_to_tail
+    def _analyze(self, boundary_points):
+        start = self._vbp(boundary_points[0])
+        stop = self._vbp(boundary_points[-1])
+        if stop < start:
+            reverse = True
+        points = [self._vbp(b) for b in boundary_points]
+        reverse = start > stop
+        if reverse == True:
+            points.reverse()
+            start,stop = (stop, start)
+        boundary = Boundary([p-start for p in points])
+        for bp,p in zip(boundary, points):
+            bp.name = p.name # preserve point names
+        boundary.name = '(%s) -> (%s)' % (start.name, stop.name)
+        boundary.real_pos = start
+        return (boundary, reverse)
+    def _vbp(self, boundary_point):
+        v = Vector((boundary_point[0], boundary_point[1]))
+        v.name = boundary_point[2]
+        return v
+    def _insert_boundary(self, all_boundaries, new):
+        if new in all_boundaries:
+            return new
+        for b in all_boundaries:
+            if len(b) == len(new) and b.real_pos == new.real_pos:
+                match = True
+                for bp,np in zip(b, new):
+                    if bp != np:
+                        match = False
+                        break
+                if match == True:
+                    b.match_count += 1
+                    return b
+        all_boundaries.append(new)
+        new.match_count = 0
+        return new
+
+TEMPLATE_LIBRARY = TemplateLibrary()
+
+
+class Vector (tuple):
+    """Simple vector addition and subtraction.
+
+    >>> v = Vector
+    >>> a = v((0, 0))
+    >>> b = v((1, 1))
+    >>> c = v((2, 3))
+    >>> a+b
+    (1, 1)
+    >>> a+b+c
+    (3, 4)
+    >>> b-c
+    (-1, -2)
+    >>> -c
+    (-2, -3)
+    >>> a < b
+    True
+    >>> c > b
+    True
+    """
+    def _set_name(self, new, other=None):
+        if hasattr(self, 'name'):
+            if self.name == None:
+                if hasattr(other, 'name'):
+                    new.name = other.name
+                    return
+            new.name = self.name
+        elif hasattr(other, 'name'):
+            new.name = other.name
+    def __neg__(self):
+        new = self.__class__(map(operator.neg, self))
+        self._set_name(new)
+        return new
+    def __add__(self, other):
+        if len(self) != len(other):
+            raise ValueError('length missmatch %s, %s' % (self, other))
+        new = self.__class__(map(operator.add, self, other))
+        self._set_name(new, other)
+        return new
+    def __sub__(self, other):
+        if len(self) != len(other):
+            raise ValueError('length missmatch %s, %s' % (self, other))
+        new = self.__class__(map(operator.sub, self, other))
+        self._set_name(new, other)
+        return new
+    def __mul__(self, other):
+        if len(self) != len(other):
+            raise ValueError('length missmatch %s, %s' % (self, other))
+        new = self.__class__(map(operator.mul, self, other))
+        self._set_name(new, other)
+        return new
+
+def nameless(vector):
+    """Return a nameless version of a given Vector.
+
+    Useful for ensuring the result of a sum / etc. has the name of the
+    *other* vector.
+    """
+    return Vector(vector)
+
+class Boundary (ID_CmpMixin, list):
+    """Contains a list of points along the boundary.
+
+    All positions are relative to the location of the first point,
+    which should therefore always be (0,0).
+    """
+    def __init__(self, points):
+        list.__init__(self)
+        ID_CmpMixin.__init__(self)
+        for p in points:
+            self.append(Vector(p))
+        self.regions = []
+        assert self[0] == (0,0), self
+        self.x_min = min([p[0] for p in self])
+        self.x_max = max([p[0] for p in self])
+        self.y_min = min([p[1] for p in self])
+        self.y_max = max([p[1] for p in self])
+
+class Region (ID_CmpMixin, list):
+    """Contains a list of boundaries and a label.
+
+    Regions can be Territories, sections of ocean, etc.
+
+    >>> r = Region('Earth',
+    ...            [Boundary([(0,0), (0,1)]),
+    ...             Boundary([(0,0), (1,0)]),
+    ...             Boundary([(0,0), (0,1)]),
+    ...             Boundary([(0,0), (1,0)])],
+    ...            [True, True, False, False],
+    ...            (0.5, 0.5))
+    >>> r.outline
+    [(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)]
+    """
+    def __init__(self, name, boundaries, head_to_tail, routes=None,
+                 route_head_to_tail=None, label_offset=(0,0)):
+        list.__init__(self, boundaries)
+        ID_CmpMixin.__init__(self)
+        for boundary in self:
+            boundary.regions.append(self)
+        self.head_to_tail = head_to_tail
+        self.name = name
+        self.routes = routes
+        self.route_head_to_tail = route_head_to_tail
+        if routes == None:
+            assert route_head_to_tail == None
+            self.routes = []
+            self.route_head_to_tail = []
+        self.route_starts = [] # set by .locate_routes
+        self.label_offset = Vector(label_offset)
+        self.generate_outline() # sets .outline, .starts
+        self.x_min = min([b.x_min+s[0] for b,s in zip(self, self.starts)])
+        self.x_max = max([b.x_max+s[0] for b,s in zip(self, self.starts)])
+        self.y_min = min([b.y_min+s[1] for b,s in zip(self, self.starts)])
+        self.y_max = max([b.y_max+s[1] for b,s in zip(self, self.starts)])
+    def generate_outline(self):
+        """Return a list of boundary points surrounding the region.
+
+        The main issue here is determining the proper border
+        orientation for a CCW outline, which we do via a user-supplied
+        list head_to_tail.
+        """
+        self.starts = []
+        points = [Vector((0,0))]
+        for boundary,htt in zip(self, self.head_to_tail):
+            pos = points[-1]
+            if htt == True:
+                assert boundary[0] == (0,0), boundary
+                new = boundary[1:]
+            else:
+                pos -= nameless(boundary[-1])
+                new = reversed(boundary[:-1])
+            pos = nameless(pos)
+            self.starts.append(pos)
+            for p in new:
+                points.append(pos+p)
+        assert points[-1] == points[0], points
+        self.outline = points
+    def locate_routes(self):
+        self.route_starts = []
+        for route,htt in zip(self.routes, self.route_head_to_tail):
+            if htt:
+                anchor = route[0]
+            else:
+                anchor = route[-1]
+            for point in self.outline:
+                if hasattr(point, 'name') and point.name == anchor.name:
+                    self.route_starts.append(point-nameless(anchor))
+                    break
+
+class Route (ID_CmpMixin):
+    """Connect non-adjacent Regions.
+    """
+    def __init__(self, boundary):
+        ID_CmpMixin.__init__(self)
+        self.boundary = boundary
+
+
+class WorldRenderer (object):
+    def __init__(self, template_lib=None, line_width=2, buf=10, dpcm=60):
+        self.template_lib = template_lib
+        if self.template_lib == None:
+            self.template_lib = TEMPLATE_LIBRARY
+        self.buf = buf
+        self.line_width = line_width
+        self.dpcm = dpcm
+    def render(self, world):
+        template = self.template_lib.get(world.name)
+        if template == None:
+            template = self._auto_template(world)
+        return self.render_template(template)
+    def render_template(self, template):
+        region_pos,width,height = self._locate(template)
+        lines = [
+            '<?xml version="1.0" standalone="no"?>',
+            '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"',
+            '  "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">',
+            '<svg width="%.1fcm" height="%.1fcm" viewBox="0 0 %d %d"'
+            % (float(width)/self.dpcm, float(height)/self.dpcm,
+               width, height),
+            '     xmlns="http://www.w3.org/2000/svg" version="1.1">',
+            '<desc>PyRisk world: %s</desc>' % template.name,
+            ]
+        drawn_rts = {}
+        for r in template.regions:
+            lines.extend([
+                    '<!-- %s -->' % r.name,
+                    '<polygon fill="red" stroke="blue" stroke-width="%d"'
+                    % self.line_width,
+                    '         points="%s" />'
+                    % ' '.join(['%d,%d' % ((region_pos[id(r)]+p)
+                                           *(1,-1) # svg y value increases down
+                                           +(0,height)) # shift back into bbox
+                                for p in r.outline[:-1]])
+                    ])
+            for rt,rt_start in zip(r.routes, r.route_starts):
+                if id(rt) in drawn_rts:
+                    continue
+                drawn_rts[id(rt)] = rt
+                lines.extend([
+                        '<polyline stroke="black" stroke-width="%d"'
+                        % self.line_width,
+                        '         points="%s" />'
+                        % ' '.join(['%d,%d' % ((region_pos[id(r)]+rt_start+p)
+                                           *(1,-1) # svg y value increases down
+                                           +(0,height)) # shift back into bbox
+                                    for p in rt])
+                        ])
+        lines.extend([
+                '<circle fill="black" cx="0" cy="0" r="20" />',
+                '<circle fill="green" cx="%d" cy="%d" r="20" />'
+                 % (width, height)
+                ])
+        lines.extend(['</svg>', ''])
+        return '\n'.join(lines)
+    def _locate(self, template):
+        region_pos = {} # {id: absolute position, ...}
+        boundary_pos = {} # {id: absolute position, ...}
+        route_pos = {} # {id: absolute position, ...}
+        b1 = template.regions[0][0]
+        boundary_pos[id(b1)] = Vector((0,0)) # fix the first boundary point
+        stack = [r for r in b1.regions]
+        while len(stack) > 0:
+            r = stack.pop()
+            if id(r) in region_pos:
+                continue # skip duplicate entries
+            r_start = None
+            for b,rel_b_start in zip(r, r.starts):
+                if id(b) in boundary_pos:
+                    b_start = boundary_pos[id(b)]
+                    r_start = b_start - rel_b_start
+                    break # found an anchor
+            if r_start == None:
+                for rt,rel_rt_start in zip(r.routes, r.route_starts):
+                    if id(rt) in route_pos:
+                        rt_start = route_pos[id(rt)]
+                        r_start = rt_start - rel_rt_start
+                        break # found an anchor
+            region_pos[id(r)] = r_start
+            for b,rel_b_start in zip(r, r.starts):
+                if id(b) not in boundary_pos:
+                    boundary_pos[id(b)] = r_start + rel_b_start
+                    for r2 in b.regions:
+                        stack.append(r2)
+            for rt,rt_start in zip(r.routes, r.route_starts):
+                if id(rt) not in route_pos:
+                    route_pos[id(rt)] = r_start + rt_start
+                    for r2 in rt.regions:
+                        stack.append(r2)
+        for r in template.regions:
+            if id(r) not in region_pos:
+                raise KeyError(r.name)
+        x_min = min([r.x_min + region_pos[id(r)][0]
+                     for r in template.regions]) - self.buf
+        x_max = max([r.x_max + region_pos[id(r)][0]
+                     for r in template.regions]) + self.buf
+        y_min = min([r.y_min + region_pos[id(r)][1]
+                     for r in template.regions]) - self.buf
+        y_max = max([r.y_max + region_pos[id(r)][1]
+                     for r in template.regions]) + self.buf
+        for key,value in region_pos.items():
+            region_pos[key] = value - Vector((x_min, y_min))
+        return (region_pos, x_max-x_min, y_max-y_min)
+    def _auto_template(self, world):
+        raise NotImplementedError
+
+def test():
+    import doctest, sys
+    failures,tests = doctest.testmod(sys.modules[__name__])
+    return failures
+
+def render_earth():
+    from .base import generate_earth
+    r = WorldRenderer()
+    print r.render(generate_earth())
+    #f = open('world.svg', 'w')
+    #f.write(r.render(generate_earth()))
+    #f.close()