3D Elevation Models with Bing Maps WPF

With the release of the Bing Maps REST Elevations service I started looking into cool and interesting things that can be done with the service. While doing some searching around, I stumbled across an interesting blog post titled Examining 3D Terrain of Bing Maps Tiles with SQL Server 2008 and WPF by one of our Bing Maps MVP’s which inspired me to see if I could make something similar using this new Elevations service. So with that, I’ve put together this blog posts which demonstrates how to create a tool for generating a 3D model of elevation data and then overlay static imagery over the top. As a teaser, here is a screenshot of a 3D model of Niagara Falls created using this code.

3DNiagraFalls

Setting up the Visual Studio Project

To start, we will create a WPF Application project in Visual Studios called BingMaps3DModel_WPF. Once this is done we will want to add references to the following libraries:

  • System.Runtime.Serialization
  • Microsoft.Maps.MapControl.WPF

Adding support for the REST based Elevation service

Since we will be accessing the Bing Maps REST Elevation service from .NET code we will need to add in a library to parse the responses from the service. Rather than writing these from scratch I’ll be making use of some code I helped put together in a previous MSDN article on using Bing Maps REST Service with .NET Libraries. To include this library into the project we will right click on the project and select Add -> New Item. Add a class file called BingMapsRESTServices.cs. Remove any content that’s in this file and copy and paste in the complete code from the bottom of the previous blog post. At this point your project should look something like this:

clip_image004

Creating the User Interface

For this application we will want to have two tabs. The first tab will have a map that the user will be able to use to select what area the 3D model should be created for. Once the user has selected the area they are interested in they will be able to press a button to generate the 3D model. Once the model is created the user will be taken to the second tab which will allow the user to view and interact with the 3D model. To make things a bit cleaner we will create a separate user control for the 3D models tab. To do this, right click on the project and select Add -> New Item. Select "User Control (WPF)" and call it ViewerPanel3D.xaml.

With this, we can create the markup for the MainWindow.xaml file. The XAML should look like this.

<Window x:Class="BingMaps3DModel_WPF.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:m="clr-namespace:Microsoft.Maps.MapControl.WPF;assembly=Microsoft.Maps.MapControl.WPF"
xmlns:local="clr-namespace:BingMaps3DModel_WPF"
Title="3D Map Generator" Height="700" Width="800">
<Grid>
<TabControl Name="MyTabs">
<TabItem Header="Map">
<Grid>
<m:Map Name="MyMap" CredentialsProvider="YOUR_BING_MAPS_KEY" Mode="AerialWithLabels"/>
<Button Content="Generate 3D Map Model" Click="Generate3DModel_Click"
Width="150" Height="25" Margin="10"
HorizontalAlignment="Right" VerticalAlignment="Top"/>
</Grid>
</TabItem>
<TabItem Header="3D Model">
<local:ViewerPanel3D x:Name="viewerPanel"/>
</TabItem>
</TabControl>
</Grid>
</Window>

If you run the application now, it should result in an application that looks like this:

3DWPFMap

Note: you may get an error if you haven’t created an instance of the click event for the button. Simply right click on the event name and press "Navigate to Event Handler" to generate the click event code that is needed.

The ViewerPanel3D User Control

We can now turn our attention to the ViewerPanel3D user control that we created and add the needed markup to that. When a model is loaded into this control we will want to give the user several sliders which they can use to rotate and move the model around with. I came across a really nice example in this blog post that I thought I would use as a starting point. The nice thing about this example is that it binds the sliders to the rotation transform which means there is no code needed in the background for this functionality. In addition to the sliders for rotating the model we will add a set of sliders for translating (moving in different directions) the model. We will also add a mouse wheel event for zooming in and out of the model. Putting this all together the markup for the ViewerPanel3D.xaml file should look like this:

<UserControl x:Class="BingMaps3DModel_WPF.ViewerPanel3D"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
mc:Ignorable="d"
d:DesignHeight="300" d:DesignWidth="300">

