Sunday, March 26, 2017

Make a 3D cylinder the easy way

To make a 3D cylinder in a WPF application, you could use a 3D model. However in this post I describe a quick alternative: With a progress bar, a adorner and an opacity mask you can get the same effect using only two dimensions.

When deciding to make a vertical cylinder I decided to try to do so by subclassing a progress bar. This offers easy access to the maximum, minimum and value properties, enabling the making of cylinders with arbitrary length.
I updated the appearance of the progress bar in three steps:

1- Paint over the solid color of the bar with a gradient brush to simulate a lightsource
2- Hide some of the bottom of the progress bar to make it look round. This is done using the property OpacityMask
3- Paint an ellipse on the top of the cylinder.

The result looks like this:


Applying the effects to the cylinders is done with an adorner. After the control is loaded, the function AddDecorator gets the adornerlayer for the control, and adds an instance of cylinderadorner to it:

public class Cylinder : ProgressBar
{
 public Cylinder()
  {
  this.Loaded += AddDecorator;
  Background = null;
  if (Foreground as SolidColorBrush is null)
   {
   throw new ArgumentException("Exception: Foreground of cylinder  must be a solid color");
   }
  }

 private void AddDecorator(object sender, RoutedEventArgs e)
  {
  AdornerLayer a = AdornerLayer.GetAdornerLayer(this);
  a.Add(new CylinderAdorner(this));
  }
}
 

Cylinderadorner is a class which inherits from Adorner. When the control is painted, the adorner is painted on top of it by OnRender.
Another example of this can be found Here.


