Multithreaded MapCruncher
MapCruncherConsole\MapCruncherClass.cs
// Copyright 2010 ESRI
// 
// All rights reserved under the copyright laws of the United States
// and applicable international laws, treaties, and conventions.
// 
// You may freely redistribute and use this sample code, with or
// without modification, provided you include the original copyright
// notice and use restrictions.
// 
// See the use restrictions.
// 

using System;
using System.Collections.Generic;
using System.Threading;
using System.Runtime.InteropServices;
using ESRI.ArcGIS.Display;
using ESRI.ArcGIS.Geometry;
using ESRI.ArcGIS.Carto;
using ESRI.ArcGIS.Geodatabase;
using ESRI.ArcGIS.esriSystem;

namespace MapCruncher
{
  // Create a class that implements the EventArgs
  public sealed class MapCruncherEventArgs : EventArgs
  {
    // Set up the event properties
    private int m_lod;
    private int m_totalLods;
    private int m_threadId;

    // create the event constructor
    public MapCruncherEventArgs(int lod, int totalLods, int threadId)
    {
      m_lod = lod;
      m_totalLods = totalLods;
      m_threadId = threadId;
    }

    public int LOD
    {
      get { return m_lod; }
    }
    public int TotalLods
    {
      get { return m_totalLods; }
    }
    public int ThreadId
    {
      get { return m_threadId; }
    }
  }

  // create a delegate for the event notification messages.
  public delegate void CookerThreadEventHandeler(object sender, MapCruncherEventArgs e);

  [ComVisible(false)]
  public sealed class MapCruncherClass
  {

    public event CookerThreadEventHandeler CookerProgress;

    // set target DPI to 96
    const double c_dDPI = 96.0;
    
    // target raster tile size
    const int c_nTileWidthInPixels = 256;
    const int c_nTileHeightInPixels = 256; 
    
    // a flag to indicate execution
    private bool m_bExecuting = false;
    
    // list containing the level of detail which will be shared amongst the processing threads
    // in order to instruct each thread which LOD to process
    private List<int> m_listLod = null;
    
    // the number of cooker threads which will be determined by the number of physical CPUs 
    private int m_nNumOfCookerThreads = 0;

    // kill event in order to instruct the cooker threads to bail out. 
    private ManualResetEvent m_killEvent = new ManualResetEvent(false);

    // the thread data which is shared to all processing threads.
    // this class defines mostly properties and will be passed as 
    // a parameter to each thread.
    private sealed class ThreadData
    {
      public ThreadData()
      {
      }

      private string sCacheName;
      private string sCachePath;
      private List<int> listLod = null;
      
      //the event used to signal the main thread that all thread are finished processing
      private ManualResetEvent eDoneEvent;
      private ManualResetEvent eKillEvent;
      private int nAliveThreadCounter;
      string mapDocumentFullName;

      public string MapDocumentFullName
      {
        get { return mapDocumentFullName; }
        set { mapDocumentFullName = value; }
      }

      public List<int> LODs
      {
        get { return listLod; }
        set { listLod = value; }
      }

      public ManualResetEvent DoneEvent
      {
        get { return eDoneEvent;  }
        set { eDoneEvent = value; }
      }

      public ManualResetEvent KillEvent
      {
        get { return eKillEvent; }
        set { eKillEvent = value; }
      }

      public string CacheName
      {
        get { return sCacheName;  }
        set { sCacheName = value; }
      }
      public string CachePath
      {
        get { return sCachePath; }
        set { sCachePath = value; }
      }

      public int AliveThreadCounter
      {
        get { return nAliveThreadCounter;  }
        set { nAliveThreadCounter = value; }
      }

      public int DecrementThreadCounter()
      {
        Interlocked.Decrement(ref nAliveThreadCounter);
        return nAliveThreadCounter;
      }
    }
    ThreadData m_threaData = null;

    
    public MapCruncherClass()
    {
      m_nNumOfCookerThreads = System.Environment.ProcessorCount;
    }

