$:.push File.dirname(__FILE__) + '/BinaryIO'
require 'binaryio'
require 'gui'

# Allow deep-copy of an object and all of its contents.
class Object
  def deep_copy
    Marshal.load(Marshal.dump(self))
  end
end

module CWrp

# File extension for WRP (4WVR and 8WVR) files.
EXT_WRP = '.wrp'
# File extension for XYZ format files.
EXT_XYZ = '.xyz'
# File extension for object template files.
EXT_OBJ_TEMPLATE = '.txt'

# Directory where object & texture replacement and mask config files are stored.
DEFAULT_CONFIG_DIR = '../config/'
################################################################################
class Vector3P
  attr_accessor(:x, :z, :y)

  # reader:: BinaryIO#BinaryReader
  def initialize(reader); end; alias_method :init_1, :initialize

  # x:: X position
  # y:: Y position
  # z:: Z position
  def initalize(x, y, z); end; alias_method :init_2, :initialize

  def initialize(*args)
    case args.size
    when 1
      reader = args[0]
      @x = reader.read_float
      @z = reader.read_float
      @y = reader.read_float
      
    when 3
        @x, @y, @z = x, y, z

    else
      raise ArgumentError
    end
  end

  # writer:: BinaryIO#BinaryWriter
  public
  def write(writer)
    writer.write_float @x
    writer.write_float @z
    writer.write_float @y

    nil
  end

  public
  def to_a
    [@x, @y, @z]
  end

  # Distance in two dimensions.
  # other:: Another Vector3P
  public
  def distance_xy(other)
    Math.sqrt(((@x - other.x) ** 2) + ((@y - other.y) ** 2))
  end

  # Distance in three dimensions.
  # other:: Another Vector3P
  public
  def distance(other)
    Math.sqrt(((@x - other.x) ** 2) + ((@y - other.y) ** 2) + ((@z - other.z) ** 2))
  end
end

################################################################################
class Matrix3P
  attr_accessor(:aside, :up)

  # reader:: BinaryIO#BinaryReader
  def initialize(reader); end;  alias_method :init_1, :initialize

  # aside:: Vector3P
  # up:: Vector3P
  # dir:: Vector3P
  def initialize(aside, up, dir); end; alias_method :init_2, :initialize

  def initialize(*args) # :nodoc:
    case args.size
    when 1
      reader = args[0]

      @aside = Vector3P.new(reader)
      @up = Vector3P.new(reader)
      @dir = Vector3P.new(reader)

    when 3
        @aside, @up, @dir = args

    else
      raise ArgumentError
    end
  end

  # writer:: BinaryIO#BinaryWriter
  public
  def write(writer)
    @aside.write(writer)
    @up.write(writer)
    @dir.write(writer)

    nil
  end

  public
  def to_a
    [@aside.to_a, @up.to_a, @dir.to_a]
  end
  
  # Azimuth (0..360)
  public
  def dir
    ((Math.atan2(@aside.y, @aside.x) * -180 / Math::PI) + 360).modulo 360 # Convert from radians
  end

  # angle:: Azimuth (0..360)
  public
  def dir=(angle)
    angle = angle * Math::PI / -180 # Convert into radians
    @aside.x = Math::cos angle
    @aside.y = Math::sin angle

    nil
  end
end

