Tablet PC: Combining Ink and GDI+ to create a fill effect

I have seen several requests on the Tablet PC developer newsgroup and on gotdotnet.com about implementing fill functionality within ink strokes. Often the first attempt at implementing such an effect is to use actual ink but it quickly becomes obvious that using ink strokes to fill a polygon is difficult, much like trying to color a picture with crayons and stay within the lines; not exactly a graphical technique suitable of the computer age. The second big issue is that ink itself is overkill for such a task, every ink stroke is a polyline of points and associated meta data, which is a lot more overhead than we need for a fill effect.

Instead, we need to forget about the Tablet PC SDK and what it offers for a minute, and think about how we would have done this on a non-Tablet using GDI or GDI+. At its core, a stroke is just a polyline, so we can treat the points as a polygon and fill it using Graphics.FillPolygon().

Tagging Fill Strokes
The first task is how to identify strokes that are to be filled versus those that aren't. One way to do this is to add an extended property to the strokes that we do want to fill. Extended properties allow you to add application specific meta data on a per Stroke, per Strokes or per Ink object basis.

We define a GUID to represent our fill strokes and in our Stroke event handler, we tag the stroke with the extended property so we can query it later. In the example code below, our InkOverlay is attached to a Panel control and any non-highlighter red stroke is tagged as a fill stroke.

 private readonly Guid FILL = new Guid("A4798FEC-7C61-4E94-B052-CAF1B6280C7A"); 
private void OnStroke(object sender, InkCollectorStrokeEventArgs e) 
{
    if (inkOverlay.EditingMode == InkOverlayEditingMode.Ink) 
    {
        if ((e.Stroke.DrawingAttributes.Color == Color.Red) && 
            (e.Stroke.DrawingAttributes.RasterOperation == RasterOperation.CopyPen))
        {
            e.Stroke.ExtendedProperties.Add(FILL, true);
            inkPanel.Invalidate(); // TODO: Efficient invalidation
        }
    }
}

In order to paint the fill straight away we invalidate the paint surface. Note that we're not being very efficient by invalidating the whole panel and in a future post I'll cover the right way to do this but I've left it out since it would distract from the topic at hand.

Rendering
Now we want to perform the actual painting of the fill; the most obvious technique might be to subscribe to the Painting event of the InkOverlay but the problem with painting the fill on this event is that any fill will be performed before any strokes are rendered so even though multiple fills will overlay correctly, the owning strokes will all render above the fills. Similarly with handling the InkOverlay.Painted event, the corresponding strokes would all be rendered below the fills. Clearly we need to render each stroke and associated fill together so we retain the correct visual Z-order. This makes things a little more tricky because it means we have to manually render each stroke including accounting for strokes that are in a selected state.

To render manually, we handle the Paint event of the control our InkOverlay is attached to and tell the InkOverlay that we're handling any redrawing by setting InkOverlay.AutoRedraw to false when the InkOverlay is first constructed.

Notice in the paint event handler below we test if the stroke has been deleted before rendering it. This is because InkOverlay.Ink.Strokes will return a Strokes object which is a collection of references to the real strokes contained within the Ink object. You can think of the Strokes object as a snapshot in time of the current collection and that while we're iterating over our snapshot, the real strokes may get deleted from the Ink object, so our references might point to deleted strokes. Fortunately the Stroke class has a Deleted property for just this reason. The bad news is that there is still the slight chance of a race condition because ink may be deleted on the inking thread after you test this property so the only way to fully account for any thread issues is to wrap the operation in a try/catch and eat any InvalidOperationExceptions.

 private void OnPanel_Paint(object sender, System.Windows.Forms.PaintEventArgs e)
{
    if (inkOverlay.Ink.Strokes.Count > 0)
    {
        foreach (Stroke s in inkOverlay.Ink.Strokes)
        {
            if (!s.Deleted)
            {
                try
                {
                    // Paint the fill for strokes tagged as such
                    if (s.ExtendedProperties.Contains(FILL))
                    {
                        // Obtain our polygon points and convert them to pixels
                        Point[] points = s.GetPoints();
                        inkOverlay.Renderer.InkSpaceToPixel(e.Graphics, ref points);
                        e.Graphics.FillPolygon(Brushes.Purple, points);
                    }

                    // Render selected strokes
                    if (inkOverlay.Selection.Contains(s))
                    {
                        // Calculate selected ink thickness
                        Point ptThickness = new Point(4, 4);
                        inkOverlay.Renderer.PixelToInkSpace(e.Graphics, ref ptThickness);

                        // Copy the DrawingAttributes since we’ll be modifying them
                        DrawingAttributes da = s.DrawingAttributes.Clone();

                        // Turn off pressure so thickness draws uniformly
                        da.IgnorePressure = true;

                        //
                        // Draw the thick outer stroke - the "outline"
                        //
                        da.Width  += ptThickness.X;
                        da.Height += ptThickness.Y;
                        if (!s.Deleted)
                            inkOverlay.Renderer.Draw(e.Graphics, s, da);

                        //
                        // Draw the inner "transparent" stroke
                        //
                        da.Width  -= ptThickness.X;
                        da.Height -= ptThickness.Y;
                        da.Color   = Color.White;

                        // Change rasterop for highlighter strokes:
                        da.RasterOperation = RasterOperation.CopyPen;

                        if (!s.Deleted)
                            inkOverlay.Renderer.Draw(e.Graphics, s, da);
                    }
                    else
                    {
                        // Render unselected strokes
                        if (!s.Deleted)
                            inkOverlay.Renderer.Draw(e.Graphics, s);
                    }
                }
                catch(InvalidOperationException)
                {
                    // Eat exceptions caused by deleted stroke race condition
                }
            }
        }
    }
}

Fill Stroke Image

Happy stroke filling!