    /// <summary>
    /// The initialization method of the class.
    /// </summary>
    /// <param name="mapDocumentFullName"></param>
    /// <param name="targetCacheScale"></param>
    /// <param name="outputFolderPath"></param>
    /// <param name="outputCacheName"></param>
    /// <returns>true upon success</returns>
    public bool Init(string mapDocumentFullName, double targetCacheScale, string outputFolderPath, string outputCacheName)
    {
      if (m_bExecuting)
        return false;
      
      // get the active map from the map document in order to get the extent and the
      // spatial reference needed to generate the cache
      IMap map = GetMapFromDocument(mapDocumentFullName);
      if (map == null)
        return false;

      IActiveView activeView = map as IActiveView;
      // get the extent from the active view
      IEnvelope extent = activeView.ScreenDisplay.DisplayTransformation.FittedBounds;
  
      // calculate the cache extent given the input extent
      IEnvelope cacheExtent;
      if (!GetCacheBounds(map, extent, out cacheExtent) || cacheExtent == null)
        return false;

      // calculate the number of LODs to generate given the target scale of the cache
      int nNumOfLODs = CreateCacheStructure(System.IO.Path.Combine(outputFolderPath, outputCacheName), map, extent, targetCacheScale);
      //populate the 
      m_listLod = new List<int>(nNumOfLODs);
      for (int i = 0; i < nNumOfLODs; ++i)
        m_listLod.Add(i);

      //create the thread data which will be shared among the cooker threads and set the relevant properties
      m_threaData = new ThreadData();
      m_threaData.CachePath = outputFolderPath;
      m_threaData.CacheName = outputCacheName;
      m_threaData.DoneEvent = new ManualResetEvent(true);
      m_threaData.KillEvent = new ManualResetEvent(false);
      m_threaData.AliveThreadCounter = m_nNumOfCookerThreads;
      m_threaData.LODs = m_listLod;
      m_threaData.MapDocumentFullName = mapDocumentFullName;

      m_threaData.KillEvent.Reset();
      m_threaData.DoneEvent.Reset();

      return true;
    }

    /// <summary>
    /// starts the execution of the cache generation
    /// </summary>
    /// <returns>true upon success</returns>
    public bool Execute()
    {
      if (m_bExecuting)
        return false;
      
      m_bExecuting = true;

      System.Diagnostics.Trace.WriteLine("Main thread " + System.Threading.Thread.CurrentThread.ManagedThreadId + " starts execution...");

      //create the subset threads array
      Thread[] threadTask = new Thread[m_nNumOfCookerThreads];

      for (int i = 0; i < m_nNumOfCookerThreads; i++)
      {
        // instantiate the cooker threads
        threadTask[i] = new Thread(new ParameterizedThreadStart(CookerProc));
        // Note the STA apartment which is required to run ArcObjects!!!
        threadTask[i].SetApartmentState(ApartmentState.STA);
        threadTask[i].IsBackground = true;
        // make sure to set the thread priority to below normal
        threadTask[i].Priority = ThreadPriority.BelowNormal;
        threadTask[i].Name = "Cooker " + (i + 1).ToString();
        // spin the cooker threads and pass in the shared thread data
        threadTask[i].Start((object)m_threaData);
      }

      // wait on the done event for all threads are done processing
      System.Diagnostics.Trace.WriteLine("Main thread " + System.Threading.Thread.CurrentThread.ManagedThreadId + " is waiting...");
      m_threaData.DoneEvent.WaitOne();
      System.Diagnostics.Trace.WriteLine("Main thread " + System.Threading.Thread.CurrentThread.ManagedThreadId + ", Execution is done!!!");

      for (int i = 0; i < m_nNumOfCookerThreads; i++)
      {
        threadTask[i].Join();
      }
      threadTask = null;

      // at the end of the execution, set the flag back to false
      m_bExecuting = false;  

      return true;
    }