################################################################################
# An object stored in a WRP file.
class WrpObject
  attr_accessor(:orientation, :position, :id, :name)
  
  NAME_LENGTH = 76
  NAME_FORMAT = "Z#{NAME_LENGTH}"

  # Reads object info from WRP stream.
  # reader:: BinaryIO#BinaryReader
  # stream:: output stream
  # is_arma:: True if this is a 8WVR format file.
  def initialize(reader, stream, is_arma); end; alias_method :init_1, :initialize

  # Creates object by giving it all its data.
  def initialize(name, id, position, orientation); end; alias_method :init_2, :initialize

  def initialize(*args) # :nodoc:
    case args.size
    when 3
      reader, stream, is_arma = args
      
      @orientation = Matrix3P.new(reader)

      @position = Vector3P.new(reader)

      @id = reader.read_uint32
      if is_arma
        name_length = reader.read_uint32

        if name_length > 0
          @name = stream.read name_length
        else
          @name = ""
        end
      else
        @name = stream.read(NAME_LENGTH).unpack(NAME_FORMAT).first
      end
      
    when 4
        @name, @id, @position, @orientation = args
    else
      raise ArgumentError
    end
  end

  # Writes object to WRP stream.
  # writer:: BinaryIO#BinaryWriter
  # stream:: output stream
  public
  def write(writer, stream)
    @orientation.write writer

    @position.write writer

    writer.write_uint32 @id

    writer.write_uint32 @name.length
    stream.write @name

    nil
  end

  # Is this a valid object, otherwise it will just be the centre position which
  # doesn't have a name.
  public
  def valid?
    not name.empty?
  end

  # Horizontal angle (-180 -> +180)
  public
  def dir
    @orientation.dir
  end
end

################################################################################
# A texure stored in a WRP file.
class Texture
  attr_accessor(:name, :major)
  
  OFP_TEXTURE_STRING_LEN = 32
  OFP_TEXTURE_STRING_FORMAT = "Z#{OFP_TEXTURE_STRING_LEN}"
  DEFAULT_MAJOR = 0 # TODO: is this reasonable?

  public
  # Reads ArmA-format (8WVR) texture information from a stream.
  # reader:: BinaryIO#BinaryReader
  # stream:: input stream
  def initialize(reader, stream); end; alias_method :init_1, :initialize
  
  # Reads OFP-format (4WVR) texture information from a stream.
  # stream:: input stream
  def initialize(stream); end; alias_method :init_2, :initialize

  # Creates a "dummy" texture.
  def initialize(); end; alias_method :init_3, :initialize

  def initialize(*args) # :nodoc:
    case args.size
    when 2
      reader, stream = args
      length = reader.read_uint32
      if length == 0
        @name = ''
        @major = 0
      else
        @name = stream.read(length)
        @major = reader.read_uint32
      end
      
    when 1
      stream = args.first

      @name = stream.read(OFP_TEXTURE_STRING_LEN).unpack(OFP_TEXTURE_STRING_FORMAT).first
      @major = DEFAULT_MAJOR
      
    when 0
      @name = ''
      @major = DEFAULT_MAJOR
    end
  end

  # writer:: BinaryIO#BinaryWriter
  # stream:: output stream
  public
  def write(writer, stream)
    if @name.empty?
      writer.write_uint32 0
    else
      writer.write_uint32 @name.length
      stream.write @name
      writer.write_uint32 @major
    end

    nil
  end
end

################################################################################
# A file contining island information. It can be read from an OFP (4WVR) or
# ArmA (8WVR) formatted WRP file. It can be exported to WRP (8WVR only), XYZ or
# object template files.
class File8WVR
  # Width of a forest block object in metres.
  FOREST_BLOCK_OBJECT_WIDTH = 50.0

  ARMA_MAGIC = "8WVR"
  OFP_MAGIC = "4WVR"

  # 50m texture and terrain cell sizes are assumed in OFP.
  OFP_CELL_SIZE = 50.0

  # Total number of textures in every OFP map.
  OFP_NUM_TEXTURES = 512 

  # OFP file heights are integers and much larger than they should be
  # so scale them down appropriately.
  OFP_TO_ARMA_HEIGHT_FACTOR = 0.045

  # Size of cells when calculating satellite mask forested areas.
  FOREST_CELL_WIDTH = 32

  # ArmA only supports certain grid sizes, from 16x16 to 4096x4096.
  LEGAL_GRID_SIZES = [16, 32, 64, 128, 256, 512, 1024, 2048, 4096]

  # Number of objects to wait before applying a progress increment.
  OBJECTS_PER_PROGRESS = 100

  include BinaryIO

  public; def is_arma?() @is_arma; end

  # Loads 4WVR or 8WVR format WRP file.
  # +stream+:: input stream
  # +show_progress+:: True to use a gui to show progress.
  def initialize(stream, show_progress)
    @show_progress = show_progress

    if @show_progress
      init_gui
    else
      @window = nil # But GUI might be inited later.
    end

    stream.binmode

    magic = stream.read 4
    case magic
    when ARMA_MAGIC
      @is_arma = true
      read_wrp(stream)
    when OFP_MAGIC
      @is_arma = false
      read_wrp(stream)
    else
      raise "Unrecognised magic code in input file (#{magic})"
    end
  end

  # Initialise the GUI application.
  protected
  def init_gui
    app = FXApp.new
    @window = MainWindow.new(app)
    app.create

    thread = Thread.new do
      app.run
    end
  end

  # Moves progress bar along by a single step.
  protected
  def increment_progress
    @window.increment_progress if @show_progress
  end

  # Resets progress bar ready for another item.
  # +title+:: Title for the progress bar.
  # +total+:: Total value to count to in the progress meter.
  protected
  def reset_progress(title, total)
    @window.reset_progress(title, total) if @show_progress
  end

  # stream:: input stream
  protected
  def read_wrp(stream)
    reader = BinaryReader.new(stream)

    # These vars are used in initiation, but can be derived from the
    # arrays after that.

    if is_arma?
      x_texture_range = reader.read_uint32
      z_texture_range = reader.read_uint32
      
      x_terrain_range = reader.read_uint32
      z_terrain_range = reader.read_uint32

      @texture_cell_size = reader.read_float

      @terrain_height = Array.new(z_terrain_range) do
        reader.read_float x_terrain_range
      end
    else
      x_texture_range = reader.read_uint32
      z_texture_range = reader.read_uint32

      # All OFP maps have the same texture gridsize.
      @texture_cell_size = OFP_CELL_SIZE

      # Terrain ranges are the same as texture ranges.
      @terrain_height = Array.new(z_texture_range) do
        reader.read_int16 x_texture_range
      end
      
      # Add crazy fudge factor to make OFP data same as ArmA data (within 1cm).
      @terrain_height.each do |row|
        row.map! { |height| height * OFP_TO_ARMA_HEIGHT_FACTOR }
      end
    end

    @texture_index = Array.new(z_texture_range) do
      reader.read_uint16 x_texture_range
    end

    # Load textures.
    if is_arma?
      n_textures = reader.read_uint32
      @textures = Array.new(n_textures) do
        Texture.new(reader, stream)
      end
    else
      @textures = Array.new
      
      OFP_NUM_TEXTURES.times do
        texture = Texture.new(stream)
        @textures.push texture unless texture.name.empty?
      end

      # TODO: Add a dummy texture?
    end

    # Load objects. Number unknown - goes to EOF.
    reset_progress 'Importing WRP', 1_000_000 / OBJECTS_PER_PROGRESS
    @objects = []
    until stream.eof?
	    @objects.push WrpObject.new(reader, stream, @is_arma)
      increment_progress if @objects.size.modulo(OBJECTS_PER_PROGRESS) == 0
    end

    unless is_arma?
      # In ArmA maps, it is the convention that the first texture is a "dummy"
      # and is never used.
      #@textures.unshift Texture.new

      # Fudge-factor to correct the heights of objects.
      #@objects.each do |object|
