Skip to content

Creating a Golf Ball

This tutorial will go through the process of creating a non-trivial 3D model. We will explore:

  • The basic structure of the Python script.
  • Some of the capabilities of Trimesh.
  • Basic debugging
  • Exporting the created 3D mesh for use by other programs.

Let's get started!

The Scenario

You have a golf date, but have misplaced all of your golf balls. You decide to 3D print some.

Step 1: Set Up

Follow the instructions in Getting Started

Step 2: Create a Ball

The first task is to create a ball.

  1. Create a python file, and call it golf_ball.py.
    • This must be in a location that has access to wherever you installed Trimesh and SCADview in the set up.
    • If you installed system wide, then anywhere on your system is fine.
    • If you've installed in a virtual environment, then place this file in the same environment.
  2. We want to use Trimesh to create a ball, so we look at Trimesh's creation api and see icosphere which seems appropriate.
  3. Edit golf_ball.py in your favourite code editor and write:
    from trimesh.creation import icosphere
    
    
    def create_mesh():
        return icosphere()
    
  4. Let's see what it looks like. If you haven't already, run from the command line:
    scadview
    
    The SCADview UI should appear.

SCADview UI

The first time you run SCADview, it can take longer to appear, but subsequent starts should be almost instant.

  1. Click the "Load py..." button. Load Dialog
    • This opens a file dialog. Choose the file you've just created, golf_ball.py.
    • This should load the file and show you a sphere.
    • If you don't see a sphere, check the output from the command line for any error messages and edit golf_ball.py to fix them.
    • Click the "Reload" button to reload the file; if all errors are corrected, you should see the sphere.

Golf Ball Step 2

Step 3: Experiment with icosphere

Let's take a look at what options icosphere has to offer. The api docs show 3 parameters:

  • subdivisions
  • radius
  • kwargs

Let's see what subdivisions does, and then choose a good radius for our golf ball. We are going to ignore kwargs.

Subdivisions

Our plan is to put one dimple in each subdivision, so let's see what 1 subdivision looks like.

  • Change the code to set subdivisions=1 and press "Reload":

    from trimesh.creation import icosphere
    
    
    def create_mesh():
        return icosphere(subdivisions=1)
    
    Golf Ball subdivisions=1 Hmmm... looks like it could be smoother. Let's get some information about the ball. icosphere is a Trimesh, which has a wealth of attributes and methods you can use on meshes you create. Let's add a print statement to show the number of vertices and faces:
    from trimesh.creation import icosphere
    
    
    def create_mesh():
        ball = icosphere(subdivisions=1)
        print(
            f"Created ball with {len(ball.vertices)} vertices and {len(ball.faces)} faces"
        )
        return ball
    
    Note that we have now created variable, ball, so we can get some information about it before we return it as the output of the create_mesh function.

  • Hit "Reload" and check the output in the command line:

    Created ball with 42 vertices and 80 faces
    

80 faces means 80 dimples with our 1-dimple-per-face-plan. We read the "Golf ball" Wikipedia article and discover:

  • There is no limit to the number of dimples.
  • Most golf balls have 300-500 dimples.
  • The record is 1070 dimples.

So we'd like to be in the 300-500 range, let's up sudivisions=2 by editing one line:

    ball = icosphere(subdivisions=2)
Then press "Reload". Golf Ball subdivisions=2 Great, that looks smoother, and it is just what we started with in Step 2. The command line output is:
Created ball with 162 vertices and 320 faces
320 faces! We are in the 300 - 500 range already. If you like, you can also try subdivisions=3

Radius

If you look at the golf ball, you can see that it is intersecting the axes at +50 and -50, so its default radius must be about 50.

Let's check that Wikipedia article for information about the size of a golf ball. It says a golf ball must have a diameter of not less than 42.67 mm. (We will use metric measurements - but SCADview itself does not assign inches, millimeters, or any other size to the units).

Let modify that one line again and press "Reload":

    ball = icosphere(subdivisions=2, radius=42.67/2)
You should now see a resized ball. Golf Ball Resized

But it looks like the same size! This is because SCADview reframes the model when it is reloaded. If you look at the axes, you can now see that the golf ball intersects at about 20, so its radius is about 40.

Interlude: Play with the UI.

Let's see what you can do with the SCADview UI. We've already used a couple of buttons, but lets read about the user interface

Step 4: Add Dimples

Now we want to add dimples. We will add a dimple at the center of each face, sizing them somewhat smaller than the face.

Let's start by adding one dimple.

  • We don't know the right size yet, so let's start with 1 mm diameter
  • We create a icosphere of 1 mm diameter, and "subtract" (remove it) from the ball.
  • We add a line to create a dimple, and return ball.subtract(dimple)
  • Notice that we apply_translation to move the dimple to the edge of the ball, in this case to the top (the z direction is up).
