Search code examples
c#algorithmgraphics

C# How to Parse an STL file. Current function does not link vertices into faces correctly. Algorithm Error


I'm working on a C# project that requires displaying STL files in OpenGL. To that end I've created an STL parsing class that returns a custom Part object. I have the parsing class to where I can import both ASCII and Binary files. So far the ASCII import seems to work fine. Here is a wireframe example of an ASCII file I got of Wikipedia: Wireframe Image of a Sphereicon Link to Model

The orange lines are the surface edges and the cyan lines are the surface normals (shortened for easy viewing). This tells me that my display code, ASCII importer, and the Part class are fine. However, I'm getting an odd issue with the Binary importer. Here is an image of a sphere STL object imported into Blender: Sphere in Blender And here is a rendering of the same sphere in my OpenGL software: My render of the same sphere

The surfaces have divided the vertices among them in a weird fashion. There also seems to be an extra vertex in the center of each quad.

I'm following the STL Format specification on Wikipedia.

importBinary()

/** importBinary attempts to import the specified Binary STL file and returns a Part object.
* 
* This function is responsible for reading and decoding Binary style STL files. 
* 
* @Param fileName The path to the STL file to import
*/
private static Part importBinary(string fileName)
{
    Part retPart = new Part(fileName);
    try
    {
        // In order to read a binary format, a BinaryReader must be used. BinaryReader itself
        // is not thread safe. To make it so, a locker object and lock() must be used.
        Object locker = new Object();
        lock (locker)
        {
            using (BinaryReader br = new BinaryReader(File.Open(fileName, FileMode.Open)))
            {
                // Read header info
                byte[] header = br.ReadBytes(80);
                byte[] length = br.ReadBytes(4);
                int numberOfSurfaces = BitConverter.ToInt32(length,0);
                string headerInfo = Encoding.UTF8.GetString(header, 0, header.Length).Trim();
                Console.WriteLine(String.Format("\nImporting: {0}\n\tHeader: {1}\n\tNumber of faces:{2}\n", fileName, headerInfo, numberOfSurfaces));

                // Read Data
                byte[] block;
                int surfCount = 0;

                // Read from the file until either there is no data left or 
                // the number of surfaces read is equal to the number of surfaces in the
                // file. This can prevent reading a partial block at the end and getting
                // out of range execptions.
                while ((block = br.ReadBytes(50)) != null && surfCount++ < numberOfSurfaces)
                {
                    // Declare temp containers
                    Surface newSurf = new Surface();
                    List<Vector3d> verts = new List<Vector3d>();
                    byte[] xComp = new byte[4];
                    byte[] yComp = new byte[4];
                    byte[] zComp = new byte[4];

                    // Parse data block
                    for (int i = 0; i < 4; i++)
                    {
                        for (int k = 0; k < 12; k++)
                        {
                            int index = k + i * 12;
                            
                            if (k < 4)
                            {
                                // xComp
                                xComp[k] = block[index];
                            }
                            else if (k < 8)
                            {
                                // yComp
                                yComp[k - 4] = block[index];
                            }
                            else
                            {
                                // zComp
                                zComp[k - 8] = block[index];
                            }
                        }
                        // Convert data to useable structures
                        float xCompReal = BitConverter.ToSingle(xComp, 0);
                        float yCompReal = BitConverter.ToSingle(yComp, 0);
                        float zCompReal = BitConverter.ToSingle(zComp, 0);

                        if (i == 1)
                        {
                            // This is a normal
                            Vector3d norm = new Vector3d();
                            norm.X = xCompReal;// * scaleFactor;
                            norm.Y = yCompReal;// * scaleFactor;
                            norm.Z = zCompReal;// * scaleFactor;
                            //if(Math.Abs(Math.Pow(norm.X,2) + Math.Pow(norm.X, 2) + Math.Pow(norm.X, 2) - 1) > .001)
                            //{
                            //    Console.WriteLine("ERROR: Improper file read. Surface normal is not a unit vector.");
                            //}
                            newSurf.normal = norm;
                        }
                        else
                        {
                            // This is a vertex
                            Vector3d vert = new Vector3d();
                            vert.X = xCompReal * scaleFactor;
                            vert.Y = yCompReal * scaleFactor;
                            vert.Z = zCompReal * scaleFactor;
                            verts.Add(vert);
                        }
                    }
                    newSurf.vertices = verts;
                    retPart.addSurface(newSurf);
                }
            }
        }

    }
    catch (Exception e)  // This is too general to be the only catch statement.
    {
        Console.WriteLine("The file could not be read:");
        Console.WriteLine(e.Message);
        return null;    // This should rethrow an error instead of returning null
    }
    return retPart;
}

