10: Auto-Zoom

This entry is part 10 of a 12-part series on WPF 3D.

Zoom to Fit

Most CAD-like programs have the ability to automatically zoom a 3D picture so it fits in the window.

You don't want this situation, where the model is too small and most of the window is wasted:

Nor do you want this situation, where the picture is zoomed so far that only a small portion of the model is visible:

So we provide a feature to automatically zoom the view to the right size so that everything just fits in the visible space available.

But the implementation of a feature like this is surprisingly tricky.  It is another one of those features which must straddle the boundary between the 3D world and the 2D world.  Scaling the model is done with a 3D transform.  But the available window space is a 2D area.

Finding the 2D Bounding Box

The first thing we need is a way to measure the size of the 2D projection.  The window is showing a 2D projection of a 3D object.  In terms of the 3D coordinate system, we know how big the object is.  But how big is the 2D projection we see on our screen? 

Unless I am missing something obvious in the WPF 3D APIs, this is surprisingly difficult to get.  My approach is to iterate over all the triangles in my scene.  For each of the three points in each triangle, I find the 2D projection of that point and grow a bounding rectangle to include it.

But how do I find the 2D projection of a 3D point?  This is the part that is a bit trickier than I expected it to be.  I naively hoped I might find that the Viewport3D class has a method that would do this for me, something like this:

public Point GetProjectedPoint(Point3D p)

But it doesn't.

As it turns out, the 3D Tools library saves the day again.  It contains a MathUtils class which contains a routine called TryWorldToViewportTransform().  This method returns a transformation matrix which converts 3D coordinates ("World") to 2D coordinates ("Viewport").  Reading the code for this method is interesting.  It has to jump through a surprising number of hoops to retrieve the matrix we need.

So my implementation of retrieving a 2D bounding box from a Viewport3D looks like this:

public static Rect Get2DBoundingBox(Viewport3D vp)
{
    bool bOK;

    Viewport3DVisual vpv =
        VisualTreeHelper.GetParent(
            vp.Children[0]) as Viewport3DVisual;

    Matrix3D m =
        MathUtils.TryWorldToViewportTransform(vpv, out bOK);

    bool bFirst = true;
    Rect r = new Rect();

    foreach (Visual3D v3d in vp.Children)
    {
        if (v3d is ModelVisual3D)
        {
            ModelVisual3D mv3d = (ModelVisual3D)v3d;
            if (mv3d.Content is GeometryModel3D)
            {
                GeometryModel3D gm3d =
                    (GeometryModel3D) mv3d.Content;

                if (gm3d.Geometry is MeshGeometry3D)
                {
                    MeshGeometry3D mg3d =
                        (MeshGeometry3D)gm3d.Geometry;

                    foreach (Point3D p3d in mg3d.Positions)
                    {
                        Point3D pb = m.Transform(p3d);
                        Point p2d = new Point(pb.X, pb.Y);
                        if (bFirst)
                        {
                            r = new Rect(p2d, new Size(1, 1));
                            bFirst = false;
                        }
                        else
                        {
                            r.Union(p2d);
                        }
                    }
                }
            }
        }
    }

    return r;
}

This code deserves a few remarks:

  1. This approach works only if I don't put Transforms on the individual objects in the scene.  A more generic implementation would need to walk up the visual tree from every MeshGeometry3D and stop to apply every Transform object it finds along the way.
  2. TryWorldToViewportTransform() wants a Viewport3DVisual, but I have a Viewport3D.  Since a Viewport3D encapsulates a Viewport3DVisual, I can just grab it.  But that member is apparently not public, so I cheat and retrieve it by walking up the visual tree from its first child.  I may be on thin ice here.
  3. I'm sure I have not handled all the cases in the hierarchy of stuff in Viewport3D.Children.

So this code is more of a hack and will need some serious attention before it can be considered robust as a general purpose solution.  Nonetheless, for my situation it is currently working.  If I run the code on my app, I can take the resulting Rect and use it to place a partly transparent Rectangle in the Overlay layer, just to prove that it's doing the right thing:

So now what?

Now that I can calculate a 2D bounding box, I want to calculate the proper zoom so that the model will just fit.  Recall from part 9 that on the left side of my window is a zoom slider which is tied to a ScaleTransform3D which is part of the Transform for the Camera on my Viewport3D.

The problem is that the mathematical relationship between the value of that slider and the coordinates of the 2D bounding box is not obvious.  I actually ran a loop and calculated a bunch of values so I could graph them in Excel.  It's not linear.  It looks more geometric, but I was only plotting the zoom value vs. the 2D height.  There's probably a way to calculate just the right scale factor, but I haven't found it yet.

When I am facing a tricky problem, sometimes I like to start by just quickly coding the simplest solution that will work.  So I put my fingers to the keyboard and typed for a couple minutes.  This is what happened:

private bool TooBig()
{
    Rect r = sdwpf.Get2DBoundingBox(vstuff.vp);

    if (r.Left < 0)
    {
        return true;
    }
    if (r.Right > vstuff.vp.ActualWidth)
    {
        return true;
    }
    if (r.Top < 0)
    {
        return true;
    }
    if (r.Bottom > vstuff.vp.ActualHeight)
    {
        return true;
    }
    return false;
}

void OnClick_Fit(object sender, RoutedEventArgs args)
{
    if (TooBig())
    {
        while (TooBig())
        {
            slider_zoom.Value -= 0.1;
        }
        while (!TooBig())
        {
            slider_zoom.Value += 0.01;
        }
        slider_zoom.Value -= 0.01;
    }
    else
    {
        while (!TooBig())
        {
            slider_zoom.Value += 0.1;
        }
        while (TooBig())
        {
            slider_zoom.Value -= 0.01;
        }
    }
}

This rather absurd solution is the first thing that popped into my head.  And it works!

Normally when I do something like this, I immediately start looking to replace the implementation with something better.  However, today is Sunday.  If I put any further thought into this problem it would be sort of like work.  So I think I'll just leave it alone for now.  I don't think it's quite stupid enough to land me on The Daily WTF:-)