views:

766

answers:

3

I have the need to create a wavey looking text object in my WPF app, and I was actually assuming that there would be a "bend along a path" type of options, but I dont see one at all in Blend.

I found a tutorial that suggest you need to convert you text to a path letter by letter then rotate it around, but that is totally horrible in my opinion, to much room for error and not enough flexibility.

I essentially want a sentence to have an animated wave effect, how can I achieve this?

Thanks all Mark

+29  A: 

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"

Ray Burns
+1 for the sheer amount of effort you put into your answer. Kudos!
Paul Lammertsma
+1. I didn't understand half of your answer, but for such a thorough explanation, you deserve it ;)
Thomas Levesque
That is one awesome reply, I need some time to digest this and get into it... I will get back to you when I have had a chance to play. Seriously, thanks so much for the effort, if I can get this working just how I want it, then this will be such a massive win for my project, you have no idea how excited this could get people :)
Mark
Ok, questions, firstly did you mean for *Path3D* to be *DisplayMesh*? I have a normal path like so `<Path Data="..." />` when I create my user control, I need to bind it like this `Path="{Binding p1.Data}"` correct?
Mark
Yes, I changed names from *Path3D* to *DisplayMesh* at the last minute and missed one replacement. And yes, the Path is intended to be bound to a Geometry so it would be bound to p1.Data (assuming p1 is in the DataContext, otherwise it would be `{Binding Data,ElementName=p1}` or something like that).
Ray Burns
+1 again due to the impressively impenetrable answer. This must be what managers feel like all the time when they look at code...
Robert Grant
hmm, ok, well I think I have filled in the blanks, but i still dont see anything rendered :(. Does this look correct for the TexturePositions `for (double t = 0; t < n; t++) mesh.TextureCoordinates.Add(new Point(t/n, t/n));`?
Mark
No it is more like `for(int i=0; i<=n; i++) for(int j=0; j<2; j++) mesh.TextureCoordinates.Add(new Point((double)t/n, j));` because you need TextureCoordinates for both the top and bottom corner. Also notice the i<=n in the outer loop. i<n doesn't include the right-most top and bottom corners.
Ray Burns
Your TextureCoordinate problem would cause you to see nothing at all. If setting the TextureCoordinates correctly still doesn't fix it, I would suspect the VisualBrush. Try a SolidColorBrush to start with, then go to a LinearGradientBrush. Once those work, try a VisualBrush containing an enormous green rectangle, then a VisualBrush containing text but with brush's ViewBox and ViewPort fixed so the text all shows up in (0,0)-(1,1) range because that is the range the TextureCoordinates are using. Test the VisualBrush separately. Once that works, do your real computations for ViewPort/Box.
Ray Burns
Thanks a lot, I'll give those a try and let you know how I go :-)
Mark
ok, I can get it to display, but its reversed to that of the path. see here: http://yfrog.com/5jpath1p ?? Also, I think there is something wrong with my bindings in my template, because I have actually not figured out how to bind the Geometry3D to the mesh in the control, `<GeometryModel3D Geometry="{TemplateBinding DisplayMesh}">` does not work. But when I hard code the mesh (instead of binding) it does work! So I added a debug button to output the mesh details when using the binding, and as far as I can tell, the mesh is fully populated. Any ideas? Also the triangles look a bit funny too...
Mark
Hi Mark, I just saw this. You are unbelievably close to a complete solution! All three of your problems are trivial to solve. #1: Correct the triangles by rearranging the order of the points in "First triangle" (sorry, my mistake). #2: Correct the orientation with a RenderTransform. #3: Correct the binding by replacing the TemplateBinding with "{Binding DisplayMesh, RelativeSource={RelativeSource TemplatedParent}}". It took me all of two minutes to get your example working with the red wavy rectangle, and five minutes more to replace the rectangle with text! I'll update my answer.
Ray Burns
A: 

I thought I would actually post the details of my progress so that we can get out of the comments (which dont format as nice :))

Here is my main window:

