There are some requirements for a 3D mesh to be print-ready. For example, the objects must be watertight (have no holes), have no triangles intersecting with one another, have manifold geometry (every edge belongs to exactly two faces), etc. This project currently contains code for removing self-intersections and filling holes in a 3D mesh. These two can also be used in tandem, but there are some known issues.
The project structure is like this:
- File
main.go
contains code for running repair and calling the functions. - The repair package contains:
- File
intersect.go
used for finding self-intersections. - File
holedetector.go
(written by @pouriamsz) used to find the holes. - File
liepa.go
used for filling the holes (has its own repo).
- File
- The
octree.go
package is used to subdivide the 3D space into an octree structure, used to detect intersections faster. This also has a known issue. - The
io.go
package is used to work with OBJ and STL files.
In order to repair a mesh using this project, read main.go and do the following:
- Use either
io.ReadBinarySTL
orio.ReadObj
and specify the path to your 3D mesh. - Use
repair.RemoveSelfIntersections
to remove self-intersecting triangles.- The first flag
useOctree
specifies whether to use the octree structure or not. Only use if mesh is very large. - The second flag
saveRemovedTriangles
can be set to save intersections found in folder "intrsct_obj". Use this to verify whether detected intersections are correct or not.
- The first flag
- Use
repair.FindBorders
to find the hole loops. - Use
repair.FillHoleLiepa
to fill the loops found. You can choose the triangle weight calculation method to be either "area" or "angle", based on papers by: Barequet & Sharir [1] and Peter Liepa [2], respectively.
The objective is to develop an algorithm that given the vertex coordinates of a pair of triangles, can determine whether they intersect or not.
Triangle-triangle intersections in 3D space can have different configurations. If two triangles
- Planes are equal, and one vertex from a triangle lies inside the other.
- Planes are equal, the triangles share a common edge and their other edges intersect.
- Planes are equal, and the triangles have three intersecting edges.
- Planes are equal, and the triangles are equal.
- Planes are equal, and a vertex from one triangle connects with the edge of the other.
- Planes are equal, and the triangles share a common vertex with no other intersection.
- Planes are equal, and the triangles share a common edge with no other intersection.
- Planes are equal, and the triangles share part of an edge with no other intersection.
- Planes arent equal, and the triangles intersect in a line spanning from one edge of a triangle to another edge of that triangle.
- Planes arent equal, and the triangles intersect in a line spanning from one edge of each triangle to another point inside that triangle.
- Planes arent equal, and the triangles intersect in a line spanning from one edge of each triangle to another edge of that triangle.
- Planes arent equal, and a vertex from one triangle lies inside the area of the other.
- Planes arent equal, and a vertex from one triangle connects to the edge of the other.
- Planes arent equal, and an edge from each triangle intersects with an edge of the other.
- Planes arent equal, and the triangles share a common vertex.
- Planes arent equal, and the triangles share a common edge.
- Planes arent equal, and the triangles share part of a common edge.
Figure 1 shows all the different configurations stated above for 2D intersections (1 to 8) and Figure 2 shows all the configurations for 3D intersections (9 to 17).
Figure 1: Different configurations for 2D triangle-triangle intersection.
Figure 2: Different configurations for 3D triangle-triangle intersection.
Separating Axis Theorem (SAT) is a method used to determine whether two convex objects are intersecting. The idea is that if there exists an axis for which the intervals of projection of the two convex objects onto it do not intersect, then the objects do not intersect. The axis - if it exists - is known as a separating axis. In order to search for a separating axis, and consequently determine that the objects do not intersect, we only need to test edge normals for both objects. The reason for this is that it can be proven that if there exists at least one separating axis, then there also exists an edge normal that is a separating axis [3]. Figure 3 shows how non-intersecting shapes can be separated along the found axis.
Figure 3: Left: Separation of convex polygons along the axis - Right: No possible separation [[3]](#geo).
A straightforward approach to implementing an algorithm for this method would be to consider each edge normal as a candidate axis, project every vertex onto it (simply by calculating the dot product of normal vector and vertex), find the maximum projection value from the left polygon and the minimum from the right one, compare them and return true only if there is a separation. Function Test2DIntersection
in intersect.go
implements this method.
An easy approach to solving this problem would be to reduce it to edge-face intersection checks between each edge of one triangle and the face of the other. However, Moller and Haines [4] propose a method which is also mentioned in [3]. In this method they first reject pairs of triangles whose vertices exist on one side of each other’s planes. This is done by computing the signed distance of each point to the other triangle’s plane.
Given three points in 3D space
And the point can be simply chosen to be
If the signed distances for each vertex of a triangle to the plane of the other triangle all have the same sign, then the vertices of that triangle exist on one side of the other triangle’s plane. Function TestOnOneSideOfPlane
implements this. Figure 4 shows what is meant by existing on one side of one another’s planes.
Figure 4: Triangle vertices exist on one side of each other’s planes.
By rejecting these pair of triangles, we can confirm that the line
In order to find the intersection intervals, we must first find the equation for
- By calculating the dot product between the normal vector of each plane and the point on it, we can find the Hessian Normal Form for each plane:
$$P_1:\vec{n_1} \cdot P = s_1$$ $$P_2:\vec{n_2} \cdot P = s_2$$ - Then the line equation can be found as:
Function LineFromTwoPlanes
uses these equations to find the line at which two planes intersect.
Now in order to calculate the intervals, the signed distances previously computed can be put to use. We know that one vertex of each triangle lies on the opposite side of
Where
By comparing
Figure 5: Left: Overlapping intervals. - Right: Non-overlapping intervals. [[3]](#geo)
The 3D intersection check is implemented inside the TestIntersection
function.
We consider cases (5-8) to be 2D edge cases and cases (12-17) to be 3D edge cases. That means triangle vertices are touching the other triangle (getting very close to it) without penetrating it. In a valid mesh, adjacent triangles share vertices and edges. That means cases 6, 7, 15 and 16 can exist and should not be identified as intersections. The other edge cases will be considered to be intersections. So our goal is to identify whether two triangles are in an edge case configuration and if so, are they considered to be intersections or not.
For 2D configurations, after computing the projection intervals onto the separation axis, these intervals can be compared to each other. If these intervals are touching each other – that means minimum of one interval is very close to maximum of the other interval – there is a high probability (but it’s not guaranteed) that the configuration is an edge case. This can be further confirmed by performing a point-in-polygon test.
For 3D configurations, when testing whether one triangle is on one side of the other triangle’s plane, the signed distances of the vertices are computed. Vertices that have zero distance to the other triangle’s plane may create an edge case. Those vertices, along with the vertices of the other triangle can used in a 2D point-in-polygon test to find out whether they are touching the other triangle or not.
The entire triangle-triangle intersection pipeline is explained below. You can read the TestIntersection
function for details.
- Check if triangles are degenerate. If so, exist the program.
- Compute the plane
$P_0$ for the first triangle$T_0$ . - Order the vertices of both triangles counter-clockwise rotation.
- Check if vertices from
$T_1$ are on one side of$T_0$ .- Calculate signed distances of each vertex from
$T_1$ to plane$P_0$ . - Store vertices with positive, negative and zero distances separately.
- If there is at least one positive distance vertex and at least one negative, that means the triangle exists on both sides of the plane, so it is not on one side.
- If there are three zero distance vertices, that means the triangle exists on the other triangle’s plane, so not on one side.
- Return whether the vertices are on one side + positive distance vertices + negative + zeros.
- Calculate signed distances of each vertex from
- If there are less than three zero distance vertices, that means the triangles are in 3D space, so perform a 3D edge case test:
- Set edgeCase to False and valid to True.
- If any of the vertices from
$T_1$ are inside$T_0$ , they must be equal to a vertex from$T_0$ .- For each vertex from
$T_1$ , rotate it along with all vertices from$T_0$ to be parallel to XY plane. - Drop Z values and perform point-in-polygon test. Point-in-polygon is performed by first checking whether the point is almost equal to a vertex from the polygon. Then it checks whether it lies on a horizontal edge (constant Y value). Then it checks whether it lies on a non-horizontal edge. At last, it performs the check which is similar to shooting a ray to the right of the point, counting the number of times it intersects with an edge of the polygon. If the number is even, it is outside, if odd, it is inside. Lastly, return true if any of these conditions are satisfied.
- If a point is inside the polygon, then edgeCase is set to true.
- If a point is inside the polygon, it must be equal to one of the vertices of that polygon. If this condition is false, it means it is lying somewhere on the edge or inside the area of that triangle. Therefore, set valid to false.
- For each vertex from
- Check if the edges between the positive distance and negative distance vertices are intersecting with the edges of
$T_0$ . If any of them are intersecting, then it is an edge case and it is not valid.
- If there is an edge case and it is not valid, we assume there is an intersection. If there is an edge case and it is valid, we assume the triangles do not intersect.
- If vertices from
$T_1$ are not on one side of$P_0$ , they must be ordered such that the two vertices on one side are separate from the vertex on the other side (Important for the input to 3D intersection test).- If two positive distance vertices and one negative, first return positives then negative.
- If two negative distance vertices and one positive, first return negatives then positive.
- If one positive, one negative and one zero, first return negative, then zero, then positive (not exactly like the previous two, but it still works).
- If all vertices from
$T_1$ are on one side of$T_0$ , and the triangles have not been identified as an edge case, return “no intersection”. - If there are three vertices on the plane of the other triangle, perform 2D separating axis intersection check. If the intervals are touching each other, consider it an edge case, and check if it is a valid edge case.
- For each vertex of
$T_0$ , perform point-in-polygon test against$T_1$ . If it is inside the polygon, it must be equal to a vertex from$T_1$ . If it is inside but not equal, then it is not valid. - Repeat the same for
$T_1$ .
- For each vertex of
- Handle 2D intersection as such:
- If the projected intervals are overlapping, there is an intersection in 2D.
- If the intervals are not overlapping, but touching each other (edge case), then test if it is an acceptable edge case. If acceptable = no intersection, else = intersection.
- If there exists a separating axis, then there is no intersection.
- Find plane
$P_1$ for triangle$T_1$ . Repeat steps 5, 6, 7 and 8 for vertices of$T_0$ against$P_1$ and its triangle$T_1$ . - If the algorithm reaches this part, the triangles are in 3D, they are not an edge case and they do not exist on one side of each other’s planes. Compute the intersection line of the two planes, and perform the 3D intersection test.
- Calculate the intervals that the triangles intersect with the line (
$t_{00}$ to$t_{01}$ and$t_{10}$ to$t_{11}$ ). This is done according to the equations in the previous report. - If one of the intervals has close to zero length and is almost equal to one end of the other interval, consider not intersecting (this is a special case that arises when testing many triangles).
- If the intervals are nearly touching return no intersection (we previously handled edge cases).
- If the intervals are overlapping, there is an intersection.
- Otherwise, there is no intersection.
- Calculate the intervals that the triangles intersect with the line (
To avoid the inefficiency of checking each triangle in a mesh against every other triangle (e.g., 89,075,844 checks for a 9,438-triangle mesh), an octree structure is employed. This approach reduces the number of intersection checks significantly (e.g., to 626,066 for the same mesh).
The octree divides 3D space into a box-shaped boundary. Each node is either a "leaf" node that contains objects, or it is an "inner" node that contains 8 smaller octrees. The objects within each node will be mesh triangles. You can read the implementation in octree.go
.
Implementation steps:
- Creating the octree:
- Initialize the root node as a "leaf" with a specified 3D boundary, maximum recursion level, and maximum objects per node.
- Determine the boundary for each triangle and insert it into the octree.
- Inserting objects:
- For each object to be added, find "leaf" nodes corresponding to the boundary. Insert the triangles into these nodes. Upon reaching maximum capacity, subdivide the node into 8 smaller cubes and recursively insert triangles into the correct child nodes.
- Retreiving objects:
- Recursively search for "leaf" nodes within a specified boundary.
- Return all objects within these nodes, facilitating efficient intersection checks with nearby triangles.
The automatic hole-filling algorithm of Peter Liepa [2] was employed for this part. The file liepa.go
contains this code and separate repo can be found here. The hole-filling algorithm depends on holedetector.go
to find the boundary loops. The original paper contains the complete explanation of the algorithm, but an overview is also given here:
This algorithm is based on using dynamic programming and it computes "minimum-weights" for series of ordered vertices iteratively. Note: weights are assigned to potential triangles and minumum-weights are calculated for series of vertices. The weights look something like
The weight function
- The area of the triangle (which is what Barequet and Sharir [1] use in their method).
- An ordered pair of the largest dihedral angle of the triangle with adjacent triangles + the area of the triangle (what Peter Liepa [2] uses).
The hole-filling algorithm is performed as such:
- Minimum-weights between adjacent vertices are set to be zero. Minimum-weights between vertices with 2 distance are set to be the weight of the triangle between them.
- Find the minimum-weights for series of vertices with 3 distance, then 4, 5, ... until the minimum-weight for the entire hole is found. To find
$W_{i,k}$ , change$m$ between$i$ (the starting vertex) and$k$ (the last vertex), calculate$W_{i,m}$ ,$W_{m,k}$ and the weight of the triangle between these three$\Omega(v_i, v_m, v_k)$ , and add these together. The index$m$ at which the minimum value for$W_{i,k}$ is found will be saved as$\lambda_{i,k}$ . - Repeat step 2 until
$W_{0,n-1}$ is found. - Recover the triangulation using the
$\lambda_{i,k}$ values saved. The triangulation is performed by calling the "Trace" method described by Barequet and Sharir [1].
The algorithm below is from Barequet and Sharir [1]. The notation is modified to match the previous section.
Let
Function Trace
if i + 2 = k then
else do:
- let
$o := \lambda_{i,k}$ - if
$o \not ={i+1}$ then Trace$(i,o)$ $S := S \cup \Delta v_iv_ov_k$ - if
$o \not ={k-1}$ then Trace$(o,k)$
od
This recursive method uses the minimum-weights and the indices calculated to perform triangulation.
- M. S. Gill Barequet, "Filling Gaps in the Boundary of a Polyhedron," Computer-Aided Geometric Design, pp. 207-229, 1995.
- P. Liepa, "Filling Holes in Meshes," Eurographics Symposium on Geometry Processing, 2003.
- D. E. Philip J. Schneider, "Geometric Tools for Computer Graphics," Elsevier Science Inc., 2002, pp. 265 - 271, 539 - 542, 347 - 376, 529 - 531.
- E. H. Tomas Akenine-Moller, Real-Time Rendering, Fourth Edition.
- W. MathWorld, "Point-Plane Distance," [Online]. Available: https://mathworld.wolfram.com/Point-PlaneDistance.html.