<UserControl.Resources>
<Style TargetType="{x:Type TextBlock}">
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style x:Key="slider">
<Setter Property="Slider.Orientation" Value="Vertical" />
<Setter Property="Slider.Height" Value="130.0" />
<Setter Property="Slider.HorizontalAlignment" Value="Center" />
<Setter Property="Slider.VerticalAlignment" Value="Center" />
</Style>
</UserControl.Resources>

<Grid Background="Gray" MouseWheel="OnViewportMouseWheel">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition Width="100"/>
</Grid.ColumnDefinitions>
<Viewport3D Name="viewport" Grid.Row="0" Grid.Column="0">
<Viewport3D.Camera>
<PerspectiveCamera x:Name="camera" FarPlaneDistance="50"
NearPlaneDistance="0" LookDirection="0,0,-10" UpDirection="0,1,0"
Position="0,0,5" FieldOfView="45">
<PerspectiveCamera.Transform>
<Transform3DGroup>
<RotateTransform3D>
<RotateTransform3D.Rotation>
<AxisAngleRotation3D
Axis="1.0, 0.0, 0.0"
Angle="{Binding ElementName=sliderX, Path=Value}"/>
</RotateTransform3D.Rotation>
</RotateTransform3D>
<RotateTransform3D>
<RotateTransform3D.Rotation>
<AxisAngleRotation3D
Axis="0.0, 1.0, 0.0"
Angle="{Binding ElementName=sliderY, Path=Value}"/>
</RotateTransform3D.Rotation>
</RotateTransform3D>
<RotateTransform3D>
<RotateTransform3D.Rotation>
<AxisAngleRotation3D
Axis="0.0, 0.0, 1.0"
Angle="{Binding ElementName=sliderZ, Path=Value}"/>
</RotateTransform3D.Rotation>
</RotateTransform3D>
<TranslateTransform3D
OffsetX="{Binding ElementName=transSliderX, Path=Value}"
OffsetY="{Binding ElementName=transSliderY, Path=Value}"
OffsetZ="{Binding ElementName=transSliderZ, Path=Value}"/>
<ScaleTransform3D
ScaleX="{Binding ElementName=sliderZoom, Path=Value}"
ScaleY="{Binding ElementName=sliderZoom, Path=Value}"
ScaleZ="{Binding ElementName=sliderZoom, Path=Value}"/>
</Transform3DGroup>
</PerspectiveCamera.Transform>
</PerspectiveCamera>
</Viewport3D.Camera>
<ModelVisual3D>

</ModelVisual3D>
<ModelVisual3D x:Name="model">
<ModelVisual3D.Content>
<Model3DGroup x:Name="group">
<AmbientLight Color="DarkGray"/>
<DirectionalLight Color="DarkGray" Direction="10,10,5"/>
</Model3DGroup>
</ModelVisual3D.Content>
</ModelVisual3D>
</Viewport3D>

<StackPanel Grid.Column="1" Width="100" Background="LightGray">
<GroupBox Header="Rotation" Margin="4.0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<TextBlock Text="X" Grid.Column="0" Grid.Row="0"/>
<TextBlock Text="Y" Grid.Column="1" Grid.Row="0"/>
<TextBlock Text="Z" Grid.Column="2" Grid.Row="0"/>
<Slider x:Name="sliderX" Grid.Column="0" Grid.Row="1" Minimum="0.0" Maximum="360.0" Value="230" Style="{StaticResource slider}">
<Slider.ToolTip>
<TextBlock Text="Rotate around X-Axis"/>
</Slider.ToolTip>
</Slider>
<Slider x:Name="sliderY" Grid.Column="1" Grid.Row="1" Minimum="-180.0" Maximum="180.0" Style="{StaticResource slider}">
<Slider.ToolTip>
<TextBlock Text="Rotate around Y-Axis"/>
</Slider.ToolTip>
</Slider>
<Slider x:Name="sliderZ" Grid.Column="2" Grid.Row="1" Minimum="-180.0" Maximum="180.0" Style="{StaticResource slider}">
<Slider.ToolTip>
<TextBlock Text="Rotate around Z-Axis"/>
</Slider.ToolTip>
</Slider>
</Grid>
</GroupBox>

