# Copyright (c) 2001, 2002, 2003 by Intevation GmbH
# Authors:
# Bernhard Herzog <bh@intevation.de>
# Jonathan Coles <jonathan@intevation.de>
# Frank Koormann <frank.koormann@intevation.de>
#
# This program is free software under the GPL (>=v2)
# Read the file COPYING coming with Thuban for details.

"""Basic rendering logic for Thuban maps

The code in this module is completely independend of wx so that it can
be tested reasonably easily and it could make it easier to write non-wx
renderers.
"""

from __future__ import generators

__version__ = "$Revision: 1.9 $"
# $Source: /thubanrepository/thuban/Thuban/UI/baserenderer.py,v $
# $Id: baserenderer.py,v 1.9 2003/11/11 18:16:20 bh Exp $

import sys
import traceback

from Thuban.Model.layer import Layer, RasterLayer
from Thuban.Model.data import SHAPETYPE_ARC, SHAPETYPE_POINT
from Thuban.Model.label import ALIGN_CENTER, ALIGN_TOP, ALIGN_BOTTOM, \
     ALIGN_LEFT, ALIGN_RIGHT

import Thuban.Model.resource

if Thuban.Model.resource.has_gdal_support():
    from gdalwarp import ProjectRasterFile


#
#       Renderer Extensions
#
# The renderer extensions provide a way to render layer types defined in
# Thuban extensions. The renderer extensions are stored as a list with
# (layer_class, draw_function) pairs. If the renderer has to draw a
# non-builtin layer type, i.e. a layer that is not a subclass of Layer
# or RasterLayer, it iterates through that list, tests whether the layer
# to be drawin is an instance of layer_class and if so calls
# draw_function with the renderer and the layer as arguments. Since
# drawing is done incrementally, the draw_function should return an
# iterable. The easiest way is to simply implement the draw_function as
# a generator and to yield in suitable places, or to return the empty
# tuple.
#
# New renderer extensions should be added with add_renderer_extension().
# If necessary the extensions list can be reset with
# init_renderer_extensions().

_renderer_extensions = []

def add_renderer_extension(layer_class, function):
    """Add a renderer extension for the layer class layer_class

    When an instance of layer_class is to be drawn by the renderer the
    renderer will call function with the renderer and the layer_class
    instance as arguments. Since drawing is done incrementally, the
    function should return an iterable. The easiest way is to simply
    implement the draw_function as a generator and to yield True in
    suitable places, or to return the empty tuple if it's not possible
    to do the rendering in incrementally.
    """
    _renderer_extensions.append((layer_class, function))

def init_renderer_extensions():
    """(Re)initialize the list of renderer extensions

    Calling this function outside of the test suite is probably not
    useful.
    """
    del _renderer_extensions[:]


#
#       Base Renderer
#