#         object.position.z *= OFP_TO_ARMA_HEIGHT_FACTOR
      #end

      # In ArmA maps, it is the convention that the last object is a "centre"
      # marker.
      unless @objects.empty?
        centre = @objects.last.deep_copy
        centre.name = ''
        centre.id += 1
        centre.position.x = map_size / 2
        centre.position.y = map_size / 2
        @objects.push centre
      end
    end

    nil
  end

  # Only write in 8WVR format
  # stream:: output stream
  public
  def write_wrp(stream)
    stream.binmode
    
    writer = BinaryWriter.new(stream)

    stream.write ARMA_MAGIC

    # Derive the array sizes.
    writer.write_uint32 @texture_index[0].size
    writer.write_uint32 @texture_index.size
    writer.write_uint32 @terrain_height[0].size
    writer.write_uint32 @terrain_height.size
    writer.write_float @texture_cell_size

    reset_progress 'Exporting WRP (terrain)', @terrain_height.size

    @terrain_height.each do |row|
      writer.write_float row
      increment_progress
    end

    reset_progress 'Exporting WRP (textures)', @textures.size
    @texture_index.each do |row|
      writer.write_uint16 row
    end

    # Save textures.
    writer.write_uint32(@textures.size)
    @textures.each do |texture|
      texture.write(writer, stream)
      increment_progress
    end

    # Save objects.
    reset_progress 'Exporting WRP (objects)', @objects.size / OBJECTS_PER_PROGRESS
    @objects.each_with_index do |object, index|
      object.write(writer, stream)
      increment_progress if index.modulo(OBJECTS_PER_PROGRESS) == 0
    end
  end

  # Writes an XYZ file, using the format
  #        x         y         z
  #      0.0    2940.0    -48.13
  # stream:: output stream
  public
  def write_xyz(stream)
    cell_size = terrain_cell_size

    reset_progress 'Exporting XYZ', @terrain_height.size

    @terrain_height.each_with_index do |row, y|
      y_value = y * cell_size
      row.each_with_index do |height, x|
        stream.printf("%11.3f %11.3f %11.3f\n",
            (x * cell_size), y_value, height)
      end

      increment_progress
    end

    terrain_grid_size * terrain_grid_size
  end

  # Writes an object template file using the format,
  # "p3d-filename";x;y;z;angle;
  # "ace_island_objects\m\bush\ace_dd_bush01";4830;9682;0;161.015;
  # stream:: output stream
  public
  def write_object_template(stream)
    extension_pattern = /\.p3d$/
    @objects.each_with_index do |obj, index|
      if obj.valid?
        pos = obj.position

        z = if is_arma? then
          # Assume that object is at 0 AGL, since we can't calculate accurate
          # AGL from ArmA WRPs due to the terrain-smoothing.
          0
        else
          # ASL of object - ASL of ground.
          pos.z # - height_at(pos.x, pos.y)
        end

        stream.printf("\"%s\";%.2f;%.2f;%.2f;%.3f;\n",
          obj.name.sub(extension_pattern, '').downcase,
          pos.x, pos.y, z, obj.dir)
      end

      increment_progress if index.modulo(OBJECTS_PER_PROGRESS) == 0
    end

    @objects.size
  end

  # List of unique object names.
  public
  def object_names
    ((@objects.map { |obj| obj.name }) - ['']).uniq.sort
  end

  # List of those texture indexes that are actually never referenced.
  # Returns an array of indexes that are never referenced (not counting index 0 which is never used)
  public
  def unused_texture_indexes
    used = @texture_index.flatten.uniq

    # Texture index 0 is never used (name is "") in ArmA-format files.
    (1...@textures.size).to_a - used
  end

  # Dump of information about the map.
  public
  def info
    num_dummy_textures = @textures.inject(0) { |t, tex| t += 1 if tex.name.empty? ; t }
    num_dummy_objects = @objects.inject(0) { |t, obj| t += 1 unless obj.valid?; t }

    unused_textures = unused_texture_indexes
    num_unused_textures = unused_textures.size
    unused_textures_str = if num_unused_textures == 0
      ''
    else
      " (#{unused_textures.map { |i| "#{i}: #{@textures[i].name}"}.join(',')})"
    end
    
    num_unique_objects = object_names.size

    <<END_TEXT