from trimesh.creation import icosphere


def create_mesh():
    ball = icosphere(subdivisions=2, radius=42.67 / 2)
    print(
        f"Created ball with {len(ball.vertices)} vertices and {len(ball.faces)} faces"
    )
    dimple = icosphere(subdivisions=2, radius=1 / 2).apply_translation([0, 0, 42.67])
    return ball.subtract(dimple)

Red Screen

After pressing "Reload", something bad happens - no ball, and the screen turns red. Something when wrong!

Red Screen

The screen turning red indicates a problem with your code. Check the command line output. In this case we see:

[MainProcess 44881] ERROR scadview.ui.wx.main_frame: 
'Trimesh' object has no attribute 'subtract'

Oh - right, my fault, we used the incorrect name for "subtracting" a mesh from another. The correct name is difference, so let change the return line to:

    return ball.difference(dimple)
and press "Reload".

Great! Now the ball is showing again, the background is green (which is good).

Golf Ball No Dimple

But no dimple. You can move the camera all around but the dimple does not show anywhere. Something else is wrong. You probably saw it in the script, but let's suppose you don't know what is wrong.

Step 5 Debug

We are going to try out some of SCADview's unique debugging tools:

  • Enabling viewing multiple meshes at the same time
  • Setting colors and transparency of the meshes.

So let's try them out.

Debug Mode - Return an Array of Meshes

To enable seeing multiple meshes, for example, before we combine them, we return them in an array.

So let's:

  • Make the dimple bigger so that it is easier to see (say radius=10)
  • Comment out our return line and instead have:
        dimple = icosphere(subdivisions=2, radius=10).apply_translation([0, 42.67, 0])
        # return ball.difference(dimple)
        return [ball, dimple]
    
  • Press "Reload" and we see two balls - the smaller one a distance away from the main ball. Golf Ball Debug
  • Of course! - we moved it the full diameter instead of the radius.
  • We need to halve the diameter - that is 11.335.
        dimple = icosphere(subdivisions=2, radius=10).apply_translation([0, 11.335, 0])
    
  • Press "Reload".
  • Now the large dimple has completely disappeared! What!?! Golf Ball Debug Again

Using Color and Transparency for Debugging

Again, you probably saw how I messed up, but let's debug anyway. Once a mesh is complete, and you don't intend to perform any more operations on it, you can assign it a color and an opaqueness (alpha).

  • A color is defined by a list or tuple of 3 floats from 0.0 - 1.0, representing the red, green and blue values.
  • alpha is a value betweeh 0.0 and 1.0 as well.
    • 0.0 is completely transparent
    • 1.0 is completely opaque

So let's:

  • Make the ball red (color=[1, 0, 0]) and semi-transparent (alpha=0.5)
  • Make the dimple blue (color=[0, 0, 1]) with the same alpha.
  • To set the color, we import set_mesh_color from scadview
    from scadview import set_mesh_color
    
        set_mesh_color(ball, [1.0, 0, 0], alpha=0.5)
        set_mesh_color(dimple, [0, 0, 1.0], alpha=0.5)
    
  • Put this all together:
    from scadview import set_mesh_color
    from trimesh.creation import icosphere
    
    
    def create_mesh():
        ball = icosphere(subdivisions=2, radius=42.67 / 2)
        print(
            f"Created ball with {len(ball.vertices)} vertices and {len(ball.faces)} faces"
        )
        dimple = icosphere(subdivisions=2, radius=10).apply_translation([0, 11.335, 0])
        set_mesh_color(ball, [1.0, 0, 0], alpha=0.5)
        set_mesh_color(dimple, [0, 0, 1.0], alpha=0.5)
        # return ball.difference(dimple)
        return [ball, dimple]
    

Press "Reload". Now we can see that our dimple is inside the main ball. I shouldn't have done the math in my head! Golf Ball Transparent

Let's clean up the script a bit by giving names to some of our values. This makes the script easier to read, and easier to modify. We will add before create_mesh some "constants":

...
GOLF_BALL_RADIUS = 42.67 / 2
DIMPLE_RADIUS = 10
SUBDIVISIONS = 2

def create_mesh():
    ...
Replacing the values in the script, we get:
from scadview import set_mesh_color
from trimesh.creation import icosphere


GOLF_BALL_RADIUS = 42.67 / 2
DIMPLE_RADIUS = 10
SUBDIVISIONS = 2


def create_mesh():
    ball = icosphere(subdivisions=SUBDIVISIONS, radius=GOLF_BALL_RADIUS)
    print(
        f"Created ball with {len(ball.vertices)} vertices and {len(ball.faces)} faces"
    )
    dimple = icosphere(
        subdivisions=SUBDIVISIONS, radius=DIMPLE_RADIUS
    ).apply_translation([0, GOLF_BALL_RADIUS, 0])
    set_mesh_color(ball, [1, 0, 0], alpha=0.5)
    set_mesh_color(dimple, [0, 0, 1], alpha=0.5)
    # return ball.difference(dimple)
    return [ball, dimple]
