WPF How can you create a nice looking wave of letters

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.

  1. Create it using Visual Studio's custom control template (making sure your assembly:ThemeInfo attribute is set correctly and all that)
  2. Add a dependency property "Path" of type Geometry (use wpfdp snippet)
  3. Add a read-only dependency property "DisplayMesh" of type Geometry3D (use wpfdpro snippet)
  4. 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:

  1. Convert to PathGeometry using PathGeometry.CreateFromGeometry
  2. Select an appropriate number of rectangles in your mesh, 'n', using a heuristic of your choice. Maybe start with hard-coding n=50.
  3. 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.
  4. Compute your TriangleIndices. This is trivial. Each rectangle will be two triangles, so there will be six indices per rectangle.
  5. 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:

  1. 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.

  2. 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.

  3. 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:

Snapshot of animating text "StackOverflowIsFun"
(source: rayburnsresume.com)


You might want to check out Charles Petzold's MSDN article Render Text On A Path With WPF (archived version here).

wavy text

I have found this article very useful and he also provides a sample where he uses animation.