    #region private methods
    /// <summary>
    /// Given the input extent, calculate the cache extent
    /// </summary>
    /// <param name="map"></param>
    /// <param name="inExtent"></param>
    /// <param name="cacheExtent"></param>
    /// <returns>true on success</returns>
    bool GetCacheBounds(IMap map, IEnvelope inExtent, out IEnvelope cacheExtent)
    {
      cacheExtent = null;

      if (map == null)
        return false;

      IActiveView activeView = map as IActiveView;
      ISpatialReference mapSR = map.SpatialReference;

      if (inExtent == null || inExtent.IsEmpty)
        inExtent = activeView.FullExtent;
      WKSEnvelope wksExtent;
      inExtent.QueryWKSCoords(out wksExtent);

      // in case that the input extent has a different spatial reference 
      // from the map, make sure to project it.
      IEnvelope calculatedCacheExtent = new EnvelopeClass();
      calculatedCacheExtent.PutWKSCoords(ref wksExtent);
      calculatedCacheExtent.SpatialReference = inExtent.SpatialReference;
      calculatedCacheExtent.Project(mapSR);

      // get the SR horizon envelope
      WKSEnvelope horizonEnv;
      GetSRHorizon(map, mapSR, out horizonEnv);
      IEnvelope horizonEnvelope = new EnvelopeClass();
      horizonEnvelope.SpatialReference = mapSR;
      horizonEnvelope.PutWKSCoords(ref horizonEnv);

      // intersect the input extent with the SR horizon
      calculatedCacheExtent.Intersect(horizonEnvelope);

      // make sure that the result envelope isn't empty;
      if (calculatedCacheExtent.IsEmpty)
        return false;

      // get the cache center point
      IPoint cacheCenterPt = ((IArea)calculatedCacheExtent).Centroid;

      double dCacheXMin, dCacheYMin, dCacheXMax, dCacheYMax;
      calculatedCacheExtent.QueryCoords(out dCacheXMin, out dCacheYMin, out dCacheXMax, out dCacheYMax);
      
      // bound the cache extent with a square that will form the first LOD
      double dBoundingSquareEdge = System.Math.Max(dCacheXMax - dCacheXMin, dCacheYMax - dCacheYMin);
      WKSEnvelope boundingSquareEnv;
      boundingSquareEnv.XMin = 0;
      boundingSquareEnv.YMin = 0;
      boundingSquareEnv.XMax = dBoundingSquareEdge;
      boundingSquareEnv.YMax = dBoundingSquareEdge;

      IEnvelope cacheBounds = new EnvelopeClass();
      cacheBounds.SpatialReference = mapSR;
      cacheBounds.PutWKSCoords(ref boundingSquareEnv);
      cacheBounds.CenterAt(cacheCenterPt);

      // return the bounding square
      cacheExtent = cacheBounds;

      return true;
    }
    
    /// <summary>
    /// get the square defining the horizon of the spatial reference
    /// </summary>
    /// <param name="map"></param>
    /// <param name="SR"></param>
    /// <param name="horizonEnv"></param>
    /// <returns></returns>
    bool GetSRHorizon(IMap map, ISpatialReference SR, out WKSEnvelope horizonEnv)
    {
      horizonEnv = new WKSEnvelope();

      if (SR == null)
        return false;

      IProjectedCoordinateSystem4 PCS4 = SR as IProjectedCoordinateSystem4;
      IGeographicCoordinateSystem2 GCS2 = SR as IGeographicCoordinateSystem2;
      IUnknownCoordinateSystem unknownCoordinateSystem = SR as IUnknownCoordinateSystem;
      if (PCS4 != null)
      {
        bool bInclusive = true;
        IGeometry horizonGeometry = PCS4.GetPCSHorizon(out horizonEnv, out bInclusive);
      }
      else if (GCS2 != null) //handle unknown spatial reference
      {
        GCS2.GetHorizon(ref horizonEnv);
      }
      else if (unknownCoordinateSystem != null) // in case that the input SR is unknown
      {
        // use the map's extent
        IActiveView activeView = map as IActiveView;
        IEnvelope extent = activeView.ScreenDisplay.DisplayTransformation.Bounds;
        extent.QueryWKSCoords(out horizonEnv);
      }

      return true;
    }
    