- Press "Reload" to make sure this works (it should). Golf Ball Transparent OK

Step 6: Make all the dimples

Now it is time to make all of the dimples.

  • Make a dimple for each face.
  • Translate (move) it to the center of the face.

Trimeshes store vertices and faces as numpy ndarrays.
numpy is a very fast package for preforming calculations on large arrays. We will take advantage of some of this, but it can get confusing if we go too deep. So we will just scratch the surface of what numpy can do.

Our strategy:

  • Iterate through each face
        for face in ball.faces:
    
  • Get the vertices for each face.
            verts = ball.vertices[face]
    
  • Find the center of each face. We use the numpy mean function.
            face_center = verts.mean(axis=0)
    
  • Find the distance from the first vertex in the face to the center. We use the numpy norm function:
            dist_to_center = np.linalg.norm(verts[0] - face_center)
    
  • Make a dimple radius some fraction of this (say 1/6), and place at the center.
            dimple_r = dist_to_center / 6.0
            dimple_mesh = icosphere(subdivisions=2, radius=dimple_r, center=face_center)
            dimple_mesh.apply_translation(face_center)
    
  • Put this all together, plus:
    • Replace DIMPLE_RADIUS with DIMPLE_RADIUS_FRACTION
    • Keep the dimples in an array
      import numpy as np
      from scadview import set_mesh_color
      from trimesh.creation import icosphere
      
      
      GOLF_BALL_RADIUS = 42.67 / 2
      DIMPLE_RADIUS_FRACTION = 1 / 6
      SUBDIVISIONS = 2
      
      
      def create_mesh():
          ball = icosphere(subdivisions=SUBDIVISIONS, radius=GOLF_BALL_RADIUS)
          print(
              f"Created ball with {len(ball.vertices)} vertices and {len(ball.faces)} faces"
          )
          set_mesh_color(ball, [1, 0, 0], alpha=0.5)
          dimples = []
          for face in ball.faces:
              verts = ball.vertices[face]
              face_center = verts.mean(axis=0)
              dist_to_center = np.linalg.norm(verts[0] - face_center)
              dimple_r = dist_to_center * DIMPLE_RADIUS_FRACTION
              dimple_mesh = icosphere(
                  subdivisions=SUBDIVISIONS, radius=dimple_r, center=face_center
              )
              dimple_mesh.apply_translation(face_center)
              dimples.append(dimple_mesh)
          return [ball] + dimples
      

This shows a transparent ball with a lot of small balls distributed around its surface. Not quite a golf ball, but it shows where the dimples will be and their size. Golf Ball Dimples It looks good, but I want them bigger, so we set

DIMPLE_RADIUS_FRACTION = 1 / 4

Step 7: Carve out the dimples

Now all that remains is:

  • Carve out each dimple (difference)
  • Return a final mesh (not an array) so that "Export" is available.
    • You may have noticed that in "debug" mode, "Export" is unavailable.

Let's carve out each dimple.

  • We add a line to remove each dimple after we create it.
  • And we just want to return the final ball, not an array of meshes.
            dimple_mesh.apply_translation(face_center)
            ball = ball.difference(dimple_mesh) # <- Added this line
            dimples.append(dimple_mesh)
        # return [ball] + dimples # <- Commented
        return ball
    
    We don't need to keep the dimples list and add each dimple to it via dimples.append(dimple_mesh), but I have a premonition we may want it again later.

Press "Reload".

Hmm. That took longer to load. You might have some questions.

  1. Q: Why did the screen turn a light purple during the load.
    • A: It always does that, to show that it is loading a script. It is not noticeable for a fast load.
  2. Q: What is that bar above the "Load .py..." button and what was it doing during the load.
    • A: That is an "indeterminant progress bar" meant to show there is progress, but we don't know how much more to go. It also always shows progress during any load, but it is also not noticeable for a fast load.
  3. Q: Why so slow?
    • A: The slowness is due to the complexity of boolean geometric operations. Each dimple has 162 vertices, 320 faces and 480 edges, as does the original ball.
      That is a lot of intersections to calculate!
  4. Q: Why is the ball gray? We are still calling set_mesh_color(ball, [1, 0, 0], alpha=0.5)
    • A: Color does not survive a boolean operation, so it reverts to gray. You can set the color after all of the boolean operations are complete. Although color is used for debugging, a single color + alpha can be assigned to the final mesh even when not debugging.
  5. Q: Where are the dimples?
    • A: If you look closely, there are 1 or 2. Golf Ball One Dimple
  6. Q: Where are the rest of them?
    • A: Let's find out.

Step 8: Debug (Again)

To see what is going on, let's return the ball and dimples as an array again, making the ball transparent red. This is a little different than before, because the ball we are returning this time should have had dimples removed.

        dimples.append(dimple_mesh)
    set_mesh_color(ball, [1, 0, 0], alpha=0.1) # <- Add 
    return [ball] + dimples # <- Uncommented
    # return ball # <- Commented 
  • Press "Reload".

Whoa! That looks cool - like a small solar system in the ball. Golf Ball Solar System It is worth noting that in addition to setting the ball to transparent red, we could have just written:

    return ball
The transparency would have shown us voids in the ball, without returning an array for "debug" mode. We could have also written:
    return [ball]
We'd see the same result, but since ball is in an array, we'd be in debug mode.

Feel free to try these out to see the difference.

A Subtle Bug

But why the "planets"?

This bug is more subtle than our previous ones. The problem is:

  • We are iterating through the faces of the ball.
  • But we are also modifying the ball as we do this.
  • So the faces are being actively changed as we interate through them - and we are just getting weird results.

Step 9: Fix

To fix this, we will:

  • Collect all of the dimples first without modifying ball
  • Then remove each dimple.

    `        # ball = ball.difference(dimple_mesh) # <- Commented
            dimples.append(dimple_mesh)
        for dimple_mesh in dimples: # <- Added
            ball = ball.difference(dimple_mesh) # <- Added 
        # set_mesh_color(ball, [1, 0, 0], alpha=0.1) # <- Commented
        # return [ball] + dimples # <- Commented
        return ball # <- Uncommented
    

  • Press "Reload" and wait while it loads.

This looks good! Golf Ball Good Let's removed the commented code, and so we have our final code:

import numpy as np
from scadview import set_mesh_color
from trimesh.creation import icosphere


GOLF_BALL_RADIUS = 42.67 / 2
DIMPLE_RADIUS_FRACTION = 1 / 6
SUBDIVISIONS = 2


def create_mesh():
    ball = icosphere(subdivisions=SUBDIVISIONS, radius=GOLF_BALL_RADIUS)
    print(
        f"Created ball with {len(ball.vertices)} vertices and {len(ball.faces)} faces"
    )
    set_mesh_color(ball, [1, 0, 0], alpha=0.5)
    dimples = []
    for face in ball.faces:
        verts = ball.vertices[face]
        face_center = verts.mean(axis=0)
        dist_to_center = np.linalg.norm(verts[0] - face_center)
        dimple_r = dist_to_center * DIMPLE_RADIUS_FRACTION
        dimple_mesh = icosphere(
            subdivisions=SUBDIVISIONS, radius=dimple_r, center=face_center
        )
        dimple_mesh.apply_translation(face_center)
        dimples.append(dimple_mesh)
    for dimple_mesh in dimples:
        ball = ball.difference(dimple_mesh)
    return ball

Step 10: Export

All that is left is to export the mesh for printing!

  • Press "Export..."
  • Select "File Type" as "OBJ (.obj)" (or whatever you need)
  • Select what folder you want to save to.
  • Press "Save".
    Export

You should now be able to import into your 3D slicer, and create the necessary gcode file for printing.

Additional Topics

Creating Multiple Meshes for Export

The create_mesh() allows you to return multiple meshes in an array, but this is "debug" mode, and so you cannot export them.

To resolve this, union your meshes into a final mesh, even if they are disjoint. For example, for 3 meshes:

    return mesh1.union(mesh2).union(mesh3)

Incremental Builds

If you have a complex build that takes many seconds, minutes, hours or more, you don't want to wait that long while debugging.

A couple of options are:

  • Build a smaller, faster version. For example, with the golf ball, we could have use fewer subdivisions in the ball and dimples, and that would have revealed our bugs. Once fixed, we could revert to the lengthier build.
  • Only build the problematic parts, hopefully quickly. Fix and iterate. Once the problems are fixed, add back in the rest of the build.

But SCADview also has an "incremental" build option. In this option, instead of using the return ... statement, you use the yield ... statement as you build your mesh. This will send whatever you have built so far to SCADview, and it will display it.

If you see a problem early on, you can make a change and reload before the previous load or reload completes.

Let's try this with the golf ball:

  • Lets yield ball each time we remove a dimple.
    for dimple_mesh in dimples:
        ball = ball.difference(dimple_mesh)
        yield ball # <- Added 
    # return ball # <- Commented 

You will see the ball, and then the dimples progressively appear.

You can also yield arrays for debug mode.

Animation

An incremental build is an animation, and you can do other animations, like move objects around the scene, and then yield the new scene.

To add a little shake to the incremental build golf ball when it is done:

    from time import sleep # <- Add at top
...
# After last for loop, add:
    for i in range(100):
        ball.apply_translation([0, 0, 1.0 - 2.0 * (i % 2)])
        yield ball
        sleep(0.03)