Original format: #{is_arma? ? ARMA_MAGIC : OFP_MAGIC} (#{is_arma? ? "ArmA" : "OFP"})
Map size: #{map_size}x#{map_size}m
Terrain grid: #{terrain_grid_size}x#{terrain_grid_size} (#{terrain_cell_size}m cells)
Texture grid: #{texture_grid_size}x#{texture_grid_size} (#{@texture_cell_size}m cells)
Total Textures: #{@textures.size} (including #{num_dummy_textures} #{num_dummy_textures == 1 ? 'dummy' : 'dummies'}), of which #{num_unused_textures} #{num_unused_textures == 1 ? 'is' : 'are'} unused#{unused_textures_str}
Total Objects: #{@objects.size} (including #{num_dummy_objects} #{num_dummy_objects == 1 ? 'dummy' : 'dummies'}), of which #{num_unique_objects} #{num_unique_objects == 1 ? 'is' : 'are'} unique
END_TEXT
  end

  # Size of a terrain cell in metres.
  public
  def terrain_cell_size
    @texture_cell_size * texture_grid_size / terrain_grid_size.to_f
  end

  # Number of terrain grid points across the map.
  public
  def terrain_grid_size
    @terrain_height.size
  end

  # Number of texture grid points across the map.
  public
  def texture_grid_size
    @texture_index.size
  end

  # Width and height of the map in metres.
  public
  def map_size
    texture_grid_size * @texture_cell_size
  end

  # Globally replace objects with new object p3ds and model-based offsets.
  public
  def replace_objects(replacements)
    replaced = 0

    reset_progress 'Replacing objects', @objects.size / OBJECTS_PER_PROGRESS

    @objects.each_with_index do |object, index|
      # If replacement has been defined, replace it.
      replacement = replacements[object.name]
      unless replacement.nil?
        new_object = replacement[rand(replacement.size)]

        object.name = new_object[:name]

        # Offset the object. The X/Y offset must be rotated based on the
        # object's orientation, but Z is added directly.
        model_offset = new_object[:offset]
        world_offset = rotate(model_offset[0], model_offset[1], object.dir)
        object.position.x += world_offset[0]
        object.position.y += world_offset[1]
        object.position.z += model_offset[2]
        
        replaced += 1
      end

      increment_progress if index.modulo(OBJECTS_PER_PROGRESS) == 0
    end

    replaced
  end

  # replacements is a hash where the key is the name of names to replace and
  # the value is an array of names to replace it with (will pick a random one if
  # the array has more than one element).
  # replacements:: Hash of texture-to-replace => array of replacement textures
  public
  def replace_textures(replacements)
    replaced = 0

    reset_progress 'Resizing terrain grid', @textures.size
    @textures.each do |texture|
      # If replacement has been defined, replace it.
      replacement = replacements[texture.name]
      unless replacement.nil?
        texture.name = replacement[rand(replacement.size)]
        replaced += 1
      end

      increment_progress
    end

    replaced
  end


  # Replace OFP forest block objects with individual trees and bushes.
  #
  # replacements:: Hash of name-to-replace => replacement data
  #
  # returns: [Number of forest replaced, number of new objects created]
  public
  def replace_forests(replacements)
    objects_to_add = Array.new # All new objects, that aren't direct replacements.
    num_forests_replaced = 0 # Number of forest objects replaced

    # Add new objects after the last one we already have.
    last_object_id = if @objects.empty?
      0
    else
      @objects.last.id
    end

    reset_progress 'Replacing forests', @objects.size / OBJECTS_PER_PROGRESS

    @objects.each_with_index do |object, index|
      # If replacement has been defined, replace it.
      replacement = replacements[object.name]
      unless replacement.nil?
        forest_id = object.id # ID of the object we are replacing.

        block_dir = object.dir

        total_proportion = replacement[:models].values.inject(0) do |total, value|
          total + value
        end

        # Work out how many to place within the block.
        min = replacement[:min].to_f
        max = replacement[:max].to_f
        to_place = (rand(max - min + 1) + min).floor

        sectors = replacement[:sectors]

        # Place a number of replacement objects.
        to_place.times do

          # Pick a random replacement object from the options.
          pick_proportion = rand() * total_proportion

          replacement[:models].each_pair do |model, proportion|
            pick_proportion -= proportion

            if pick_proportion <= 0
              # Use a copy of the original orientation, so you have the same
              # scale, but then rotate it randomly.
              orientation = object.orientation.deep_copy
              orientation.dir = rand() * 360

              # Work out an appropriate position within the available sectors.
              h_offset, v_offset = random_sector_position(sectors)

              # Rotate the position based on the orientation of the block.
              h_offset, v_offset = rotate(h_offset, v_offset, block_dir)

              # Rotate the position around the block position,
              # based on the block dir.
              # TODO: The height will ALWAYS be wrong, unless on a flat plane.
              position = object.position.deep_copy
              position.x += h_offset
              position.y += v_offset
              position.z = height_at(position.x, position.y)

              # The height (position.z) will be wrong, since object is not in same place!
              if forest_id.nil?
                # Not the first replacement, add object as a new object (not replacing an old ID).
                last_object_id += 1
                objects_to_add.push WrpObject.new(model, last_object_id, position, orientation)
              else
                # The first replacement for this block, so replace the block ID.
                num_forests_replaced += 1
                @objects[index] = WrpObject.new(model, forest_id, position, orientation)
                forest_id = nil
              end

              break
            end
          end
        end
      end

      increment_progress if index.modulo(OBJECTS_PER_PROGRESS) == 0
    end

    @objects += objects_to_add
    
    [num_forests_replaced, objects_to_add.size]
  end

  # x:: X position
  # y:: Y position
  # by:: Number of degrees to rotate the position by.
  protected
  def rotate(x, y, by)

    # Convert to polar coordinates.
    angle = Math::atan2(y, x)
    magnitude = Math.sqrt(y ** 2 + x ** 2)

    # Rotate (degrees are clockwise and radians are anti-clockwise).
    angle -= by * Math::PI / 180

    # Back to cartesian.
    [Math::cos(angle) * magnitude, Math::sin(angle) * magnitude]
  end

  # Find an appropriate random position within N, E, W, S sectors.
  #   ______
  #  |\ N  /|
  #  | \  / |
  #  |W \/ E|
  #  |  /\  |
  #  | /  \ |
  #  |/_S__\|
  #
  # sectors:: Array of sectors that are acceptable for the position.
  protected
  def random_sector_position(sectors)
    loop do
      h_offset = (rand() * FOREST_BLOCK_OBJECT_WIDTH) - (FOREST_BLOCK_OBJECT_WIDTH / 2)
      v_offset = (rand() * FOREST_BLOCK_OBJECT_WIDTH) - (FOREST_BLOCK_OBJECT_WIDTH / 2)

      # Check which sector the random position is in.
      if h_offset.abs > v_offset.abs
        # Horizontal offset is greater than vertical one, so must be W or E.
        if h_offset > 0
          sector = :E
        else
          sector = :W
        end
      else
        # Vertical offset is greater thant horizontal one, so must be N or S.
        if v_offset > 0
          sector = :N
        else
          sector = :S
        end
      end
      
      return [h_offset, v_offset] if sectors.include? sector
    end
  end

  # Gives the height, in metres, at a given x/y position. If between grid
  # points, an interpolated value will be given.
  # x:: x position in m
  # y:: y position in m
  public
  def height_at(x, y)
    cell_size = terrain_cell_size.to_f
    
    # Ensure we don't go out of bounds.
    x = [x, map_size - cell_size].min
    y = [y, map_size - cell_size].min

    x_offset = x.modulo(cell_size) / cell_size
    y_offset = y.modulo(cell_size) / cell_size

    x_cell = x / cell_size
    y_cell = y / cell_size

    if (x_offset == 0) and (y_offset == 0)
      # Just read straight out of the grid.
      height = @terrain_height[y_cell][x_cell]
    else
      # Interpolate to work out the actual height player will see.
      x_min = (x_cell).floor
      x_max = (x_cell).ceil

      y_min = (y_cell).floor
      y_max = (y_cell).ceil

      # Depending in which of two triangles that the point is in, we choose
      # a corner to start from.
      # B---
      # | /|
      # |/ |
      # 0--B