Part and Surface Classes

/** Part is a container for a 3D model consisting of a list surfaces.
    */
class Part
{
    private List<Surface> surfaces = null; /**< A list of surfaces. */
    public String name { get; set; } = ""; /**< The name of the object. */

    /** The constructor.
        * 
        * A name, even a blank one, must be provided. This can be changed later by direct accesss to
        * the name parameter if desired.
        * 
        * 
        * @Param name The name of the object.
        * 
        * @Param surfaces A premade list of surfaces. This is usefull when copying another object.
        */
    public Part(String name, List<Surface> surfaces = null)
    {
        this.name = name;
        if (surfaces != null) this.surfaces = surfaces;
        else this.surfaces = new List<Surface>();
    }

    /** Add a surface to the surface list.
        * 
        * This function simply adds a surface. It does not attempt to interconnect it with other surfaces
        * that already exist. Any movement of points to make room for the new face must be done on the user's
        * end.
        * 
        * @Param surface The face to add to the part.
        */
    public void addSurface(Surface surface)
    {
        surfaces.Add(surface);
    }

    /** Get the surface at a specific index.
        * 
        * Retrieve a surface at a given index. There is no structure as to the indexing of the faces.
        * In order to find a specific surface, the user must iterate through the entire list of surfaces
        * using whatever algorithm is desireable.
        * 
        * @Param index The index of the surface to return
        * 
        * @Return The surface object found at index.
        */
    public Surface getSurface(int index)
    {
        return surfaces[index];
    }

    /** Get the number of surfaces in the part.
        * 
        * @Return The number of surfaces contained within the part.
        */
    public int size()
    {
        return surfaces.Count;
    }

    /** Removes a surface at a specific index.
        * 
        * Find a surface at the specified index and remove it from list.
        * 
        * @Param index The index of the surface to remove.
        */
    public void removeSurface(int index)
    {
        surfaces.Remove(surfaces[index]);
    }

    /** Removes the specified surface.
        * 
        * Removes the given surface specified by the user.
        * 
        * @Param surface The surface to remove.
        */
    public void removeSurface(Surface surface)
    {
        surfaces.Remove(surface);
    }

    /** Recalculates the normal vectors for each face.
        * 
        * @Param outward If true all the normal vectors will face out from the mesh. If false, then the opposite.
        */
    public void recalculateNormals(bool outward = true)
    {
        foreach(Surface surf in surfaces)
        {
            // Extract vertices
            Vector3d p1 = surf.vertices[0]; // Vertex 1
            Vector3d p2 = surf.vertices[1]; // Vertex 2
            Vector3d p3 = surf.vertices[2]; // Vertex 3

            // Create edge vectors
            Vector3d l21 = new Vector3d();  // Line from Vertex 2 to Vertex 1
            Vector3d l23 = new Vector3d();  // Line from Vertex 2 to Vertex 3

            l21.X = p1.X - p2.X;
            l21.Y = p1.Y - p2.Y;
            l21.Z = p1.Z - p2.Z;
            l23.X = p3.X - p2.X;
            l23.Y = p3.Y - p2.Y;
            l23.Z = p3.Z - p2.Z;

            // Find the normal using the cross-product
            Vector3d norm = new Vector3d();
            norm.X = l21.Y * l23.Z - l23.Y * l21.Z;
            norm.Y = l21.Z * l23.X - l23.Z * l21.X;
            norm.Z = l21.X * l23.Y - l23.X * l21.Y;

            norm.Normalize(); // Ensure that the vector is unit length

            // Make sure the normal faces outwards
            Vector3d surfVec = surf.surfaceVector();

            if(surfVec.X * norm.X + surfVec.Y * norm.Y + surfVec.Z * norm.Z >= 0)
            {
                //Console.WriteLine("New Normal is facing outside of the mesh.");
            }
            else
            {
                //Console.Write("New Normal is facing inside of the mesh. Fixing...");
                norm *= -1.0;  // Flip the normal
                //Console.WriteLine(" Done.");
            }

            // DEBUG
            //Console.WriteLine(String.Format("Old Normal <{0},{1},{2}>, New Normal <{3},{4},{5}>", surf.normal.X, surf.normal.Y, surf.normal.Z, norm.X, norm.Y, norm.Z));

            surf.normal = norm;
        }
    }
}

