Neo
Neo

Reputation: 817

How to draw a point in 3d coordinates on C# WinForms?

I want to have points drawn in runtime in a WinForms application. How do I do that?

Upvotes: 1

Views: 2473

Answers (1)

John Alexiou
John Alexiou

Reputation: 29244

The simple answer

You need to implement some kind of projective transformation to convert each Vector3 from (X,Y,Z) coordinates to PointF with (X,Y) coordinates.

On each Paint event, move the origin to the center of the drawing surface and project the 3D points with some math as follows

g.TranslateTransform(ClientSize.Width/2, ClientSize.Height/2);
pixel.X = scale * vector.X / (camera.Z -vector.Z);
pixel.Y = -scale * vector.Y / (camera.Z -vector.Z);
g.DrawEllipse(Pens.Black, pixel.X-2, pixel.Y-2, 4,4);

Where g is the Graphics object passed from the Paint event. The reason for the negative for the Y coordinate is because in WinForms positive Y is downwards, and for 3D graphics it makes sense to objey the right hand rules and have Y pointing upwards. The DrawEllipse method draws a little circle where the point is. Use FillEllipse to fill in the circle instead if you want.


The detailed answer

I have a sample project on GitHub on simple rendering of 3D geometry in Winforms.

fig1

There are other parts to the sample you can ignore, but I will explain the process I came up to render simple 3D objects on a WinForms control.

  1. PictureBox is where the target control for the rendering. This is the only requirement for the form. To have Control placed for things to show on. PictureBox is convenient, and it supports double buffering on the get-go.

  2. Camera is a class that does the rendering. It is responsible for the following tasks.

    • Reference to the target control and handling the Paint event.
    • Handling the math for projecting 3D points into pixels.
    • Checks if an object is visible (backface culling).
    • Configure the Graphics object before rendering on screen.
    • Handling any mouse events if needed.
    • Define the 3D viewpoint properties.

    A simplified version of my Camera class is below. The things to look into is the Project() methods that take up geometry Vector3 objects and return PointF objects.

    using static SingleConstants;
    
    public delegate void CameraPaintHandler(Camera camera, Graphics g);
    
    public class Camera
    {
        public event CameraPaintHandler Paint;
    
        /// <summary>
        /// Initializes a new instance of the <see cref="Camera" /> class.
        /// </summary>
        /// <param name="control">The target control to draw scene.</param>
        /// <param name="fov">
        /// The FOV angle (make zero for orthographic projection).
        /// </param>
        /// <param name="sceneSize">Size of the scene across/</param>
        public Camera(string name, Control control, float fov, float sceneSize = 1f)
        {
            Name = name;
            OnControl = control;
            FOV = fov;
            SceneSize = sceneSize;
            LightPos = new Vector3(0 * sceneSize, 0 * sceneSize / 2, -sceneSize);
            Orientation = Quaternion.Identity;
            Target = Vector3.Zero;
            control.Paint += (s, ev) =>
            {
                Paint?.Invoke(this, ev.Graphics);
            };
        }
    
        public GraphicsState SetupView(Graphics g, SmoothingMode smoothing = SmoothingMode.AntiAlias)
        {
            var gs = g.Save();
            g.SmoothingMode = smoothing;
            var center = ViewCenter;
            g.TranslateTransform(center.X, center.Y);
            return gs;
        }
        public Point ViewCenter => new Point(
            OnControl.Margin.Left + OnControl.ClientSize.Width/2,
            OnControl.Margin.Top + OnControl.ClientSize.Height/2);
        public string Name { get; set; }
        public Control OnControl { get; }
        public float SceneSize { get; set; }
        public float FOV { get; set; }
        public Quaternion Orientation { get; set; }
        public Vector3 LightPos { get; set; }
        public Vector3 Target { get; set; }
        public int ViewSize
        {
            get => Math.Min(
            OnControl.ClientSize.Width - OnControl.Margin.Left - OnControl.Margin.Right,
            OnControl.ClientSize.Height - OnControl.Margin.Top - OnControl.Margin.Bottom);
        }
        public int ViewHalfSize => ViewSize/2;
        public float Scale => ViewHalfSize/SceneSize;
        public float DrawSize
        {
            get => 2 * (float)Math.Tan(FOV / 2 * Math.PI / 180);
            set
            {
                FOV = 360/pi*Atan(value/2);
            }
        }
        /// <summary>
        /// Get the pixels per model unit scale.
        /// </summary>
        public Vector3 EyePos { get => Target + Vector3.Transform(Vector3.UnitZ * SceneSize / DrawSize, Quaternion.Inverse(Orientation)); }
        public float EyeDistance
        {
            get => SceneSize/DrawSize;
            set
            {
                DrawSize = SceneSize/value;
            }
        }
        public Vector3 RightDir { get => Vector3.TransformNormal(Vector3.UnitX, Matrix4x4.CreateFromQuaternion(Quaternion.Inverse(Orientation))); }
        public Vector3 UpDir { get => Vector3.TransformNormal(Vector3.UnitY, Matrix4x4.CreateFromQuaternion(Quaternion.Inverse(Orientation))); }
        public Vector3 EyeDir { get => Vector3.TransformNormal(Vector3.UnitZ, Matrix4x4.CreateFromQuaternion(Quaternion.Inverse(Orientation))); }
    
        public PointF Project(Vector3 node)
        {
            float r = 2 * (float)Math.Tan(FOV / 2 * Math.PI / 180);
            int sz = ViewHalfSize;
            float f = sz/r;
            float camDist = SceneSize / r;
            var R = Matrix4x4.CreateFromQuaternion(Orientation);
            return Project(node, f, camDist, R);
        }
    
        protected PointF Project(Vector3 node, float f, float camDist, Matrix4x4 R)
        {
            var point = Vector3.Transform(node-Target, R);
            return new PointF(
                +f  * point.X / (camDist - point.Z),
                -f  * point.Y / (camDist - point.Z));
        }
    
        public RectangleF Project(Bounds bounds)
        {
            var nodes = bounds.GetNodes();
            var points = Project(nodes);
            if (points.Length>0) {
    
                RectangleF box = new RectangleF(points[0], SizeF.Empty);
                for (int i = 1; i < points.Length; i++)
                {
                    box.X = Math.Min(box.X, points[i].X);
                    box.Y = Math.Min(box.Y, points[i].Y);
                    box.Width = Math.Max(box.Width, points[i].X-box.X);
                    box.Height = Math.Max(box.Height, points[i].Y-box.Y);
                }
                return box;
            }
            return RectangleF.Empty;
        }
    
        public PointF[] Project(Triangle triangle) => Project(new[] { triangle.A, triangle.B, triangle.C });
        public PointF[] Project(Polygon polygon) => Project(polygon.Nodes);
        /// <summary>
        /// Projects the specified nodes into a 2D canvas by applied the camera 
        /// orientation and projection.
        /// </summary>
        /// <param name="nodes">The nodes to project.</param>
        /// <returns>A list of Gdi points</returns>
        public PointF[] Project(Vector3[] nodes)
        {
            float r = 2 * (float)Math.Tan(FOV / 2 * Math.PI / 180);
            float camDist = SceneSize / r;
            float f = ViewHalfSize/r;
            var R = Matrix4x4.CreateFromQuaternion(Orientation);
    
            var points = new PointF[nodes.Length];
            for (int i = 0; i < points.Length; i++)
            {
                points[i] = Project(nodes[i], f, camDist, R);
            }
    
            return points;
        }
        /// <summary>
        /// Uses the arc-ball calculation to find the 3D point corresponding to a
        /// particular pixel on the screen
        /// </summary>
        /// <param name="pixel">The pixel with origin on center of control.</param>
        /// <param name="arcBallFactor"></param>
        public Vector3 UnProject(Point pixel, float arcBallFactor = 1)
        {
            Ray ray = CastRayThroughPixel(pixel);
            Sphere arcBall = new Sphere(Target, arcBallFactor * SceneSize/2);
            var Rt = Matrix4x4.CreateFromQuaternion(Quaternion.Inverse(Orientation));
            bool hit = arcBall.Hit(ray, out var t);
            return Vector3.Transform(ray.GetPointAlong(t), Rt);
        }
    
        public bool IsVisible(Polygon polygon)
            => polygon.Nodes.Length <3 || IsVisible(polygon.Nodes[0]-Target, polygon.Normal);
        /// <summary>
        /// Determines whether a face is visible. 
        /// </summary>
        /// <param name="position">Any position on the face.</param>
        /// <param name="normal">The face normal.</param>
        public bool IsVisible(Vector3 position, Vector3 normal)
        {
            float λ = Vector3.Dot(normal, position - EyePos);
    
            return λ < 0;
        }
    
    }
    
  3. Scene and VisibleObject. The base class for objects to be drawn is called VisibleObject. Everything you see in the screenshot above derives from a VisibleObject. This includes solids, curves and the triad of coordinates. A Scene is just a collection of VisibleObject to be drawn, and it handles the Paint event issued by the Camera. Finally it iterates through the objects and issues the command to be rendered.

    public class Scene
    {
        readonly List<VisibleObject> drawable;
    
        public Scene()
        {
            drawable = new List<VisibleObject>();
            Triad = new VisibleTriad("W");
        }
        [Category("Model")]
        public VisibleObject[] Drawable => drawable.ToArray();
        public T AddDrawing<T>(T drawing) where T : VisibleObject
        {
            drawable.Add(drawing);
            return drawing;
        }
        [Category("Model")]
        public VisibleTriad Triad { get; }
        public void Render(Graphics g, Camera camera)
        {
            var state = camera.SetupView(g);
    
            Triad.Render(g, camera, Pose.Identity);
    
            foreach (var item in drawable)
            {
                item.Render(g, camera, Pose.Identity);
            }
            Gdi.Style.Clear();
            g.Restore(state);
    
        }
    
    }
    

    and the base VisibleObject class

    public abstract class VisibleObject 
    {
        public abstract void Render(Graphics g, Camera camera, Pose pose);
    }
    

    One point to understand is that the location of each VisibleObject is not contained within. This is done so multiple copies of the same object can be drawn at various locations on the screen.

  4. Pose The 3D position and orientation of each object is defined with the Pose class, which contains a Vector3 origin, and a Quaternion orientation.

    The main functionality is in the FromLocal() and ToLoca() methods that do the local to world or reverse transformations.

    public readonly struct Pose 
        : IEquatable<Pose>
    {
        readonly (Vector3 position, Quaternion orientation) data;
        public Pose(Quaternion orientation) : this(Vector3.Zero, orientation) { }
        public Pose(Vector3 position) : this(position, Quaternion.Identity) { }
        public Pose(Vector3 position, Quaternion orientation) : this()
        {
            data = (position, orientation);
        }
        public static readonly Pose Identity = new Pose(Vector3.Zero, Quaternion.Identity);
        public static implicit operator Pose(Vector3 posiiton) => new Pose(posiiton);
        public static implicit operator Pose(Quaternion rotation) => new Pose(rotation);
        public Vector3 Position { get => data.position; }
        public Quaternion Orientation { get => data.orientation; }
    
        public Vector3 FromLocal(Vector3 position)
            => Position +  Vector3.Transform(position, Orientation);
        public Vector3[] FromLocal(Vector3[] positions)
        {
            var R = Matrix4x4.CreateFromQuaternion(Orientation);
            Vector3[] result = new Vector3[positions.Length];
            for (int i = 0; i < result.Length; i++)
            {
                result[i] = Position + Vector3.Transform(positions[i], R);
            }
            return result;
        }
        public Vector3 FromLocalDirection(Vector3 direction)
            => Vector3.Transform(direction, Orientation);
        public Vector3[] FromLocalDirection(Vector3[] directions)
        {
            var R = Matrix4x4.CreateFromQuaternion(Orientation);
            Vector3[] result = new Vector3[directions.Length];
            for (int i = 0; i < result.Length; i++)
            {
                result[i] = Vector3.TransformNormal(directions[i], R);
            }
            return result;
        }
        public Quaternion FromLocal(Quaternion orientation)
            => Quaternion.Multiply(Orientation, orientation);
        public Pose FromLocal(Pose local) 
            => new Pose(FromLocal(local.Position), FromLocal(local.Orientation));
        public Vector3 ToLocal(Vector3 position)
            => Vector3.Transform(position-Position, Quaternion.Inverse(Orientation));
        public Vector3[] ToLocal(Vector3[] positions)
        {
            var R = Matrix4x4.CreateFromQuaternion(Quaternion.Inverse(Orientation));
            Vector3[] result = new Vector3[positions.Length];
            for (int i = 0; i < result.Length; i++)
            {
                result[i] = Vector3.Transform(positions[i]-Position, R);
            }
            return result;
        }
        public Vector3 ToLocalDirection(Vector3 direction)
            => Vector3.Transform(direction, Quaternion.Inverse(Orientation));
        public Vector3[] ToLocalDirection(Vector3[] directions)
        {
            var R = Matrix4x4.CreateFromQuaternion(Quaternion.Inverse(Orientation));
            Vector3[] result = new Vector3[directions.Length];
            for (int i = 0; i < result.Length; i++)
            {
                result[i] = Vector3.TransformNormal(directions[i], R);
            }
            return result;
        }
        public Quaternion ToLocal(Quaternion orientation)
            => Quaternion.Multiply(orientation, Quaternion.Inverse(Orientation));
        public Pose ToLocal(Pose pose)
            => new Pose(ToLocal(pose.Position), ToLocal(pose.Orientation));
    
        #region Algebra
        public static Pose Add(Pose A, Pose B)
        {
            return new Pose(A.data.position+B.data.position, A.data.orientation+B.data.orientation);
        }
        public static Pose Subtract(Pose A, Pose B)
        {
            return new Pose(A.data.position+B.data.position, A.data.orientation-B.data.orientation);
        }
    
        public static Pose Scale(float factor, Pose A)
        {
            return new Pose(factor*A.data.position, Quaternion.Multiply(A.data.orientation, factor));
        }
        #endregion
    
        #region Operators
        public static Pose operator +(Pose a, Pose b) => Add(a, b);
        public static Pose operator -(Pose a) => Scale(-1, a);
        public static Pose operator -(Pose a, Pose b) => Subtract(a, b);
        public static Pose operator *(float a, Pose b) => Scale(a, b);
        public static Pose operator *(Pose a, float b) => Scale(b, a);
        public static Pose operator /(Pose a, float b) => Scale(1 / b, a);
        #endregion
    }
    
  5. Gdi is a graphics library to handle specific sub-tasks such as drawing points on the screen, drawing labels, drawing curves, and various shapes but with a specific style and handing color operations. In addition, it keeps a current Pen and SolidFill object for re-use defining the stroke and fill colors to be used in the low-level Gdi drawing operations. Some details are removed from below:

    public static class Gdi
    {
        /// <summary> 
        /// Converts RGB to HSL 
        /// </summary> 
        /// <remarks>Takes advantage of whats already built in to .NET by using the Color.GetHue, Color.GetSaturation and Color.GetBrightness methods</remarks> 
        /// <param name="color">A Color to convert</param> 
        /// <returns>An HSL tuple</returns> 
        public static (float H, float S, float L) GetHsl(this Color color)
        {
            var H = color.GetHue() / 360f;
            var L = color.GetBrightness();
            var S = color.GetSaturation();
    
            return (H, S, L);
        }
        /// <summary>
        /// Converts a color from HSL to RGB
        /// </summary>
        /// <remarks>Adapted from the algorithm in Foley and Van-Dam</remarks>
        /// <param name="hsl">The HSL tuple</param>
        /// <returns>A Color structure containing the equivalent RGB values</returns>
        public static Color GetColor(this (float H, float S, float L);
        public static Style Style { get; } = new Style();
    
        public static void DrawPoint(this Graphics g, Color color, PointF point, float size = 4f)
        {
            Style.Clear();
            Style.Fill.Color = color;
            g.FillEllipse(Style.Fill, point.X - size/2, point.Y - size/2, size, size);
        }
        public static void DrawLine(this Graphics g, Color color, PointF start, PointF end, float width = 1f)
        {
            Style.Stroke.Color = color;
            Style.Stroke.Width = width;
            g.DrawLine(Style.Stroke, start, end);
        }
        public static void DrawArrow(this Graphics g, Color color, PointF start, PointF end, float width = 1f);
        public static void DrawLabel(this Graphics g, Color color, PointF point, string text, ContentAlignment alignment, int offset = 2);
        public static void DrawPath(this Graphics g, GraphicsPath path, Color color, bool fill = true);
    
        public static void DrawCircle(this Graphics g, PointF center, float radius, Color color, bool fill = true);
        public static void DrawEllipse(this Graphics g, PointF center, float majorAxis, float minorAxis, float angle, Color color, bool fill = true);
        public static void DrawCurve(this Graphics g, PointF[] points, Color color, bool fill = true);
        public static void DrawClosedCurve(this Graphics g, PointF[] points, Color color, bool fill = true);
        public static void DrawPolygon(this Graphics g, PointF[] points, Color color, bool fill = true);
    }
    

    For example to draw a 3D point on the screen at location (100,30) with color Red you would issue code like this from a paint handler with access to a Graphics g object

    PointF point = new PointF(100,30);
    // Calls extension method `Gdi.DrawPoint()`
    g.DrawPoint(Color.Red, point, 4f); 
    

Using the above framework to draw a single point defined by a Vector3 from System.Numerics on the screen you will need the following VisibleObject derived class. The magic happens in the Render() method which uses the supplied Camera to project the 3D point into a pixel location. In addition, it defines a text label to draw next to the point.

    public class VisiblePoint : VisibleObject
    {
        public VisiblePoint(string label, Color color, float size = 4f)
        {
            Label = label;
            Color=color;
            Size=size;
        }

        public Color Color { get; }
        public float Size { get; }
        public string Label { get; }

        public override void Render(Graphics g, Camera camera, Pose pose)
        {
            var pixel = camera.Project(pose.Position);
            g.DrawPoint(Color, pixel, Size);
            if (!string.IsNullOrEmpty(Label))
            {
                g.DrawLabel(Color, pixel, Label, ContentAlignment.BottomRight);
            }
        }
    }

Upvotes: 8

Related Questions