What you are looking for is essentially a non-linear transformation. The Transform property in Visual can only perform linear transformations. Fortunately, WPF 3D features come to your rescue. You can easily accomplish what you are looking for by creating a simple user control that will be used as follows:
<local:DisplayOnPath Path="{Binding ...}" Content="Text to display" />
Here's how to do it:
First, create a custom DisplayOnPath control.
- Create it using a custom Visual Studio control template (make sure your assembly: ThemeInfo attribute is set correctly and all that)
- Add a "Path" dependency property of type
Geometry (use the wpfdp snippet) - Add the read-only dependency property of DisplayMesh of type
Geometry3D (use the wpfdpro snippet) - Add a
PropertyChangedCallback for Path to call the ComputeDisplayMesh method to convert Path to Geometry3D, and then set DisplayMesh from it.
It will look something like this:
public class DisplayOnPath : ContentControl { static DisplayOnPath() { DefaultStyleKeyProperty.OverrideMetadata ... } public Geometry Path { get { return (Geometry)GetValue(PathProperty) ... public static DependencyProperty PathProperty = ... new UIElementMetadata { PropertyChangedCallback = (obj, e) => { var displayOnPath = obj as DisplayOnPath; displayOnPath.DisplayMesh = ComputeDisplayMesh(displayOnPath.Path); })); public Geometry3D DisplayMesh { get { ... } private set { ... } } private static DependencyPropertyKey DisplayMeshPropertyKey = ... public static DependencyProperty DisplayMeshProperty = ... }
Then create a style template and Themes/Generic.xaml controls in Themes/Generic.xaml (or the ResourceDictionary included in it), as for any custom Themes/Generic.xaml controls. The template will have the following contents:
<Style TargetType="{x:Type local:DisplayOnPath}"> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type local:DisplayOnPath}"> <Viewport3DVisual ...> <ModelVisual3D> <ModelVisual3D.Content> <GeometryModel3D Geometry="{Binding DisplayMesh, RelativeSource={RelativeSource TemplatedParent}}"> <GeometryModel3D.Material> <DiffuseMaterial> <DiffuseMaterial.Brush> <VisualBrush ...> <VisualBrush.Visual> <ContentPresenter /> ...
To do this, a 3D model is displayed that uses DisplayMesh to determine the location and uses your Content control as a brush material.
Please note that you may need to set other properties in Viewport3DVisual and VisualBrush in order for the layout to work the way you want and to visually stretch the content accordingly.
All that remains is the "ComputeDisplayMesh" function. This is a trivial display if you want the top of the content (the displayed words) to be perpendicular to a certain distance from the path. Of course, there are other algorithms that you can choose instead, for example, create a parallel path and use the percentage distance along each.
In any case, the basic algorithm is the same:
- Convert to
PathGeometry using PathGeometry.CreateFromGeometry - Select the appropriate number of rectangles in your 'n' grid using the heuristic of your choice. Maybe start with hard coding n = 50.
- Calculate your
Positions for all corners of the rectangles. There are n + 1 angles at the top and n + 1 angles at the bottom. Each bottom corner can be found by calling PathGeometry.GetPointAtFractionOfLength . This also returns the tangent, so it's easy to find the top corner. - Calculate your
TriangleIndices . This is trivial. Each rectangle will consist of two triangles, so there will be six indices in each rectangle. - Calculate your
TextureCoordinates . This is even more trivial because they will all be 0, 1 or i / n (where I am the index of the rectangle).
Note that if you use a fixed value of n, the only thing you will ever have to recount when changing the path is the Posisions array. Everything else is fixed.
Here is what the main part of this method looks like:
var pathGeometry = PathGeometry.CreateFromGeometry(path); int n=50; // Compute points in 2D var positions = new List<Point>(); for(int i=0; i<=n; i++) { Point point, tangent; pathGeometry.GetPointAtFractionOfLength((double)i/n, out point, out tangent); var perpendicular = new Vector(tangent.Y, -tangent.X); perpendicular.Normalize(); positions.Add(point + perpendicular * height); // Top corner positions.Add(point); // Bottom corner } // Convert to 3D by adding 0 'Z' value mesh.Positions = new Point3DCollection(from p in positions select new Point3D(pX, pY, 0)); // Now compute the triangle indices, same way for(int i=0; i<n; i++) { // First triangle mesh.TriangleIndices.Add(i*2+0); // Upper left mesh.TriangleIndices.Add(i*2+2); // Upper right mesh.TriangleIndices.Add(i*2+1); // Lower left // Second triangle mesh.TriangleIndices.Add(i*2+1); // Lower left mesh.TriangleIndices.Add(i*2+2); // Upper right mesh.TriangleIndices.Add(i*2+3); // Lower right } // Add code here to create the TextureCoordinates
This is about it. Most of the code is written above. I leave it to you to fill in everything else.
By the way, please note that if you take a creative approach with a value of "Z", you can get really amazing effects.
Refresh
Mark implemented the code for this and ran into three problems. Here are the problems and solutions for them:
I made a mistake in my TriangleIndices order for triangle # 1. This is fixed above. Initially, I had these indicators, going to the top left - bottom left - top right. Going around the triangle counterclockwise, we actually saw the back of the triangle, so nothing was drawn. Just changing the order of the indices, we rotate clockwise so that the triangle is visible.
The binding to GeometryModel3D was originally a TemplateBinding binding. This did not work because TemplateBinding does not handle updates in the same way. Changing it to regular binding solved the problem.
The coordinate system for 3D is + Y up, while for 2D + Y it is down, so the path turned upside down. This can be solved either by negating Y in the code, or by adding a RenderTransform to ViewPort3DVisual , as you prefer. I personally prefer RenderTransform because it makes the ComputeDisplayMesh code more readable.
Here's a snapshot of Mark's code animating a mood that I think we all share:

(source: rayburnsresume.com )