    /// <summary>
    /// validate and create the folder containing the cache. Also write to the 
    /// cache the cache info (CacheInfo class) as well as the cache extent. This information defines the 
    /// cached raster dataset and will also be used by the cooker threads.
    /// </summary>
    /// <param name="bstrCacheFullName"></param>
    /// <param name="map"></param>
    /// <param name="cacheBounds"></param>
    /// <param name="targetCacheScale"></param>
    /// <returns></returns>
    int CreateCacheStructure(string bstrCacheFullName, IMap map, IEnvelope cacheBounds, double targetCacheScale)
    {
      if (cacheBounds == null || cacheBounds.IsEmpty)
        return -1;

      // given the target map scale, calculate the required resolution.
      double dTargetResolution = CalculateResolution(map, targetCacheScale);
      if (dTargetResolution <= 0)
        return -1;

      double dBoundingSquareEdge = cacheBounds.Width;

      // according to the cache tile size, calculate the required number of level of details
      List<double> listResolutions = new List<double>();
      double dResolution = dBoundingSquareEdge / (double)c_nTileWidthInPixels;
      while (dResolution > dTargetResolution)
      {
        listResolutions.Add(dResolution);
        dResolution *= 0.5;
      }

      // write the cache folder and the objects defining it.
      ICacheInfo cacheInfo = CreateCacheInfo(listResolutions, cacheBounds, map.SpatialReference, bstrCacheFullName);
      if (cacheInfo == null)
        return -1;

      return listResolutions.Count;
    }
    
    /// <summary>
    /// Given the target map scale, calculate the equivalent resolution (number map units per on pixel).
    /// </summary>
    /// <param name="map"></param>
    /// <param name="targetCacheScale"></param>
    /// <returns></returns>
    double CalculateResolution(IMap map, double targetCacheScale)
    {
      if (targetCacheScale <= 0.0)
      return -1;

      if (map == null)
        return -1;

      IDisplayTransformation dt = ((IActiveView)map).ScreenDisplay.DisplayTransformation;

      ISpatialReference sr = dt.SpatialReference;
      esriUnits mapUnits = dt.Units;
       
      // map scale is defined as the ration between the size of an object comparing to
      // its size in the map. Display units are measured in dots per inches and therefore 
      // we need to convert the given map units to inches as well.
      double dFittedBoundsWidth = dt.FittedBounds.Width;
      IUnitConverter unitConverter = new UnitConverterClass();
      double dFittedBoundsWidthInches = unitConverter.ConvertUnits(dFittedBoundsWidth, mapUnits, esriUnits.esriInches);
      double dInchesFactor = dFittedBoundsWidthInches / dFittedBoundsWidth;

      return (targetCacheScale / (c_dDPI * dInchesFactor));
    }
    
    /// <summary>
    /// create the cache info which defines the cache structure
    /// </summary>
    /// <param name="listResolutions"></param>
    /// <param name="cacheExtent"></param>
    /// <param name="mapSR"></param>
    /// <param name="sCacheFullName"></param>
    /// <returns></returns>
    ICacheInfo CreateCacheInfo(List<double> listResolutions, IEnvelope cacheExtent, ISpatialReference mapSR, string sCacheFullName)
    {
      if (cacheExtent == null || sCacheFullName.Length == 0)
        return null;

      // get the upper left corner of the cache (point of origin)
      IPoint origin = cacheExtent.UpperLeft;
            
      // calculate the LODs
      ILODInfos lODInfos = new LODInfosClass();
      lODInfos.RemoveAll();
      ILODInfo lODInfo;
      for (int lod = 0 ; lod < listResolutions.Count ; ++lod)
      {
        lODInfo = new LODInfoClass();
        lODInfo.LevelID = lod;
        lODInfo.Resolution = listResolutions[lod];
        lODInfos.Add(lODInfo);
      }

      // set the tiling schema
      ITileCacheInfo tileCacheInfo = new TileCacheInfoClass();
      tileCacheInfo.TileCols = c_nTileWidthInPixels;
      tileCacheInfo.TileRows = c_nTileHeightInPixels;
      tileCacheInfo.Origin = origin;
      tileCacheInfo.SpatialReference = mapSR;
      tileCacheInfo.LODInfos = lODInfos;
      tileCacheInfo.ComputeScales();  

      // the output cache image format, 32 bit (RGBA) PNG.
      string sCacheFormat = "PNG32";

      ITileImageInfo tileImageInfo = new TileImageInfoClass();
      tileImageInfo.Format = sCacheFormat;

      // create a MapCooker in order to write all of the files to the new cache
      IMapCooker mapCooker = new MapCookerClass();
      mapCooker.Format = sCacheFormat;
      mapCooker.Path = sCacheFullName;
      mapCooker.Extent = cacheExtent;
      mapCooker.TileCacheInfo = tileCacheInfo;
      ((IMapCooker2)mapCooker).TileImageInfo = tileImageInfo;

      // define the cache extent
      ICacheDatasetInfo cacheDatasetInfo = new CacheDatasetInfoClass();
      cacheDatasetInfo.Extent = cacheExtent;

      // define the storage of the case
      ICacheStorageInfo cacheStorageInfo = new CacheStorageInfoClass();
      cacheStorageInfo.PacketSize = 128;
      cacheStorageInfo.StorageFormat = esriMapCacheStorageFormat.esriMapCacheStorageModeCompact;
      
      // write the cache info and extent to the local cache dir
      ((IMapCooker3)mapCooker).WriteTilingSchemeEx(tileCacheInfo, 
                                                   tileImageInfo, 
                                                   cacheStorageInfo, 
                                                   cacheDatasetInfo,  
                                                   sCacheFullName, 
                                                   "");
      

      ICacheInfo cacheInfo = new CacheInfoClass();
      cacheInfo.TileCacheInfo = tileCacheInfo;
      cacheInfo.TileImageInfo = tileImageInfo;
      ((ICacheInfo2)cacheInfo).CacheStorageInfo = cacheStorageInfo;

      return cacheInfo;
    }

