As alluded to in the last post in this series (ignoring a related post that dealt with user interface integration) I wasn’t really happy with some of the tricks I needed in the WinForms version to try and make a coherent user interface for tracking accessed point clouds in a hosted Photosynth browsing session. This post replaces the WinForms UI with one implemented using WPF, and in fact might also have been titled “Using data-binding in WPF to track a list of objects with associated thumbnails” or something to that effect. :-)
What I’ve done in the new version of the solution used to build this tool is to add a separate WPF application project (called BrowsePhotosynth) while leaving the WinForms project there as a historical reference (that one is still called Browser). The new WPF project builds an executable called “ADNPlugins-BrowsePhotosynth2.exe”, which has made it easy to update the DLL project to work with this new version – all we have to do is add a “2” in a few places. Otherwise the code in the main DLL module remains unchanged (and won’t be listed in this post). Please refer back to the previous post for instructions on getting the application to work: if you have already got the last version working, you should be able to drop the new DLL and EXE into the same folder and they should just work.
Let’s now take a look at the pertinent files from the project for our new-fangled WPF browser. Bear in mind that I’m far from being a WPF expert, myself (I do feel as though I’m making progress, though), so I’ve relied a lot on code I’ve found on the web for various tasks: accessing command-line arguments, automatically updating the UI when bound properties change, adding a splitter, enabling selection on hover, using a gradient selection style and changing a button’s colour when being hovered over. Which means the code may have redundant properties set or use inconsistent approaches to solving the various design problems, but anyway. We’re all learning. :-)
Firstly we have the main App.xaml file, which only has one interesting change, and that’s to call our Application_Startup() event before the UI is shown:
<Application x:Class="BrowsePhotosynth.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="BrowsePhotosynth.xaml"
Startup="Application_Startup">
<Application.Resources>
</Application.Resources>
</Application>
And in the associated App.xaml.cs “code-behind” we have that function’s implementation:
using System.Windows;
namespace BrowsePhotosynth
{
public partial class App : Application
{
internal static int _hwnd;
private void Application_Startup(
object sender, StartupEventArgs e
)
{
// Extract the handle passed as an argument.
// This is AutoCAD's main window, and we'll use it
// to pump messages. If there's no handle, set it
// to 0, which means "standalone mode"
_hwnd =
(e.Args.Length > 0 ? int.Parse(e.Args[0]) : 0);
}
}
}
The only notable difference to the equivalent code from the WinForms version is that we need to look at item 0 in the argument array rather than item 1. Otherwise we store the HWnd as a static integer which we can then access from our main browser implementation.
Here’s the BrowsePhotosynth.xaml file (the indentation is a little off, in terms of where attributes are relative to their elements, but anyway):
<Window
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:wfi=
"clr-namespace:System.Windows.Forms.Integration;assembly=WindowsFormsIntegration"
xmlns:wb="clr-namespace:csExWB;assembly=csExWB"
x:Class="BrowsePhotosynth.PhotosynthBrowser"
Loaded="WindowLoaded"
Closing="WindowClosing"
Title="Browse Photosynth"
Height="600"
Width="800"
Topmost="True"
WindowStartupLocation="CenterScreen"
Icon="/ADNPlugin-BrowsePhotosynth2;component/Browser.ico">
<Window.Resources>
<!--
Style to make our list box have that nice gradient
selection style, but also to hook the slider up to
adjust the size of the list items
-->
<Style
x:Key="FocusListBoxItemStyle"
TargetType="{x:Type ListBoxItem}">
<!--
Hook up our slider to both width and height properties
-->
<Setter
Property="Width"
Value=
"{Binding Path=Value, ElementName=sizeSlider, Mode=TwoWay}"
/>
<Setter
Property="Height"
Value=
"{Binding Path=Value, ElementName=sizeSlider, Mode=TwoWay}"
/>
<!--
Bind the item alignment to the container's
-->
<Setter
Property="HorizontalContentAlignment"
Value=
"{Binding
Path=HorizontalContentAlignment,
RelativeSource=
{RelativeSource
AncestorType= {x:Type ItemsControl}}}"
/>
<Setter
Property="VerticalContentAlignment"
Value=
"{Binding
Path=VerticalContentAlignment,
RelativeSource=
{RelativeSource
AncestorType={x:Type ItemsControl}}}"
/>
<!--
Here's the border we add when highlighting
-->
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ListBoxItem}">
<Border
Name="listItemFocusBorder"
SnapsToDevicePixels="true"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}">
<ContentPresenter
HorizontalAlignment=
"{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment=
"{TemplateBinding VerticalContentAlignment}"
SnapsToDevicePixels=
"{TemplateBinding SnapsToDevicePixels}"
/>
</Border>
<!--
We only add it when an list item is selected
-->
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="true">
<Setter
Property="Foreground"
Value=
"{DynamicResource
{x:Static SystemColors.HighlightTextBrushKey}}"
/>
<!--
Our gradient backrgound style
-->
<Setter
Property="Background"
TargetName="listItemFocusBorder">
<Setter.Value>
<!--
Gradient from Photosynth green to black
-->
<LinearGradientBrush
EndPoint="0.5,1"
StartPoint="0.5,0">
<GradientStop Color="#B4E800" Offset="0"/>
<GradientStop Color="Black" Offset="1"/>
</LinearGradientBrush>
</Setter.Value>
</Setter>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!--
Style to make our button get a different look (with
a nice green background, but also a bolder, black
font) when it is hovered over
-->
<Style x:Key="FocusButtonStyle" TargetType="{x:Type Button}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Button}">
<!--
Create our border for manipulation
-->
<Border
Name="buttonFocusBorder"
CornerRadius="5"
BorderThickness="3"
BorderBrush="White"
Background="Black">
<ContentPresenter
HorizontalAlignment="Center"
VerticalAlignment="Center"
/>
</Border>
<ControlTemplate.Triggers>
<!--
When the button is hovered overm change the
background to Photosynth green, the forground
to black, and the font-weight to bold
-->
<Trigger
Property="IsMouseOver"
Value="True">
<Setter
TargetName="buttonFocusBorder"
Property="Background"
Value="#B4E800"
/>
<Setter
TargetName="buttonFocusBorder"
Property="Button.Foreground"
Value="Black"
/>
<Setter
TargetName="buttonFocusBorder"
Property="Button.FontWeight"
Value="Bold"
/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<!--
Now for our actual window elements
-->
<Grid
Name="grid"
Background="Black">
<!--
Start with a three column outer grid for our browser,
our splitter and our point cloud list-related stuff
-->
<Grid.ColumnDefinitions>
<ColumnDefinition Width="450*" MinWidth="50" />
<ColumnDefinition Width="10*" MinWidth="3" />
<ColumnDefinition Width="170*" MinWidth="30" />
</Grid.ColumnDefinitions>
<!--
Add our browser to the left, hosted by a WinForms
host
Be warned - the browser control causes VS 2008 to
act very flakily. Expect regular crashes and restarts
-->
<wfi:WindowsFormsHost Grid.Column="0" Name="wfh">
<wb:cEXWB
x:Name="browser"
ProtocolHandlerBeginTransaction="HttpTransaction"
/>
</wfi:WindowsFormsHost>
<!--
Now our splitter control in the middle
-->
<GridSplitter
ResizeDirection="Columns"
Grid.Column="1"
Width="3"
HorizontalAlignment="Center"
/>
<!--
And our nested grid containing three rows for the
clear button, the list itself and the slider
-->
<Grid
Grid.Column="2"
Background="Black">
<Grid.RowDefinitions>
<RowDefinition Height="32"/>
<RowDefinition Height="*"/>
<RowDefinition Height="30"/>
</Grid.RowDefinitions>
<!--
Add the button to clear the list
-->
<Button
Name="clearButton"
Grid.Row="0"
Height="25"
Background="Black"
Foreground="White"
Style="{StaticResource FocusButtonStyle}"
Click="ClearButton_Click">
Clear Point Cloud History
</Button>
<!--
Now the list of accessed point clouds
-->
<ListView
Name="clouds"
Grid.Row="1"
Background="Black"
MinHeight="200"
BorderThickness="0"
ItemContainerStyle=
"{DynamicResource FocusListBoxItemStyle}">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel IsItemsHost="True"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate>
<Viewbox
MouseDown="Viewbox_MouseDown"
MouseEnter="ListBoxItem_MouseEnter">
<Border
Padding="20,20,10,10"
Margin="5">
<StackPanel>
<!--
Add the image with the default size
-->
<Image
Width="170"
Opacity="20"
OpacityMask="Black"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Height="170"
Source="{Binding Path=ImagePath}"
/>
<!--
Add the name of the point cloud in green
-->
<TextBlock
Foreground="#B4E800"
Text="{Binding Path=Name}"
TextAlignment="Center"
TextWrapping="Wrap"
Width="170"
/>
</StackPanel>
</Border>
</Viewbox>
</DataTemplate>
</ListBox.ItemTemplate>
</ListView>
<!--
And lastly the slider for the list item size
-->
<Slider
Name="sizeSlider"
Grid.Row="2"
Width="200"
Orientation="Horizontal"
VerticalAlignment="Center"
Value="170"
IsSnapToTickEnabled="True"
Minimum="50"
Maximum="400"
TickPlacement="BottomRight"
TickFrequency="10"
/>
</Grid>
</Grid>
</Window>
The comments should go some way to explaining the logic behind the design.
The “code-behind” file is actually in many ways less complicated than its predecessor, as we aren’t having to deal with custom draw code (that’s taken care of in the XAML, now).
Here’s a summary of the main enhancements/optimizations:
- We now use data-binding to display the information we have about our synths
- In the previous version we had to maintain various lists (the synth data, the images and the visible list), so this has simplified things greatly
- We had a little more plumbing to do to make sure our properties implement INotifyPropertyChanged properly, so that the UI gets updated automatically when the properties change
- We also have to use an ObservableCollection<> rather than a List<> for our SynthInfo objects, which allow the notifications to propagate properly. Luckily this doesn’t result in a change in the XML data stored for our synths, which is cool
- WPF had issues with dependency properties such as this being updated from other threads, so we no longer use a timer to update our images
- Dispatcher.Invoke() is actually a better way for queuing up this task, in any case, and makes life much simpler
- Our Window is constructed via App.xaml, and so can no longer take arguments
- We now set the URL in our form constructor and get AutoCAD’s HWnd from the app object, where it gets set as a static variable on startup
- Our events are now hooked up via XAML, which streamlines our form’s constructor
- The csExWb control causes an annoying COM exception in a WPF application on shutdown, which is probably a clue to the issue we were seeing with the WinForms application (and originally with AutoCAD, when I had it hosted in a NETLOADed DLL)
- Because the exception is more visible from WPF, we don’t ever exit the application, even when the “X” is used by the user
- We now just hide the form and let AutoCAD kill the process when the BP command is next called
- This version of the application doesn’t have a right-click menu for importing the point clouds
- This is should be a trivial thing to add back, in case
- In terms of the UI changes, we have a nice little slider that allows the user to change the size of the images in the list, which is quite handy
- It allows you to zoom into the image to help decide whether to import it
Right, that’s about it for the changes. Here’s the updated C# code file, BrowsePhotosynth.xaml.cs:
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Net;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Windows.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Xml;
using System.Xml.Serialization;
namespace BrowsePhotosynth
{
/// <summary>
/// Interaction logic for BrowsePhotosynth.xaml
/// </summary>
public partial class PhotosynthBrowser : Window
{
// A Win32 function we'll use to send messages to AutoCAD
[DllImport("user32.dll")]
private static extern IntPtr SendMessageW(
IntPtr hWnd, int Msg, IntPtr wParam,
ref COPYDATASTRUCT lParam
);
// And the structure we'll require to do so
private struct COPYDATASTRUCT
{
public IntPtr dwData;
public int cbData;
public IntPtr lpData;
}
public abstract class NotifyPropertyChangedBase :
INotifyPropertyChanged
{
#region INotifyPropertyChanged Members
public event PropertyChangedEventHandler PropertyChanged;
#endregion
#region Methods
protected bool CheckPropertyChanged<T>(
string propertyName, ref T oldValue, ref T newValue
)
{
if (oldValue == null && newValue == null)
return false;
if ((oldValue == null && newValue != null) ||
!oldValue.Equals((T)newValue))
{
oldValue = newValue;
FirePropertyChanged(propertyName);
return true;
}
return false;
}
protected void FirePropertyChanged(string propertyName)
{
if (PropertyChanged != null)
{
PropertyChanged(
this, new PropertyChangedEventArgs(propertyName)
);
}
}
#endregion
}
// A class containing the browsing information about each
// point-cloud. This is made serializable to XML, to
// allow easy persistence
[XmlRoot("Synth")]
public class SynthInfo : NotifyPropertyChangedBase
{
// The name of our Photosynth
private string _name;
[XmlAttribute("Name")]
public string Name
{
get { return _name; }
set
{
if (CheckPropertyChanged<string>(
"Name", ref _name, ref value))
FirePropertyChanged("Name");
}
}
// Its URL
private string _url;
[XmlElement("Url")]
public string Url
{
get { return _url; }
set
{
if (CheckPropertyChanged<string>(
"Url", ref _url, ref value))
FirePropertyChanged("Url");
}
}
// The location of its image file
private string _image;
[XmlElement("Image")]
public string Image
{
get { return _image; }
set
{
if (CheckPropertyChanged<string>(
"Image", ref _image, ref value))
FirePropertyChanged("ImagePath");
}
}
// A more complete location for the image
// (not persisted to XML)
public string ImagePath
{
get { return _imagePath + _image; }
}
}
// The location of our JPGs, PCGs and the history XML
private static string _imagePath = "";
// We store a central list of these SynthInfo objects
private ObservableCollection<SynthInfo> _synths =
new ObservableCollection<SynthInfo>();
// Public property for the URL loaded into the browser
// (this is the URL of the browser, which is typically
// "http://photosynth.net")
private string _url = null;
public string Url
{
set { _url = value; }
get { return _url; }
}
// Public property for the handle of the AutoCAD application
// we're connected to
private int _hwnd = 0;
public int HWnd
{
set { _hwnd = value; }
get { return _hwnd; }
}
// Internal constants
const string pointsName = "points_0_0.bin";
const string suffix = " - Photosynth";
const string historyXml = "BrowsingHistory.xml";
const int imgWidth = 128;
const int imgHeight = imgWidth;
// Form constructor
public PhotosynthBrowser()
{
InitializeComponent();
_imagePath =
Environment.GetFolderPath(
Environment.SpecialFolder.MyDocuments
) + "\\Photosynth Point Clouds\\";
_url = "http://photosynth.net";
_hwnd = App._hwnd;
}
private void WindowClosing(
object sender, System.ComponentModel.CancelEventArgs e
)
{
// Store our browsing history to XML
SerializeHistory(_synths);
// If running from AutoCAD, just hide the application,
// as we need to kill it rather then let it exit
// (the browser control causes a COM Exception in WPF)
if (_hwnd > 0)
{
e.Cancel = true;
Hide();
}
}
private void WindowLoaded(object sender, RoutedEventArgs e)
{
// Navigate to the provided URL, if it's non-null and
// not the one we're already pointed at
if (!String.IsNullOrEmpty(_url) &&
browser.LocationUrl.ToString() != _url)
browser.Navigate(_url);
// Load our browsing history from the XML file.
_synths = DeserializeHistory();
clouds.ItemsSource = _synths;
UpdateImages();
}
// Select an item when the mouse hovers over it
private void ListBoxItem_MouseEnter(
object sender, MouseEventArgs e
)
{
clouds.SelectedItem = (sender as Viewbox).DataContext;
if (!clouds.IsFocused)
clouds.Focus();
}
// When an item in our list is clicked on
private void Viewbox_MouseDown(
object sender, MouseButtonEventArgs e
)
{
if (clouds.SelectedItems.Count == 1)
{
if (e.LeftButton == MouseButtonState.Pressed)
ImportPointCloud();
}
}
// Clear our history list when the clear button is clicked
private void ClearButton_Click(object sender, RoutedEventArgs e)
{
_synths.Clear();
string histFile = _imagePath + historyXml;
if (File.Exists(histFile))
File.Delete(histFile);
}
// Find out when HTTP traffic is in progress
private void HttpTransaction(
object sender,
csExWB.ProtocolHandlerBeginTransactionEventArgs e
)
{
// If we detect the first point cloud file in a series...
if (e.URL.Contains(pointsName))
{
csExWB.cEXWB wb = (csExWB.cEXWB)sender;
// Get the page's title and extract the URL
string title = wb.GetTitle(true);
// If the point cloud was embedded in the main page,
// let's extract its actual title from the HTML content
// (this will change as the Photosynth page structure
// changes, but if it doesn't find the relevant entries
// then we just use the overall title)
if (title.StartsWith("Photosynth"))
{
string src = wb.DocumentSource;
if (src.Contains("title-block"))
{
src = src.Substring(src.IndexOf("title-block"));
if (src.Contains("A href="))
{
src = src.Substring(src.IndexOf("A href="));
if (src.Contains(">"))
{
src = src.Substring(src.IndexOf(">"));
if (src.Contains("<"))
{
int endPos = src.IndexOf("<");
if (endPos > 1)
{
title = src.Substring(1, endPos - 1);
}
}
}
}
}
}
else if (title.EndsWith(suffix))
{
// Strip off the common suffix, if it's there
title =
title.Substring(0, title.Length - suffix.Length);
}
// Extract the base URL, without the initial point-cloud
// name
string baseUrl =
e.URL.Substring(0, e.URL.Length - pointsName.Length);
// Use this info to create a new entry in our list
AddToPointCloudList(title, baseUrl);
// We cannot just start downloading images directly, but
// we can if we queue up the function call
Dispatcher.Invoke(
DispatcherPriority.Normal,
(ThreadStart)delegate() { UpdateImages(); }
);
}
}
// Update the images in our synth list, if needed
private void UpdateImages()
{
// Add images for any items which don't yet have them
// (realistically this will usually be just one image,
// as we check every second and browsing takes time)
for (int i=0; i < _synths.Count; i++)
{
SynthInfo sinf = _synths[i];
if (String.IsNullOrEmpty(sinf.Image))
{
string baseUrl = sinf.Url;
// Transform our base URL to get the URL
// to an appropriate image on the server
if (baseUrl.Contains(".synth_files"))
{
string imageUrl =
baseUrl.Substring(
0, baseUrl.LastIndexOf(".synth_files")
)
+ "_files/6/0_0.jpg";
// Create a web client to download the image
WebClient wc = new WebClient();
using (wc)
{
string locFile =
MakeValidFileName(sinf.Name) + ".jpg";
string locImage = _imagePath + locFile;
// Try to download our image file
try
{
wc.DownloadFile(imageUrl, locImage);
}
catch
{ }
// If we were successful, load and add it
if (File.Exists(locImage))
{
// Make sure our browsing history reflects
// the existence of the downloaded image
sinf.Image = locFile;
_synths[i] = sinf;
}
}
}
}
}
}
// Add a point cloud with a certain title and URL to our list
private void AddToPointCloudList(string title, string baseUrl)
{
bool found = false;
// First we check that it's not already in the list
foreach (SynthInfo sinf in _synths)
{
if (sinf.Name == title && sinf.Url == baseUrl)
{
found = true;
break;
}
}
// If it isn't add it to the list and to our browsing history
if (!found)
{
SynthInfo sinf = new SynthInfo();
sinf.Url = baseUrl;
sinf.Name = title;
_synths.Add(sinf);
}
}
// Import a point cloud into AutoCAD by firing a command
private void ImportPointCloud()
{
// If we're not connected to an AutoCAD session (via
// the handle we received as a command-line argument),
// then we show a message and continue
if (_hwnd == 0)
{
MessageBox.Show(
"This browser is not connected to an instance of " +
"AutoCAD. Relaunch from AutoCAD to import Point " +
"Clouds from your browsing history.",
"Browse Photosynth",
MessageBoxButton.OK,
MessageBoxImage.Information
);
}
else
{
// Assume the item in the _synths list is at the same
// location as the selected item
SynthInfo sinf = _synths[clouds.SelectedIndex];
string title = sinf.Name;
string firstUrl = sinf.Url + pointsName;
// Hide the form and stop the browsing operation
Hide();
browser.NavToBlank();
browser.Stop();
// Fire off our command to AutoCAD
SendCommandToAutoCAD(
"_.IMPORTPHOTOSYNTH \"" + firstUrl + "\" \"" +
title + "\" "
);
// We no longer exit the application, as a COM exception
// causes problems with WPF
}
}
// Save the current state of our browsing history
// to an XML file
private void SerializeHistory(
ObservableCollection<SynthInfo> synths
)
{
if (!Directory.Exists(_imagePath))
Directory.CreateDirectory(_imagePath);
if (_synths.Count > 0)
{
XmlSerializer xs =
new XmlSerializer(
typeof(ObservableCollection<SynthInfo>)
);
XmlTextWriter xw =
new XmlTextWriter(_imagePath + historyXml, Encoding.UTF8);
xs.Serialize(xw, synths);
xw.Close();
}
}
// Read and return the previous browsing history from
// our stored XML file
private ObservableCollection<SynthInfo> DeserializeHistory()
{
string histFile = _imagePath + historyXml;
if (File.Exists(histFile))
{
XmlSerializer xs =
new XmlSerializer(
typeof(ObservableCollection<SynthInfo>)
);
XmlTextReader xr = new XmlTextReader(histFile);
if (xs.CanDeserialize(xr))
{
ObservableCollection<SynthInfo> synths =
(ObservableCollection<SynthInfo>)xs.Deserialize(xr);
xr.Close();
return synths;
}
}
return new ObservableCollection<SynthInfo>();
}
// Just use the Win32 API to communicate with AutoCAD.
// We simply need to send a command string, so this
// approach avoids a dependency on AutoCAD's COM
// interface
private void SendCommandToAutoCAD(string toSend)
{
const int WM_COPYDATA = 0x4A;
COPYDATASTRUCT cds = new COPYDATASTRUCT();
cds.dwData = new IntPtr(1);
string data = toSend + "\0";
cds.cbData = data.Length * Marshal.SystemDefaultCharSize;
cds.lpData = Marshal.StringToCoTaskMemAuto(data);
SendMessageW(
new IntPtr(_hwnd),
WM_COPYDATA,
new IntPtr(this.HWnd),
ref cds
);
Marshal.FreeCoTaskMem(cds.lpData);
}
// Function to create a valid filename from a string.
// This has been duplicated from the plugin project
private static string MakeValidFileName(string name)
{
string invChars =
Regex.Escape(new string(Path.GetInvalidFileNameChars()));
string invRegEx = string.Format(@"[{0}]", invChars + ".");
return Regex.Replace(name, invRegEx, "-");
}
}
}
Let’s see what happens when we run our updated BP command. Right off we should see the same history gets displayed in the new browser:
You’ll notice black isn’t mapped as the transparent colour in these images, which can be done with WPF but with quite a bit more work than it took with WinForms, unfortunately.
If we hover over the button at the top, we see it highlighted with our green background colour:
We can use the slider at the bottom to adjust the size of the image to get them all visible in the list:
And we can slide it the other way to zoom right into the thumbnail images, too:
Other than that the application should behave as before with respect to AutoCAD.
My next planned enhancement is on the AutoCAD side of things as opposed to the browsing interface. I’d like to to use the Photosynth web service to query information about a particular synth to help optimize the download and processing of the points. Which may mean using F# or perhaps the Task Parallel Library from C# to parallelize much of these operations (taking advantage of multiple cores and the effect of network latency). But that’s for another day. :-)