<GroupBox Header="Translate" Margin="4.0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<TextBlock Text="X" Grid.Column="0" Grid.Row="0"/>
<TextBlock Text="Y" Grid.Column="1" Grid.Row="0"/>
<TextBlock Text="Z" Grid.Column="2" Grid.Row="0"/>
<Slider x:Name="transSliderX" Grid.Column="0" Grid.Row="1" Minimum="-10" Maximum="10" Style="{StaticResource slider}">
<Slider.ToolTip>
<TextBlock Text="Translate along the X-Axis"/>
</Slider.ToolTip>
</Slider>
<Slider x:Name="transSliderY" Grid.Column="1" Grid.Row="1" Minimum="-10" Maximum="10" Style="{StaticResource slider}">
<Slider.ToolTip>
<TextBlock Text="Translate along the Y-Axis"/>
</Slider.ToolTip>
</Slider>
<Slider x:Name="transSliderZ" Grid.Column="2" Grid.Row="1" Minimum="-10" Maximum="10" Style="{StaticResource slider}">
<Slider.ToolTip>
<TextBlock Text="Translate along the Z-Axis"/>
</Slider.ToolTip>
</Slider>
</Grid>
</GroupBox>

<GroupBox Header="Zoom" Margin="4.0">
<Slider x:Name="sliderZoom" IsDirectionReversed="True" Minimum="0.01" Maximum="1" Value="0.8" Style="{StaticResource slider}" />
</GroupBox>
</StackPanel>
</Grid>
</UserControl>

We can now add the method that will handle the mouse wheel event to zoom the model accordingly. To do this, we can right click on the mouse wheel event name and press "Navigate to Event Handler." This will generate the mouse wheel event code that is needed. We can then add in a bit of logic for setting the value of the zoom slider which will in turn zoom the model as there is a binding connected to the slider. The code for the file ViewerPanel3D.xaml.cs should look like this:

using System.Windows.Controls;
using System.Windows.Input;

namespace BingMaps3DModel_WPF
{
/// <summary>
/// Interaction logic for ViewerPanel3D.xaml
/// </summary>
public partial class ViewerPanel3D : UserControl
{
public ViewerPanel3D()
{
InitializeComponent();
}

private void OnViewportMouseWheel(object sender, MouseWheelEventArgs e)
{
sliderZoom.Value -= (double)e.Delta / 1000;
}
}
}

If we run the application and go to the 3D model tab we should see something that looks like this.

3DWPFFrame

Generating the Model

Having a nice panel for viewing the model is a good start but doesn’t really do us much good without having a 3D model to view. To create the 3D model we will need to do the following:

(1) Get a static map image for the based on the center point and zoom level of the map. To keep things easy, we will make keep the width and height of the image equal to 800 pixels.

(2) Based on the center point, zoom level and imagery size we will then need to calculate the bounding box of the image as we will need it to request the elevation data.

(3) Make a request for the elevation data for the bounding box we created. Again, to keep things simple we will specify that the data points be evenly distributed over 30 rows and columns. This will result in 900 elevation data points being returned which is under the 1000 elevation data point limit.

(4) We need to loop through all the elevation data, calculate the relative coordinate, and then convert this coordinate to a pixel coordinate. We will also need to convert the elevation into a pixel length and then scale these values down relative to the size of the map.

(5) Now we can create a 3D Mesh Geometry out of the data. To do this, we will need to specify all the data points as 3 dimensional coordinates, and then specify the texture coordinates used to map the static map image to the mesh. We will also need to specify the point indices used to create the triangles needed for the mesh.