#      if x_offset > y_offset
#        base_height = @terrain_height[y_min][x_max] # bottom right corner/triangle.
#        x_offset = 1 - x_offset
#        x_diff = @terrain_height[y_min][x_min] - base_height # BL
#        y_diff = @terrain_height[y_max][x_max] - base_height # TR
#      else
#        base_height = @terrain_height[y_max][x_min] # top left corner/triangle.
#        y_offset = 1 - y_offset
#        x_diff = @terrain_height[y_max][x_min] - base_height # TL
#        y_diff = @terrain_height[y_min][x_min] - base_height # BR
#      end

      # A single square cell is made up of two triangles. O is the origin [0, 0]
      # TL--TR
      #  |\ |
      #  | \|
      #  O--BL
      # First, work out which triangle we are in and get the "base height" of
      # the outside corner (O or TR). Then work out the height difference to
      # each of TL and BL and interpolate the action height of the point.
      # Note: This algorithm is probably not as efficient as solving the planar
      # equation, but I am both too ignorant and too lazy to bother doing it
      # properly!
      if (x_offset + y_offset) > 1
        # Top right triangle.
        base_height = @terrain_height[y_max][x_max] # TR
        y_offset = 1 - y_offset # Going left from TR to TL
        x_offset = 1 - x_offset # Going down from TL to O
        x_diff = @terrain_height[y_max][x_min] - base_height # TL
        y_diff = @terrain_height[y_min][x_max] - base_height # BR
      else
        # Bottom left triangle.
        base_height = @terrain_height[y_min][x_min] # O
        x_diff = @terrain_height[y_min][x_max] - base_height # BL
        y_diff = @terrain_height[y_max][x_min] - base_height # TR
      end

      height = base_height + (x_diff * x_offset) + (y_diff * y_offset)
    end

    height
  end

  # Decreases the terrain cell size, thus increasing the number of grid points.
  #
  # +cell_size+:: The new size of terrain cells. This must be an positive integer divisor of the current cell size.
  public
  def resize_terrain_grid(cell_size)
    valid_cell_sizes = (LEGAL_GRID_SIZES.map { |s| map_size / s })
    unless valid_cell_sizes.include? cell_size
      raise ArgumentError, "Bad requested cell_size, #{cell_size}m. " +
        "Valid sizes for this map would be #{valid_cell_sizes.join(', ')})."
    end

    new_grid_size = map_size / cell_size.to_f

    reset_progress 'Resizing terrain grid', new_grid_size
    
    # Don't resize if they didn't ask for it.
    unless new_grid_size == terrain_grid_size
      new_grid = Array.new(new_grid_size) do |y|
        y_size = y * cell_size
        
        row = Array.new(new_grid_size) do |x|
          height_at(x * cell_size, y_size)
        end

        increment_progress

        row
      end

      @terrain_height = new_grid
    end

    nil
  end

  # Applies bumpiness factor across the whole height-map.
  #
  # +bumpiness+:: Maximum deviation of each new point from standard.
  #
  # TODO: Use a better algorithm for generating noise.
  public
  def apply_bumpiness(bumpiness)
    raise ArgumentError, "Bumpiness must be > 0" unless bumpiness > 0

    reset_progress 'Applying bumpiness', @terrain_height.size
    
    @terrain_height.each do |row|
      row.map! { |height| height + ((rand() - rand()) * bumpiness) }
      increment_progress
    end

    nil
  end

  # Set the height along all four edges to a specific value.
  # +height+:: New height along edges.
  public
  def set_edge_height(height)
    height = height.to_f
    reset_progress 'Setting edge height', 1

    @terrain_height.first.map! { height }
    @terrain_height.last.map! { height }

    @terrain_height.each do |row|
      row[0] = height
      row[row.size - 1] = height
    end

    increment_progress

    height
  end

  # Raise sea level by an amount.
  # +amount+:: Amount by which to raise sea level
  public
  def raise_sea_level(amount)
    amount = amount.to_f

    reset_progress 'Raising sea-level', @terrain_height.size
    @terrain_height.each do |row|
      row.map! { |height| height - amount }
      increment_progress
    end

    amount
  end
    
  # +filename+:: File to export
  # +cell_width+:: Width of mask pixels in metres.
  # +config+:: YAML config to base mask on.
  public
  def export_satellite_mask(filename, cell_width, config)
    # Convert height colour data into Fox colours.
    heights_info = config[:heights]
    heights_info.each do |height_info|
      height_info[:colour] = FXRGB(*height_info[:colour])
    end

    mask_width = map_size / cell_width

    # GUI will already be running if progress bar is shown.
    init_gui if @window.nil?
    
    @window.create_mask(mask_width)

    FXDCWindow.new(@window.image) do |dc|

      # First, differentiate land and sea types based on height.
      reset_progress "Exporting satellite mask (Terrain)", mask_width
      
      (0...mask_width).each do |y|
        (0...mask_width).each do |x|
          height = height_at(x * cell_width, y * cell_width)

          heights_info.each do |height_level|
            if height <= height_level[:upto]
              dc.foreground = height_level[:colour]
              dc.drawPoint(x, mask_width - 1 - y)
              break
            end
          end
        end
        
        increment_progress
      end

      # Draw buildings/roads and check for forests.
      urban_info = config[:urban]
      urban_sizes = urban_info[:sizes]
      urban_sizes.default = urban_info[:default_size]
      dc.foreground = FXRGB(*urban_info[:colour])
      urban_pattern = /#{urban_info[:pattern]}/

      forest_info = config[:forest]
      forest_cell_ratio = FOREST_CELL_WIDTH / cell_width

      forest_grid = Array.new(map_size / FOREST_CELL_WIDTH) do
        Array.new(map_size / FOREST_CELL_WIDTH, 0)
      end

      forest_pattern = /#{forest_info[:pattern]}/
      forest_classes = forest_info[:classes]

      reset_progress "Exporting satellite mask (Objects)", @objects.size / OBJECTS_PER_PROGRESS
      @objects.each_with_index do |object, index|
        case object.name
        when urban_pattern
          x_centre = object.position.x / cell_width
          y_centre = object.position.y / cell_width

          dir = object.dir

          size = urban_sizes[object.name]
          x_range = (-size[0] / 2)..(size[0] / 2)
          y_range = (-size[1] / 2)..(size[1] / 2)

          x_range.each do |x_model|
            y_range.each do |y_model|
              x_offset, y_offset = rotate(x_model / cell_width, y_model / cell_width, dir)
              x = [[mask_width - 1, x_centre + x_offset].min, 0].max
              y = [[mask_width - 1, y_centre + y_offset].min, 0].max

              dc.drawPoint(x, mask_width - 1 - y)

              # Any urban will prevent forest terrain.
              forest_grid[x / forest_cell_ratio][(y / forest_cell_ratio) + 1] = -10000
            end
          end

        when forest_pattern
          if forest_classes.include? object.name
            forest_grid[object.position.x / FOREST_CELL_WIDTH][object.position.y / FOREST_CELL_WIDTH] += 1
          end
        end

        increment_progress if index.modulo(OBJECTS_PER_PROGRESS) == 0
      end

      # Forests.
      reset_progress "Exporting satellite mask (Forests)", forest_grid.size
      dc.foreground = FXRGB(*forest_info[:colour])
      min_forest_cell_density = forest_info[:min_density] * (FOREST_CELL_WIDTH ** 2)
      forest_grid.each_with_index do |row, x|
        row.each_with_index do |density, y|
          if density >= min_forest_cell_density
            dc.fillRectangle(x * forest_cell_ratio,
              (forest_grid.size - 1 - y) * forest_cell_ratio,
              forest_cell_ratio,
              forest_cell_ratio)
          end
        end

        increment_progress
      end
    end

    @window.save_mask(filename)
  end
end
end