<Window.Resources>
        <Style TargetType="{x:Type local:DisplayOnPath}">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate TargetType="{x:Type local:DisplayOnPath}">
                        <Viewport3D>
                            <Viewport3D.Camera>
                                <PerspectiveCamera FieldOfView="60" 
                                               FarPlaneDistance="1000" 
                                               NearPlaneDistance="10" 
                                               Position="0,0,300" 
                                               LookDirection="0,0,-1" 
                                               UpDirection="0,1,0"/>
                            </Viewport3D.Camera>
                            <ModelVisual3D>
                                <ModelVisual3D.Content>
                                    <Model3DGroup>
                                        <AmbientLight Color="#ffffff" />
                                        <GeometryModel3D Geometry="{TemplateBinding DisplayMesh}">
                                            <GeometryModel3D.Material>
                                                <DiffuseMaterial>
                                                    <DiffuseMaterial.Brush>
                                                        <SolidColorBrush Color="Red" />
                                                    </DiffuseMaterial.Brush>
                                                </DiffuseMaterial>
                                            </GeometryModel3D.Material>
                                        </GeometryModel3D>
                                    </Model3DGroup>
                                </ModelVisual3D.Content>
                            </ModelVisual3D>
                        </Viewport3D>
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
        <Storyboard x:Key="movepath">
            <PointAnimationUsingKeyFrames BeginTime="00:00:00" Storyboard.TargetName="p1" Storyboard.TargetProperty="(Path.Data).(PathGeometry.Figures)[0].(PathFigure.Segments)[4].(LineSegment.Point)">
                <SplinePointKeyFrame KeyTime="00:00:01" Value="181.5,81.5"/>
            </PointAnimationUsingKeyFrames>
            <PointAnimationUsingKeyFrames BeginTime="00:00:00" Storyboard.TargetName="p1" Storyboard.TargetProperty="(Path.Data).(PathGeometry.Figures)[0].(PathFigure.Segments)[3].(LineSegment.Point)">
                <SplinePointKeyFrame KeyTime="00:00:01" Value="141.5,69.5"/>
            </PointAnimationUsingKeyFrames>
            <PointAnimationUsingKeyFrames BeginTime="00:00:00" Storyboard.TargetName="p1" Storyboard.TargetProperty="(Path.Data).(PathGeometry.Figures)[0].(PathFigure.Segments)[1].(LineSegment.Point)">
                <SplinePointKeyFrame KeyTime="00:00:01" Value="62.5,49.5"/>
            </PointAnimationUsingKeyFrames>
        </Storyboard>
    </Window.Resources>

    <Window.Triggers>
        <EventTrigger RoutedEvent="FrameworkElement.Loaded">
            <BeginStoryboard Storyboard="{StaticResource movepath}"/>
        </EventTrigger>
    </Window.Triggers>

  <Grid x:Name="grid1">
    <Path x:Name="p1" Stroke="Black" Margin="238.5,156.5,331.5,0" VerticalAlignment="Top" Height="82">
        <Path.Data>
            <PathGeometry>
                <PathFigure StartPoint="0.5,0.5">
                    <LineSegment Point="44.5,15.5"/>
                    <LineSegment Point="73.5,30.5"/>
                    <LineSegment Point="91.5,56.5"/>
                    <LineSegment Point="139.5,53.5"/>
                    <LineSegment Point="161,80"/>
                </PathFigure>
            </PathGeometry>
        </Path.Data>
    </Path>
    <local:DisplayOnPath x:Name="wave1" Path="{Binding Data, ElementName=p1, Mode=Default}" />
    </Grid>

Then I have the actual user control:

public partial class DisplayOnPath : UserControl
    {
        public MeshGeometry3D DisplayMesh
        {
            get { return (MeshGeometry3D)GetValue(DisplayMeshProperty); }
            set { SetValue(DisplayMeshProperty, value); }
        }

        public Geometry Path
        {
            get { return (Geometry)GetValue(PathProperty); }
            set { SetValue(PathProperty, value); }
        }

        public static readonly DependencyProperty DisplayMeshProperty = 
            DependencyProperty.Register("DisplayMesh", typeof(MeshGeometry3D), typeof(DisplayOnPath), new FrameworkPropertyMetadata(new MeshGeometry3D(), FrameworkPropertyMetadataOptions.AffectsRender));

        public static readonly DependencyProperty PathProperty =
        DependencyProperty.Register("Path", 
                                    typeof(Geometry), 
                                    typeof(DisplayOnPath), 
                                    new PropertyMetadata()
                                    {
                                        PropertyChangedCallback = (obj, e) =>
                                        {
                                            var displayOnPath = obj as DisplayOnPath;
                                            displayOnPath.DisplayMesh = ComputeDisplayMesh(displayOnPath.Path);
                                        }
                                    }
        );

        private static MeshGeometry3D ComputeDisplayMesh(Geometry path)
        {
            var mesh = new MeshGeometry3D();

            var pathGeometry = PathGeometry.CreateFromGeometry(path);
            int n = 50;
            int height = 10;

            // Compute points in 2D
            var positions = new List<Point>();
            for (int i = 0; i <= n; i++)
            {
                Point point, tangent;
                pathGeometry.GetPointAtFractionLength((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 + 1);  // Lower left
                mesh.TriangleIndices.Add(i * 2 + 2);  // Upper right
                // 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
            }

            for (int i = 0; i <= n; i++)
            {
                for (int j = 0; j < 2; j++)
                {
                    mesh.TextureCoordinates.Add(new Point((double) i/n, j));
                }
            }

            //Console.WriteLine("Positions=\"" + mesh.Positions + "\"\nTriangleIndices=\"" + mesh.TriangleIndices +
            //                  "\"\nTextureCoordinates=\"" + mesh.TextureCoordinates + "\"");
            return mesh;
        }

        static DisplayOnPath()
        {
            DefaultStyleKeyProperty.OverrideMetadata(typeof(DisplayOnPath), new FrameworkPropertyMetadata(typeof(DisplayOnPath)));
        }

        public DisplayOnPath()
        {
            InitializeComponent();
        }
    }

At the moment as is, this does not render anything other than the path.

BUT if you get the mesh details of wave1 after the window has loaded, then replace the binding to be hard coded values, you get this: http://img199.yfrog.com/i/path1.png/

Which has 2 main problems as it is:

  1. The triangles are all pointy, so I think the rectangles are not being defined correctly
  2. Its reversed! But I think thats got something to do with the tangents
Mark
you should to edit your original question, not to add an answer (as this isn't a answer)
Rubens Farias
+6  A: 

You might want to check out Charles Petzold's MSDN article Render Text On A Path With WPF. alt text

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

Patrick Klug
thanks for the link, I cannot believe that this article did not come up in my googling...
Mark
no worries - glad I could help :-)
Patrick Klug