(6) As a final step, we will create a Geometry Model out of the 3D Mesh and set the static image as the material to be overlaid on top of it. This model can then be passed into our ViewerPanel3D user control.

Most of the math used to work with the pixel coordinates are based off of these two articles: Bing Maps Tile System, and VE Imagery Service and Custom Icons. Putting all the above tasks together and adding them to the MainWindow.xaml.cs file you should end up with a code for the MainWindow.xaml file that looks like this:

using System;
using System.IO;
using System.Net;
using System.Runtime.Serialization.Json;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D;
using BingMapsRESTService.Common.JSON;

namespace BingMaps3DModel_WPF
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
#region Private Properties

private string sessionBingMapsKey;

private GeometryModel3D mGeometry;

private const double mapSize = 800;

private double topLeftX;
private double topLeftY;
private double zoom;

#endregion

#region Constructor

public MainWindow()
{
InitializeComponent();

MyMap.CredentialsProvider.GetCredentials((x) =>
{
sessionBingMapsKey = x.ApplicationId;
});
}

#endregion

#region Button Event Handler

private void Generate3DModel_Click(object sender, RoutedEventArgs e)
{
double cLat = MyMap.Center.Latitude;
double cLon = MyMap.Center.Longitude;

//Round off the zoom level to the nearest integer as an integer zoom level is needed for the REST Imagery service
zoom = Math.Round(MyMap.ZoomLevel);

//Only generate models when the user is zoomed in at a decent zoom level, otherwise the model will just be a flat sheet.
if (zoom < 8)
{
MessageBox.Show("This zoom level is not supported. Please zoom in closer (>8).");
return;
}

//Clear current model from the viewer panel
if (mGeometry != null)
{
viewerPanel.group.Children.Remove(mGeometry);
}

//Open up the 3D model tab
MyTabs.SelectedIndex = 1;

//Calculate bounding box of image for specified zoom level and center point for map dimensions

//Retrieve image of map dimensions for the specified zoom level and center point.
string imgUrl = string.Format("http://dev.virtualearth.net/REST/v1/Imagery/Map/Aerial/{0},{1}/{2}?mapSize={3},{4}&key={5}",
cLat,
cLon,
zoom,
mapSize,
mapSize,
sessionBingMapsKey);

ImageBrush imgBrush = new ImageBrush();
imgBrush.ImageSource = new BitmapImage(new Uri(imgUrl));

DiffuseMaterial material = new DiffuseMaterial(imgBrush);

//calcuate pixel coordinates of center point of map
double sinLatitudeCenter = Math.Sin(cLat * Math.PI / 180);
double pixelXCenter = ((cLon + 180) / 360) * 256 * Math.Pow(2, zoom);
double pixelYCenter = (0.5 - Math.Log((1 + sinLatitudeCenter) / (1 - sinLatitudeCenter)) / (4 * Math.PI)) * 256 * Math.Pow(2, zoom);

//calculate top left corner pixel coordiates of map image
topLeftX = pixelXCenter - (mapSize / 2);
topLeftY = pixelYCenter - (mapSize / 2);

//Calculate bounding coordinates of map view
double brLongitude, brLatitude, tlLongitude, tlLatitude;

PixelToLatLong(new System.Windows.Point(900, 800), out brLatitude, out brLongitude);
PixelToLatLong(new System.Windows.Point(0, 0), out tlLatitude, out tlLongitude);

//Retrieve elevation data for the specified bounding box
//Rows * Cols <= 1000 -> Let R = C = 30

string elevationUrl = string.Format("http://dev.virtualearth.net/REST/v1/Elevation/Bounds?bounds={0},{1},{2},{3}&rows=30&cols=30&key={4}",
brLatitude,
tlLongitude,
tlLatitude,
brLongitude,
sessionBingMapsKey);

WebClient wc = new WebClient();
wc.OpenReadCompleted += (s, a) =>
{
using (Stream stream = a.Result)
{
DataContractJsonSerializer ser = new DataContractJsonSerializer(typeof(Response));
Response response = ser.ReadObject(stream) as Response;

if (response != null &&
response.ResourceSets != null &&
response.ResourceSets.Length > 0 &&
response.ResourceSets[0] != null &&
response.ResourceSets[0].Resources != null &&
response.ResourceSets[0].Resources.Length > 0)
{
ElevationData elevationData = response.ResourceSets[0].Resources[0] as ElevationData;

//Map elevation data to 3D Mesh
MeshGeometry3D mesh = new MeshGeometry3D();

double dLat = Math.Abs(tlLatitude - brLatitude) / 30;
double dLon = Math.Abs(tlLongitude - brLongitude) / 30;

double x, y, m2p;

for (int r = 0; r < 30; r++)
{
y = tlLatitude + (dLat * r);

for (int c = 0; c < 30; c++)
{
int idx = r * 30 + c;

x = tlLongitude + (dLon * c);

double z = -elevationData.Elevations[idx];

m2p = 156543.04 * Math.Cos(y * Math.PI / 180) / Math.Pow(2, zoom);

System.Windows.Point p = LatLongToPixel(y, x);
p.X = (p.X - 400) / mapSize;
p.Y = (p.Y + 400) / mapSize;

mesh.Positions.Add(new Point3D(p.X, p.Y, z / mapSize / m2p));

mesh.TextureCoordinates.Add(p);

//Create triangles for model
if (r < 29 && c < 29)
{
mesh.TriangleIndices.Add(idx);
mesh.TriangleIndices.Add(idx + 1);
mesh.TriangleIndices.Add(idx + 30);

mesh.TriangleIndices.Add(idx + 1);
mesh.TriangleIndices.Add(idx + 31);
mesh.TriangleIndices.Add(idx + 30);
}
}
}

//Add 3D mesh to view panel
mGeometry = new GeometryModel3D(mesh, material);
mGeometry.Transform = new Transform3DGroup();
viewerPanel.group.Children.Add(mGeometry);
}
}
};

wc.OpenReadAsync(new Uri(elevationUrl));
}

