2

I'm trying to implement Newell's Method to calculate the surface normal vector in Python, based on the following pseudocode from here.

Begin Function CalculateSurfaceNormal (Input Polygon) Returns Vector

   Set Vertex Normal to (0, 0, 0)

   Begin Cycle for Index in [0, Polygon.vertexNumber)

      Set Vertex Current to Polygon.verts[Index]
      Set Vertex Next    to Polygon.verts[(Index plus 1) mod Polygon.vertexNumber]

      Set Normal.x to Sum of Normal.x and (multiply (Current.y minus Next.y) by (Current.z plus Next.z))
      Set Normal.y to Sum of Normal.y and (multiply (Current.z minus Next.z) by (Current.x plus Next.x))
      Set Normal.z to Sum of Normal.z and (multiply (Current.x minus Next.x) by (Current.y plus Next.y))

   End Cycle

   Returning Normalize(Normal)

End Function

Here's my code:

Point3D = collections.namedtuple('Point3D', 'x y z')

def surface_normal(poly):
    n = [0.0, 0.0, 0.0]

    for i, v_curr in enumerate(poly):
        v_next = poly[(i+1) % len(poly)]
        n[0] += (v_curr.y - v_next.y) * (v_curr.z - v_next.z)
        n[1] += (v_curr.z - v_next.z) * (v_curr.x - v_next.x)
        n[2] += (v_curr.x - v_next.x) * (v_curr.y - v_next.y)

    normalised = [i/sum(n) for i in n]

    return normalised

def test_surface_normal():
    poly = [Point3D(0.0, 0.0, 0.0),
            Point3D(0.0, 1.0, 0.0),
            Point3D(1.0, 1.0, 0.0),
            Point3D(1.0, 0.0, 0.0)]

    assert surface_normal(poly) == [0.0, 0.0, 1.0]

This fails at the normalisation step since the n at that point is [0.0, 0.0, 0.0]. If I'm understanding correctly, it should be [0.0, 0.0, 1.0] (confirmed by Wolfram Alpha).

What am I doing wrong here? And is there a better way of calculating surface normals in python? My polygons will always be planar so Newell's Method isn't absolutely necessary if there's another way.

Jamie Bull
  • 12,889
  • 15
  • 77
  • 116

3 Answers3

2

Ok, the problem was actually a stupid one.

The lines like:

n[0] += (v_curr.y - v_next.y) * (v_curr.z - v_next.z)

should be:

n[0] += (v_curr.y - v_next.y) * (v_curr.z + v_next.z) 

The values in the second set of brackets should be added, not subtracted.

Jamie Bull
  • 12,889
  • 15
  • 77
  • 116
1

If you want an alternate approach to Newell's Method you can use the Cross-Product of 2 non-parallel vectors. That should work for just about any planar shape you'd provide it. I know the theory says it is for convex polygons, but examples we've looked at on Wolfram Alpha return an appropriate surface normal for even concave polygons (e.g. a bowtie polygon).

dblclik
  • 406
  • 2
  • 8
  • Yes, I get the divide by zero - because I'm trying to normalise `[0,0,0]` which isn't a valid vector. That shouldn't be the value at that point in the code. And by planar, I mean the first one. A 2D polygon in 3D space. – Jamie Bull Aug 17 '16 at 16:31
  • Okay, I got that too (I avoided it by using this--`normalised = [i/sum(n) if sum(n)!=0.0 else 0 for i in n]`--just to get past it). Will your polygons always be convex, or is it possible to have ones that are not? – dblclik Aug 17 '16 at 16:36
  • They could be any shape, though not self-intersecting. And your workaround might fail, for example with the vector `[-0.5, 0.0, 0.5]`. I was using `try: except:` block but it shouldn't be necessary if the algorithm is working correctly. – Jamie Bull Aug 17 '16 at 16:39
  • I'm still going to toy with getting this to work, but your point about failing for some cases is correct. This should work in all cases, just to have in your code as quick avoidance to Div-by-Zero: `normalised = [i/sum(n) if len(list(set(n)))!=1 or sum(n)!=0 else 0.0 for i in n]` – dblclik Aug 17 '16 at 16:54
  • What's wrong with a try, except? It's more readable and avoids testing as it's not necessary. – Jamie Bull Aug 17 '16 at 16:58
  • 1
    I saw your EDIT, but wanted to make one call out, JIC. I was going to recommend the Cross Product approach but I believe it only works for convex polygons, which is why I asked about that above. You may want to test it on a concave polygon to make sure you're not going to get an error in those cases. – dblclik Aug 17 '16 at 17:00
  • Nothing's wrong with a try, except :) I use them all of the time! I was just giving a one line solution to fit your existing code format – dblclik Aug 17 '16 at 17:01
  • Let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/121193/discussion-between-jamie-bull-and-dblclik). – Jamie Bull Aug 17 '16 at 17:10
1

I thought it worth clarifying a few points about the informative implementation of Newell's method above. On first reading https://stackoverflow.com/users/1706564/jamie-bull update I thought the change of sign referred to the first equation only. But in fact it is all of them. Second, to provide a comparison to cross-product method. Finally, deal with divide by zero issue - but could just divide by epsilon. I also used numpy instead.

import numpy as np

def surface_normal_newell(poly):

    n = np.array([0.0, 0.0, 0.0])

    for i, v_curr in enumerate(poly):
        v_next = poly[(i+1) % len(poly),:]
        n[0] += (v_curr[1] - v_next[1]) * (v_curr[2] + v_next[2]) 
        n[1] += (v_curr[2] - v_next[2]) * (v_curr[0] + v_next[0])
        n[2] += (v_curr[0] - v_next[0]) * (v_curr[1] + v_next[1])

    norm = np.linalg.norm(n)
    if norm==0:
        raise ValueError('zero norm')
    else:
        normalised = n/norm

    return normalised


def surface_normal_cross(poly):

    n = np.cross(poly[1,:]-poly[0,:],poly[2,:]-poly[0,:])

    norm = np.linalg.norm(n)
    if norm==0:
        raise ValueError('zero norm')
    else:
        normalised = n/norm

    return normalised


def test_surface_normal3():
    """ should return:

        Newell:
        Traceback (most recent call last):
          File "demo_newells_surface_normals.py", line 96, in <module>
            test_surface_normal3()
          File "demo_newells_surface_normals.py", line 58, in test_surface_normal3
            print "Newell:", surface_normal_newell(poly) 
          File "demo_newells_surface_normals.py", line 24, in surface_normal_newell
            raise ValueError('zero norm')
        ValueError: zero norm
    """
    poly = np.array([[1.0,0.0,0.0],
                     [1.0,0.0,0.0],
                     [1.0,0.0,0.0]])
    print "Newell:", surface_normal_newell(poly) 


def test_surface_normal2():
    """ should return:

        Newell: [ 0.08466675 -0.97366764 -0.21166688]
        Cross : [ 0.08466675 -0.97366764 -0.21166688]
    """
    poly = np.array([[6.0,1.0,4.0],
                     [7.0,0.0,9.0],
                     [1.0,1.0,2.0]])
    print "Newell:", surface_normal_newell(poly)
    print "Cross :", surface_normal_cross(poly)


def test_surface_normal1():
    """ should return:

        Newell: [ 0.  0. -1.]
        Cross : [ 0.  0. -1.]
    """
    poly = np.array([[0.0,1.0,0.0],
                     [1.0,1.0,0.0],
                     [1.0,0.0,0.0]])
    print "Newell:", surface_normal_newell(poly) 
    print "Cross :", surface_normal_cross(poly)


print "Test 1:"
test_surface_normal1()
print "\n"

print "Test 2:"
test_surface_normal2()
print "\n"

print "Test 3:"
test_surface_normal3()
fink-nottle
  • 221
  • 2
  • 6
  • I'm trying to convert surface_normal_cross function to PHP. so far your implementation uses non-standard logic (meaning built-in functions in python) I can't understand it. Is it possible to add more info.. – Desolator Nov 30 '19 at 21:48