What you're looking for is effectively a non-linear transform. The Transform property on Visual can only do linear transforms. Fortunately WPF's 3D features come to your rescue. You can easily accomplish what you are looking for by creating a simple custom control that would be used like this:
<local:DisplayOnPath Path="{Binding ...}" Content="Text to display" />
Here is how to do it:
First create the "DisplayOnPath" custom control.
- Create it using Visual Studio's custom control template (making sure your assembly:ThemeInfo attribute is set correctly and all that)
- Add a dependency property "Path" of type
Geometry
(use wpfdp snippet)
- Add a read-only dependency property "DisplayMesh" of type
Geometry3D
(use wpfdpro snippet)
- Add a
PropertyChangedCallback
for Path to call a "ComputeDisplayMesh" method to convert the Path to a Geometry3D, 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 = ...
}
Next create the style and control template in Themes/Generic.xaml
(or a ResourceDictionary
included by it) as for any custom control. The template will have contents like this:
<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 />
...
What this does is display a 3D model that uses a DisplayMesh for location and uses your control's Content as a brush material.
Note that you may need to set other properties on the Viewport3DVisual and VisualBrush to get the layout to work the way you want and for the content visual to be stretched appropriately.
All that is left is the "ComputeDisplayMesh" function. This is a trivial mapping if you want the top of the content (the words you are displaying) to be perpendicular a certain distance out from the path. Of course, there are other algorithms you might choose instead, such as to create a parallel path and use percent distance along each.
In any case, the basic algorithm is the same:
- Convert to
PathGeometry
using PathGeometry.CreateFromGeometry
- Select an appropriate number of rectangles in your mesh, 'n', using a heuristic of your choice. Maybe start with hard-coding n=50.
- Compute your
Positions
values for all the corners of the rectangles. There are n+1 corners on top and n+1 corners on the bottom. Each bottom corner can be found by calling PathGeometry.GetPointAtFractionOfLength
. This also returns a tangent, so it is easy to find the top corner as well.
- Compute your
TriangleIndices
. This is trivial. Each rectangle will be two triangles, so there will be six indices per rectangle.
- Compute your
TextureCoordinates
. This is even more trivial, because they will all be 0, 1, or i/n (where i is the rectangle index).
Note that if you are using a fixed value of n, the only thing you ever have to recompute when the path changes is the Posisions
array. Everything else is fixed.
Here is the 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(p.X, p.Y, 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
That's about it. Most of the code is written above. I leave it to you to fill in the rest.
By the way, note that by being creative with the 'Z' value, you can get some truly awesome effects.
Update
Mark implemented the code for this and encountered three problems. Here are the problems and the solutions for them:
I made a mistake in my TriangleIndices order for triangle #1. It is corrected above. I originally had those indices going upper left - lower left - upper right. By going around the triangle counterclockwise we actually saw the back of the triangle so nothing was painted. By simply changing the order of the indices we go around clockwise so the triangle is visible.
The binding on the GeometryModel3D was originally a TemplateBinding
. This didn't work because TemplateBinding doesn't handle updates the same way. Changing it to a regular binding fixed the problem.
The coordinate system for 3D is +Y is up, whereas for 2D +Y is down, so the path appeared upside-down. This can be solved by either negating Y in the code or by adding a RenderTransform
on the ViewPort3DVisual
, as you prefer. I personally prefer the RenderTransform because it makes the ComputeDisplayMesh code more readable.
Here is a snapshot of Mark's code animating a sentiment I think we all share: