Some implementation details of the Planerator

My previous two posts (here and here) discuss usage of the Planerator control.  There are some unique issues that needed to be resolved in the implementation that WPF geeks might be interested in.  If you just are interested in using it, and not finding out how the sausage is made, you might want to just skip this.

Actually, though, the sausage-making in this case is pretty nice, and the Planerator implementation is quite clean.  It's contained in about 280 lines of C# code in Planerator.cs.  There are some tricky aspects, though. Below are the salient points.

Interactive Content on 3D

One of the new features in WPF 3.5 is Viewport2DVisual3D, which is a way of getting a UIElement in as a material in 3D, and yet have it remain interactive.  This is used in the Planerator for the front face of the plane.  The back face of the plane just uses a VisualBrush for its material, and thus isn't interactive.

Caching of brush realizations

One critical performance optimization that the developer must opt into is to allow caching of the bitmaps that result from rasterizing a VisualBrush or Viewport2DVisual3D.  Without doing this, each frame renders the material anew, which can be quite expensive.  There are static methods off of RenderOptions that control caching in WPF.  In Planerator.cs, these are called from the "SetCachingForObject" private method.

Layout invalidation

When the contents of the Planerator change internally resulting in layout changes to those contents (for instance, a Label in a horizontal StackPanel gets longer, making the entire content wider), then the Planerator itself should be remeasured.  This, though, poses a problem because the contents are embedded in a Viewport2DVisual3D, and layout doesn't flow in and out of that. 

The solution pursued here is that when we set up the Planerator, we wrap the child in a custom Decorator defined in the project and called LayoutInvalidationCatcher.  LayoutInvalidationCatcher overrides MeasureOverride and ArrangeOverride, and results in invalidating the Planerator instance's measure and arrange, respectively, thus forcing the Planerator to be re-laid out, and getting the results we want.

Databinding passthrough

A problem similar to the Layout Invalidation problem above is with databinding.  Because the children are embedded in Viewport2DVisual3D, databinding processing doesn't flow into it.  Therefore, we make the logical child of the Planerator be the content itself, while the visual child is the 3D construct that get built up, including the use of Viewport2DVisual3D.  By doing this, we allow databinding to flow to the Planerator's child properly, through the logical tree.  Then everything is set up to render it as part of the visual tree.

Proper sizing

Finally, a key aspect to making Planerator meaningful and friction-free is that it sizes itself precisely to the size of the underlying content, as if the Planerator just weren't there (at least until you start changing the rotation angles).  There are two relevant pieces to this.  One is camera setup, and this is described in detail in this previous post of mine. 

The second is in determining the proper size of the child element itself.  It's not sufficient to use the results of Measure(), since those aren't the "ink bounds"... that is, it includes whitespace that doesn't get rendered to because, for instance, a Canvas is made explicitly wider than all the stuff that renders into it.  This is problematic because when such a visual is applied as a VisualBrush (or as Viewport2DVisual3D) it just uses the rendered bounds of the Visual, and thus the result would be distorted if we used Measure() results.  Thus, in order to get the correct "ink bounds", we use VisualTreeHelper.GetDescendantBounds().

 

Put all those together, and you get a pretty cool Planerator control.