    /// <summary>
    /// re-hydrate a map cooker from the serialized objects defined in the cache
    /// </summary>
    /// <param name="map"></param>
    /// <param name="td"></param>
    /// <returns></returns>
    IMapCooker CreateMapCooker(ThreadData td)
    {
      // get the full path to the cache
      string sCacheFullName = System.IO.Path.Combine(td.CachePath, td.CacheName);

      IMapCooker mapCooker = null;
      // make sure to block the other threads while re-hydrating the map cooker in order
      // to avoid corrupting the cache.
      lock(td)
      {
        // re-hydrate the cache info first
        ICacheInfo cacheInfo = ReadCacheConfiguration(sCacheFullName);
        if (cacheInfo == null)
          return null;

        ITileCacheInfo tileCacheInfo = cacheInfo.TileCacheInfo;
        ITileImageInfo tileImageInfo = cacheInfo.TileImageInfo;

        ICacheInfo2 cacheInfo2 = cacheInfo as ICacheInfo2;
        if (cacheInfo2 == null)
          return null;

        ICacheStorageInfo cacheStorageInfo = cacheInfo2.CacheStorageInfo;
        // rehydrate the CacheDatasetInfo which defines the cache extent
        ICacheDatasetInfo cacheDatsetInfo = ReadCacheDatasetInfo(sCacheFullName);
        IEnvelope cacheExtent = cacheDatsetInfo.Extent;

        // create a MapCooker
        mapCooker = new MapCookerClass();
        mapCooker.Path = sCacheFullName;
        mapCooker.TileCacheInfo = tileCacheInfo;
        mapCooker.Extent = cacheExtent;
        ((IMapCooker2)mapCooker).TileImageInfo = tileImageInfo;
        ((IMapCooker3)mapCooker).CacheStorageInfo = cacheStorageInfo;
        ((IMapCooker3)mapCooker).CacheDatasetInfo = cacheDatsetInfo;

      }
      
      return mapCooker;
    }
    