#endregion

#region Helper Methods

private System.Windows.Point LatLongToPixel(double latitude, double longitude)
{
//Formulas based on following article:
//http://msdn.microsoft.com/en-us/library/bb259689.aspx

//calculate pixel coordinate of location
double sinLatitude = Math.Sin(latitude * Math.PI / 180);
double pixelX = ((longitude + 180) / 360) * 256 * Math.Pow(2, zoom);
double pixelY = (0.5 - Math.Log((1 + sinLatitude) / (1 - sinLatitude)) / (4 * Math.PI)) * 256 * Math.Pow(2, zoom);

//calculate relative pixel cooridnates of location
double x = pixelX - topLeftX;
double y = pixelY - topLeftY;

return new System.Windows.Point((int)Math.Floor(x), (int)Math.Floor(y));
}

private void PixelToLatLong(System.Windows.Point pixel, out double lat, out double lon)
{
double x = topLeftX + pixel.X;
double y = topLeftY + pixel.Y;

lon = (x * 360) / (256 * Math.Pow(2, zoom)) - 180;

double efactor = Math.Exp((0.5 - y / 256 / Math.Pow(2, zoom)) * 4 * Math.PI);

lat = Math.Asin((efactor - 1) / (efactor + 1)) * 180 / Math.PI;
}

#endregion
}
}

You should now be able to run the application. Give it a go and zoom into an area where there is a good chance of being varying elevations. I find using a zoom level between 15 and 19 works best. If you don’t see a model generated, try zooming out or translating along the Z axis.

Here is a screenshot of my finished application with the generated 3D model of a section of the Grand Canyon. Nice, right?

GrandCanyon

- Ricky Brundritt, EMEA Bing Maps Technology Solution Professional