class BaseRenderer:

    """Basic Renderer Infrastructure for Thuban Maps

    This class can't be used directly to render because it doesn't know
    anything about real DCs such as how to create pens or brushes. That
    functionality has to be provided by derived classes. The reason for
    this is that it makes the BaseRenderer completely independend of wx
    and thus it's quite easy to write test cases for it.
    """
    # If true the render honors the visibility flag of the layers
    honor_visibility = 1

    # Transparent brushes and pens. Derived classes should define these
    # as appropriate.
    TRANSPARENT_PEN = None
    TRANSPARENT_BRUSH = None

    def __init__(self, dc, map, scale, offset, region = None,
                 resolution = 72.0, honor_visibility = None):
        """Inititalize the renderer.

        dc -- the device context to render on.

        scale, offset -- the scale factor and translation to convert
                between projected coordinates and the DC coordinates

        region -- The region to draw as a (x, y, width, height) tuple in
                  the map's coordinate system. Default is None meaning
                  to draw everything.

        resolution -- the assumed resolution of the DC. Used to convert
                absolute lengths like font sizes to DC coordinates. The
                default is 72.0. If given, this parameter must be
                provided as a keyword argument.

        honor_visibility -- boolean. If true, honor the visibility flag
                of the layers, otherwise draw all layers. If None (the
                default), use the renderer's default. If given, this
                parameter must be provided as a keyword argument.
        """
        # resolution in pixel/inch
        self.dc = dc
        self.map = map
        self.scale = scale
        self.offset = offset
        self.region = region
        if honor_visibility is not None:
            self.honor_visibility = honor_visibility
        # store the resolution in pixel/point because it's more useful
        # later.
        self.resolution = resolution / 72.0

    def tools_for_property(self, prop):
        """Return a suitable pen and brush for the property

        This method must be implemented in derived classes. The return
        value should be a tuple (pen, brush).
        """
        raise NotImplementedError

    def render_map(self):
        """Render the map onto the DC.

        Both map and DC are the ones the renderer was instantiated with.

        This method is just a front-end for render_map_incrementally
        which does all rendering in one go. It also calls the dc's
        BeginDrawing and EndDrawing methods before and after calling
        render_map_incrementally.
        """

        self.dc.BeginDrawing()
        try:
            for cont in self.render_map_incrementally():
                pass
        finally:
            self.dc.EndDrawing()

    def render_map_incrementally(self):
        """Render the map incrementally.

        Return an iterator whose next method should be called until it
        returns False. After returning False it will raise StopIteration
        so that you could also use it in a for loop (implementation
        detail: this method is implemented as a generator).

        Iterate through all layers and draw them. Layers containing
        vector data are drawn with the draw_shape_layer method, raster
        layers are drawn with draw_raster_layer. The label layer is
        drawn last with draw_label_layer.

        During execution of this method, the map is bound to self.map so
        that methods especially in derived classes have access to the
        map if necessary.
        """
        # Whether the raster layer has already been drawn. See below for
        # the optimization this is used for
        seenRaster = True

        #
        # This is only a good optimization if there is only one
        # raster layer and the image covers the entire window (as
        # it currently does). We note if there is a raster layer
        # and only begin drawing layers once we have drawn it.
        # That way we avoid drawing layers that won't be seen.
        #
        if Thuban.Model.resource.has_gdal_support():
            for layer in self.map.Layers():
                if isinstance(layer, RasterLayer) and layer.Visible():
                    seenRaster = False
                    break

        for layer in self.map.Layers():
            # if honor_visibility is true, only draw visible layers,
            # otherwise draw all layers
            if not self.honor_visibility or layer.Visible():
                if isinstance(layer, Layer) and seenRaster:
                    for i in self.draw_shape_layer_incrementally(layer):
                        yield True
                elif isinstance(layer, RasterLayer) \
                    and Thuban.Model.resource.has_gdal_support():
                    self.draw_raster_layer(layer)
                    seenRaster = True
                    yield True
                else:
                    # look it up in the renderer extensions
                    for cls, func in _renderer_extensions:
                        if isinstance(layer, cls):
                            for i in func(self, layer):
                                yield True
                            break
                    else:
                        # No renderer found. Print a message about it
                        print >>sys.stderr, ("Drawing layer %r not supported"
                                             % layer)
            yield True

        self.draw_label_layer(self.map.LabelLayer())
        yield False

    def draw_shape_layer_incrementally(self, layer):
        """Draw the shape layer layer onto the map incrementally.

        This method is a generator which yields True after every 500
        shapes.
        """
        scale = self.scale
        offx, offy = self.offset

        map_proj = self.map.projection
        layer_proj = layer.projection

        brush = self.TRANSPARENT_BRUSH
        pen   = self.TRANSPARENT_PEN

        old_prop = None
        old_group = None
        lc = layer.GetClassification()
        field = layer.GetClassificationColumn()
        defaultGroup = lc.GetDefaultGroup()
        table = layer.ShapeStore().Table()

        if lc.GetNumGroups() == 0:
            # There's only the default group, so we can pretend that
            # there is no field to classifiy on which makes things
            # faster since we don't need the attribute information at
            # all.
            field = None

        # Determine which render function to use.
        useraw, draw_func, draw_func_param = self.low_level_renderer(layer)

        #
        # Iterate through all shapes that have to be drawn.
        #

        # Count the shapes drawn so that we can yield every few hundred
        # shapes
        count = 0

        # Cache the tools (pens and brushes) for the classification
        # groups. This is a mapping from the group's ids to the a tuple
        # (pen, brush)
        tool_cache = {}

        for shape in self.layer_shapes(layer):
            count += 1
            if field is None:
                group = defaultGroup
            else:
                value = table.ReadValue(shape.ShapeID(), field)
                group = lc.FindGroup(value)

            if not group.IsVisible():
                continue

            try:
                pen, brush = tool_cache[id(group)]
            except KeyError:
                pen, brush = tool_cache[id(group)] \
                             = self.tools_for_property(group.GetProperties())

            if useraw:
                data = shape.RawData()
            else:
                data = shape.Points()
            draw_func(draw_func_param, data, pen, brush)
            if count % 500 == 0:
                yield True

    def layer_shapes(self, layer):
        """Return an iterable over the shapes to be drawn from the given layer.

        The default implementation simply returns all ids in the layer.
        Override in derived classes to be more precise.
        """
        return layer.ShapeStore().AllShapes()

    def low_level_renderer(self, layer):
        """Return the low-level renderer for the layer for draw_shape_layer

        The low level renderer to be returned by this method is a tuple
        (useraw, func, param) where useraw is a boolean indicating
        whether the function uses the raw shape data, func is a callable
        object and param is passed as the first parameter to func. The
        draw_shape_layer method will call func like this:

            func(param, shapedata, pen, brush)

        where shapedata is the return value of the RawData method of the
        shape object if useraw is true or the return value of the Points
        method if it's false. pen and brush are the pen and brush to use
        to draw the shape on the dc.

        The default implementation returns one of
        self.draw_polygon_shape, self.draw_arc_shape or
        self.draw_point_shape as func and layer as param. None of the
        method use the raw shape data. Derived classes can override this
        method to return more efficient low level renderers.
        """
        shapetype = layer.ShapeType()
        if shapetype == SHAPETYPE_POINT:
            func = self.draw_point_shape
        elif shapetype == SHAPETYPE_ARC:
            func = self.draw_arc_shape
        else:
            func = self.draw_polygon_shape
        return False, func, layer

    def make_point(self, x, y):
        """Convert (x, y) to a point object.

        Derived classes must override this method.
        """
        raise NotImplementedError

    def projected_points(self, layer, points):
        """Return the projected coordinates of the points taken from layer.

        Transform all the points in the list of lists of coordinate
        pairs in points.

        The transformation applies the inverse of the layer's projection
        if any, then the map's projection if any and finally applies
        self.scale and self.offset.

        The returned list has the same structure as the one returned the
        shape's Points method.
        """
        proj = self.map.GetProjection()
        if proj is not None:
            forward = proj.Forward
        else:
            forward = None
        proj = layer.GetProjection()
        if proj is not None:
            inverse = proj.Inverse
        else:
            inverse = None
        result = []
        scale = self.scale
        offx, offy = self.offset
        make_point = self.make_point
        for part in points:
            result.append([])
            for x, y in part:
                if inverse:
                    x, y = inverse(x, y)
                if forward:
                    x, y = forward(x, y)
                result[-1].append(make_point(x * scale + offx,
                                             -y * scale + offy))
        return result

    def draw_polygon_shape(self, layer, points, pen, brush):
        """Draw a polygon shape from layer with the given brush and pen

        The shape is given by points argument which is a the return
        value of the shape's Points() method. The coordinates in the
        DC's coordinate system are determined with
        self.projected_points.
        """
        points = self.projected_points(layer, points)

        if brush is not self.TRANSPARENT_BRUSH:
            polygon = []
            for part in points:
                polygon.extend(part)

            insert_index = len(polygon)
            for part in points[:-1]:
                polygon.insert(insert_index, part[0])

            self.dc.SetBrush(brush)
            self.dc.SetPen(self.TRANSPARENT_PEN)
            self.dc.DrawPolygon(polygon)

        if pen is not self.TRANSPARENT_PEN:
            # At last draw the boundarys of the simple polygons
            self.dc.SetBrush(self.TRANSPARENT_BRUSH)
            self.dc.SetPen(pen)
            for part in points:
                self.dc.DrawLines(part)

    def draw_arc_shape(self, layer, points, pen, brush):
        """Draw an arc shape from layer with the given brush and pen

        The shape is given by points argument which is a the return
        value of the shape's Points() method. The coordinates in the
        DC's coordinate system are determined with
        self.projected_points.
        """
        points = self.projected_points(layer, points)
        self.dc.SetBrush(brush)
        self.dc.SetPen(pen)
        for part in points:
            self.dc.DrawLines(part)

    def draw_point_shape(self, layer, points, pen, brush):
        """Draw a point shape from layer with the given brush and pen

        The shape is given by points argument which is a the return
        value of the shape's Points() method. The coordinates in the
        DC's coordinate system are determined with
        self.projected_points.

        The point is drawn as a circle centered on the point.
        """
        points = self.projected_points(layer, points)
        if not points:
            return

        radius = int(round(self.resolution * 5))
        self.dc.SetBrush(brush)
        self.dc.SetPen(pen)
        for part in points:
            for p in part:
                self.dc.DrawEllipse(p.x - radius, p.y - radius,
                                    2 * radius, 2 * radius)

    def draw_raster_layer(self, layer):
        """Draw the raster layer

        This implementation does the projection and scaling of the data
        as required by the layer's and map's projections and the scale
        and offset of the renderer and then hands the transformed data
        to self.draw_raster_data() which has to be implemented in
        derived classes.
        """
        offx, offy = self.offset
        width, height = self.dc.GetSizeTuple()

        in_proj = ""
        proj = layer.GetProjection()
        if proj is not None:
            for p in proj.GetAllParameters():
                in_proj += "+" + p + " "

        out_proj = ""
        proj = self.map.GetProjection()
        if proj is not None:
            for p in proj.GetAllParameters():
                out_proj += "+" + p + " "

        xmin = (0 - offx) / self.scale
        ymin = (offy - height) / self.scale
        xmax = (width - offx) / self.scale
        ymax = (offy - 0) / self.scale

        try:
            data = ProjectRasterFile(layer.GetImageFilename(),
                                     in_proj, out_proj,
                                     (xmin, ymin, xmax, ymax), "",
                                     (width, height))
        except (IOError, AttributeError, ValueError):
            # Why does this catch AttributeError and ValueError?
            # FIXME: The exception should be communicated to the user
            # better.
            traceback.print_exc()
        else:
            self.draw_raster_data(data, "BMP")

    def draw_raster_data(self, data, format="BMP"):
        """Draw the raster image in data onto the DC

        The raster image data is a string holding the data in the format
        indicated by the format parameter. The image is assumed to be
        exactly the size of the dc and to cover it completely.

        The format parameter is a string with the name of the format.
        The following format names should be used:

          'BMP' -- Windows Bitmap
          'JPEG' -- Jpeg

        The default format is 'bmp'.

        This method has to be implemented by derived classes. The
        implementation in the derived class should try to support at
        least the formats specified above and may support more.
        """
        raise NotImplementedError

    def label_font(self):
        """Return the font object for the label layer"""
        raise NotImplementedError

    def draw_label_layer(self, layer):
        """Draw the label layer

        All labels are draw in the font returned by self.label_font().
        """
        scale = self.scale
        offx, offy = self.offset

        self.dc.SetFont(self.label_font())

        map_proj = self.map.projection
        if map_proj is not None:
            forward = map_proj.Forward
        else:
            forward = None

        for label in layer.Labels():
            x = label.x
            y = label.y
            text = label.text
            if forward:
                x, y = forward(x, y)
            x = int(round(x * scale + offx))
            y = int(round(-y * scale + offy))
            width, height = self.dc.GetTextExtent(text)
            if label.halign == ALIGN_LEFT:
                # nothing to be done
                pass
            elif label.halign == ALIGN_RIGHT:
                x = x - width
            elif label.halign == ALIGN_CENTER:
                x = x - width/2
            if label.valign == ALIGN_TOP:
                # nothing to be done
                pass
            elif label.valign == ALIGN_BOTTOM:
                y = y - height
            elif label.valign == ALIGN_CENTER:
                y = y - height/2
            self.dc.DrawText(text, x, y)