public class CylinderAdorner : Adorner
 {
 //store two variables needed more than once
 private Cylinder cylinder;
 private PathGeometry pathgeometry;
 const int sizeFactor = 4; //ratio of width vs height used for ellipses

 public CylinderAdorner(UIElement element) : base(element)
  {
  cylinder = this.AdornedElement as Cylinder;
  }

 protected override void OnRender(DrawingContext drawingContext)
  {
  //get outline of cylinder, used by all three next functions
  pathgeometry = getCylinderOutline();

  //overlay gradientbrush to simulate 3D shape
  overlayGradient(drawingContext);

  //Now hide the bottom / right of cylinder with an opacitymask    
  cylinder.OpacityMask = getOpacityMask();

  //place ellipse on top of cylinder
  placeEllipse(drawingContext);
  }
       ...

OverlayGradient draws a white and black shape over the progress bar. The shape of this is a cylinder with a rounded bottom, as tall as the "value" part of the progress bar.

GetOpacityMask uses the same shape, filled with a black brush, to only show this part of the bar.

Finally placeEllipse draws the top of the cylinder. It also uses pathgeometry to get the dimensions of the ellipse: pathgeometry contains a RECT poperty called Bounds which contains the outer dimensions.

The work is done by the other members of CylinderAdorner:
public class CylinderAdorner : Adorner
{
//store for two variables needed more than once
private Cylinder cylinder;
private PathGeometry pathgeometry;
const int sizeFactor = 4; //ratio of width vs height used for ellipses

public CylinderAdorner(UIElement element) : base(element)
 {
 cylinder = this.AdornedElement as Cylinder;
 }

protected override void OnRender(DrawingContext drawingContext)
 {
 //get outline of cylinder, used by both PaintGradient and getOpacityMask
 pathgeometry = getCylinderOutline();

 //overlay gradientbrush to simulate 3D shape
 overlayGradient(drawingContext);

 //Now hide the bottom / right of cylinder with an opacitymask    
 cylinder.OpacityMask = getOpacityMask();

 //place ellipse on top of cylinder
 placeEllipse(drawingContext);
 }

 private Rect getCylinderOutlineRect(Orientation orientation)
  {
  Rect rect = new Rect();
  if (cylinder.Orientation == Orientation.Vertical)
   {
   //creatre rectangle on the bottom part of progress bar, from the bottom of the cylinder up to value
   rect.Width = cylinder.ActualWidth;
   rect.Y = cylinder.ActualHeight - ((cylinder.Value * cylinder.ActualHeight) / (cylinder.Maximum - cylinder.Minimum));
   rect.Height = cylinder.ActualHeight - rect.Y;
   }
  else
   {
   //create rectangle on the left of the progress bar, with width equivalent to value
   rect.Height = cylinder.ActualHeight;
   rect.Width = (cylinder.Value * cylinder.ActualWidth) / (cylinder.Maximum - cylinder.Minimum);
   }
  return rect;
  }

 /// <summary>
 /// Create a PathFigure with the outline of a cylinder with rounded bottom
 /// Start drawing from top right corner clockwise
 /// </summary>
 /// <returns>Outline of a cylinder</returns>
 private PathGeometry getCylinderOutline()
  {
  List<PathSegment> pathSegments = new List<PathSegment>();

  //get rect around the straight part of the cylinder
  Rect rect = getCylinderOutlineRect(cylinder.Orientation);

  if (cylinder.Orientation == Orientation.Vertical)
   {
   //draw line to bottom right corner, arc to bottom left, line to top left:
   pathSegments.Add(new LineSegment(new Point(rect.Width, rect.Bottom - (rect.Width / sizeFactor)), false));
   pathSegments.Add(new ArcSegment(new Point(0, rect.Bottom - (rect.Width / sizeFactor)), new Size(rect.Width / 2, rect.Width / sizeFactor), 0, false, SweepDirection.Clockwise, false));
   pathSegments.Add(new LineSegment(new Point(0, rect.Top), false));
   }
  else
   {
   //draw line to bottom right, line to bottom left, arc to top left:
   pathSegments.Add(new LineSegment(new Point(rect.Width, rect.Height), false));
   pathSegments.Add(new LineSegment(new Point(rect.Height / sizeFactor, rect.Height), false)); // stop some distance short of left side
   pathSegments.Add(new ArcSegment(new Point(rect.Height / sizeFactor, 0), new Size(rect.Height / sizeFactor, rect.Height / 2), 0, false, SweepDirection.Clockwise, false));
   }

  //drawing starts at top right:
  PathFigure pf = new PathFigure(new Point(rect.Width, rect.Top), pathSegments, true);
  return new PathGeometry(new List<PathFigure> { pf });
  }

 /// <summary>
 /// Draw a lineair gradient brush over the cylinder to create 3D effect
 /// Start and end points are relative, from 0 to 1. 0.5 is half the dimension of the control
 /// </summary>
 /// <param name="drawingContext"></param>
 private void overlayGradient(DrawingContext drawingContext)
  {
  LinearGradientBrush lbg = new LinearGradientBrush();
  if (cylinder.Orientation == Orientation.Vertical)
   {
   lbg.StartPoint = new Point(0, 0.5);
   lbg.EndPoint = new Point(1, 0.5);
   }
  else
   {
   lbg.StartPoint = new Point(0.5, 0);
   lbg.EndPoint = new Point(0.5, 1);
   }
  lbg.GradientStops.Add(new GradientStop(Colors.White, 0));
  lbg.GradientStops.Add(new GradientStop((cylinder.Foreground as SolidColorBrush).Color, 0.3));
  lbg.GradientStops.Add(new GradientStop(Colors.Black, 0.7));
  lbg.Opacity = 0.25;

  drawingContext.DrawGeometry(lbg, null, pathgeometry);
  }

 /// <summary>
 /// Create an opacitymask composed of a rectangle and a circle,
 /// to round to bottom end of the progress bar
 /// </summary>
 /// <param name="radius">Radius of the circle to use</param>
 public Brush getOpacityMask()
  {
  GeometryDrawing maskDrawing = new GeometryDrawing(Brushes.Black, null, pathgeometry);
  DrawingBrush drawingBrush = new DrawingBrush
   {
   Drawing = maskDrawing,
   Stretch = Stretch.None,
   ViewboxUnits = BrushMappingMode.Absolute,
   AlignmentX = AlignmentX.Left,
   AlignmentY = AlignmentY.Top,
   };
  return drawingBrush;
  }

 /// <summary>
 /// Determine size and place of an ellipse,
 /// it goes on top of the cylinder.
 /// </summary>
 /// <param name="origin">Center of the ellipse</param>
 /// <param name="radius">Width and height of the circle</param>
 private void placeEllipse(DrawingContext drawingContext)
  {
  Point origin = new Point();
  Vector radius = new Vector();

  if (cylinder.Orientation == Orientation.Vertical)
   {
   radius.X = pathgeometry.Bounds.Width / 2;
   radius.Y = pathgeometry.Bounds.Width / sizeFactor;

   origin.X = pathgeometry.Bounds.Width / 2;
   origin.Y = pathgeometry.Bounds.Top;
   }
  else
   {
   radius.X = pathgeometry.Bounds.Height / sizeFactor;
   radius.Y = pathgeometry.Bounds.Height / 2;

   origin.Y = pathgeometry.Bounds.Height / 2;
   origin.X = cylinder.Value / (cylinder.Maximum - cylinder.Minimum) * cylinder.ActualWidth;
   }

  //Paint ellipse on control
  drawingContext.DrawEllipse(cylinder.Foreground, new Pen
   (new SolidColorBrush(Colors.Black), 0.5), origin, radius.X, radius.Y);
  }
 }

As it turns out, this code works quite nicely. There is one caveat though: If you give the control a margin, and make the window small enough for the control to be clipped, the adorner is not clipped with it.
And while this is not a problem in practice when showing a full cylinder, I am still making a proper 3D cylinder the next time around, as shown here.

Want to check out the code for yourself?
download 3D Cylinder.zip from Sabercat File Hosting



No comments:

Post a Comment