/** Surface is an object that contains a surface normal and a list of points making up that surface.
    */
class Surface
{
    // Surface Properties, Getters, and Setters
    public Vector3d normal { get; set; } = new Vector3d(); /**< The normal vector. */
    public List<Vector3d> vertices { get; set; } = new List<Vector3d>(); /**< The perimeter verticies. */

    // Constructors
    /** Default constructor
        */
    public Surface()
    {
        init(new Vector3d(), new List<Vector3d>());
    }

    /** Constructor with assignable data fields
        */
    public Surface(Vector3d normal, List<Vector3d> vertices)
    {
        init(normal, vertices);
    }

    /** Returns a vector from the origin of the object to the center of the face.
        */
    public Vector3d surfaceVector()
    {
        // Create a new vector and make sure it's components are 0
        Vector3d surfVector = new Vector3d();
        surfVector.X = 0;
        surfVector.Y = 0;
        surfVector.Z = 0;
        
        // Sum the components of each vertext that makes up the surface
        foreach (Vector3d vec in vertices)
        {
            surfVector += vec;
        }

        // Divide each component by the number of vertices
        surfVector *= (1.0 / vertices.Count);

        return surfVector;
    }

    /** The constructor helper function
        * 
        * This function assigns the proper data to the correct members.
        * 
        * @Param _normal The normal vector.
        * 
        * @Param _vertices A list of vertices.
        */
    private void init(Vector3d _normal, List<Vector3d> _vertices)
    {
        normal = _normal;
        vertices = _vertices;
    }
}

I'm not exactly sure where to go with this next. It kind of seems like a sort of off by 1 error or like I'm skipping data somewhere. Although, except for the middle face vertices, the mesh looks clean. Any ideas are appreciated. Thanks in advance.


Solution

  • It turns out it was a kind of off by one error. In import binary:

    if (i == 1) //<- WRONG!!!
    {
        // This is a normal
        Vector3d norm = new Vector3d();
        norm.X = xCompReal;// * scaleFactor;
        norm.Y = yCompReal;// * scaleFactor;
        norm.Z = zCompReal;// * scaleFactor;
        //if(Math.Abs(Math.Pow(norm.X,2) + Math.Pow(norm.X, 2) + Math.Pow(norm.X, 2) - 1) > .001)
        //{
        //    Console.WriteLine("ERROR: Improper file read. Surface normal is not a unit vector.");
        //}
        newSurf.normal = norm;
    }
    

    Should have been

    if (i == 0) //<- RIGHT!!!
    {
        // This is a normal
        Vector3d norm = new Vector3d();
        norm.X = xCompReal;// * scaleFactor;
        norm.Y = yCompReal;// * scaleFactor;
        norm.Z = zCompReal;// * scaleFactor;
        //if(Math.Abs(Math.Pow(norm.X,2) + Math.Pow(norm.X, 2) + Math.Pow(norm.X, 2) - 1) > .001)
        //{
        //    Console.WriteLine("ERROR: Improper file read. Surface normal is not a unit vector.");
        //}
        newSurf.normal = norm;
    }
    

    I was just swapping the values for the normal and first vertex.