    /// <summary>
    /// generate the cache for the input LOD
    /// </summary>
    /// <param name="map"></param>
    /// <param name="mapCooker"></param>
    /// <param name="lod"></param>
    void GenerateLOD(IMap map, IMapCooker mapCooker, int lod)
    {
      System.Diagnostics.Trace.WriteLine("Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId + " is processing LOD: " + lod + ".");
      if (map == null || mapCooker == null || lod < 0)
        return;

      // get the screen display
      IScreenDisplay screenDisplay = ((IActiveView)map).ScreenDisplay;

      // populate a long array with the input LOD to generate
      ILongArray longArray = new LongArrayClass();
      longArray.Add(lod);
      // use the map cooker to generate the required LOD
      mapCooker.Update(map, null, screenDisplay, mapCooker.Extent, longArray, esriMapCacheUpdateMode.esriMapCacheUpdateRecreateAll, null);

      System.Diagnostics.Trace.WriteLine("Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId + " finished processing LOD: " + lod + ".");
    }
    
    /// <summary>
    /// re-hydrate an ArcObject serialized to an xml file. 
    /// </summary>
    /// <param name="cacheFullName"></param>
    /// <param name="objectFileName"></param>
    /// <returns></returns>
    object ReadObjectFromFile(string cacheFullName, string objectFileName)
    {
      // create the stream
      IXMLStream xmlStream = new XMLStreamClass();
      // get the file name and load it to the xml stream
      string sConfigFileName = System.IO.Path.Combine(cacheFullName, objectFileName);
      xmlStream.LoadFromFile(sConfigFileName);
      xmlStream.Reset();

      // read the object from the xml stream
      IXMLReader xmlReader = new XMLReaderClass();
      xmlReader.ReadFrom(xmlStream as IStream);
      IXMLSerializer xmlSerializer = new XMLSerializerClass();
      object unkObj = xmlSerializer.ReadObject(xmlReader, null, null);

      return unkObj;
    }
    
    /// <summary>
    /// re-hydrate the serialized cache info from the cache
    /// </summary>
    /// <param name="cacheFullName"></param>
    /// <returns></returns>
    ICacheInfo ReadCacheConfiguration(string cacheFullName)
    {
      //Read the configuration XML to figure out the tiling scheme.
      return ReadObjectFromFile(cacheFullName, "conf.xml") as ICacheInfo;
    }

    /// <summary>
    /// re-hydrate the serialized cache extent from the cache
    /// </summary>
    /// <param name="cacheFullName"></param>
    /// <returns></returns>
    ICacheDatasetInfo ReadCacheDatasetInfo(string cacheFullName)
    {
      object unkObj = ReadObjectFromFile(cacheFullName, "conf.cdi");
      IEnvelope envelope = unkObj as IEnvelope;
      ICacheDatasetInfo cacheDatasetInfo = new CacheDatasetInfoClass();
      cacheDatasetInfo.Extent = envelope;
      return cacheDatasetInfo;
    }
    
    /// <summary>
    /// get the active map given a map document path. 
    /// </summary>
    /// <param name="mapDocFullPath"></param>
    /// <returns></returns>
    IMap GetMapFromDocument( string mapDocFullPath)
    {
      IMapDocument mapDocument = new MapDocumentClass();
      mapDocument.Open(mapDocFullPath, null);

      IMap map = mapDocument.ActiveView as IMap;
      if (map == null)
        map = mapDocument.get_Map(0);

      mapDocument.Close();

      return map;
    }
    
    // the thread procedure
    private void CookerProc(object threadParam)
    {
      ThreadData td = (ThreadData)threadParam;
      if (td == null)
        return;

      IMap map = GetMapFromDocument(td.MapDocumentFullName);
      IMapCooker mapCooker = CreateMapCooker(td);

      while (true)
      {
        // check the kill sequence first
        if (td.KillEvent.WaitOne(0))
          break;

        int nLOD = -1;
        lock (td.LODs)
        {
          if (td.LODs.Count == 0)
          {
            System.Diagnostics.Trace.WriteLine("Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId + " is exiting.");
            break;
          }

          nLOD = td.LODs[0];
          td.LODs.Remove(nLOD);
        }

        GenerateLOD(map, mapCooker, nLOD);

        if (CookerProgress != null)
        {
          MapCruncherEventArgs e = new MapCruncherEventArgs(nLOD, td.LODs.Count, System.Threading.Thread.CurrentThread.ManagedThreadId);
          CookerProgress(this, e); 
        }
      }

      // the thread is exiting
      int nAliveThreadCount = td.DecrementThreadCounter();
      if (nAliveThreadCount <= 0)
      {
        System.Diagnostics.Trace.WriteLine("Thread " + System.Threading.Thread.CurrentThread.ManagedThreadId + " signaling done event...");
        td.DoneEvent.Set();
      }
    }
    #endregion
  }
}