Introduction: 3D Printed Ping-Pong Ball

About: Passionate about DIY with a background in mechanical engineering, I want to share ideas and techniques concerning 3D modeling and software development geared towards making tangile objects. I also enjoy sports…

The purpose of this instructable is to model a ping-pong ball that can be easily
3d printed while keeping a low weight for playability. I got the inspiration for
this project from the deceptor concept from finnish designer janne kyttanen
(read about it here : 3d printed ping pong table + paddles ).


One drawback of current 3d printing techniques is that they typically don't allow
a wall thickness under 0.8mm, which would result in a heavy and thus not
practical ball (standard 40mm balls weigh 2.7 grams).
To mitigate that issue, i chose to use a geodome-like structure, which has a high
strength to weight ratio.

We will walk through the steps required to model such a ball in the following
sections. The only tool we'll be using for this purpose is OpenJSCAD,
a powerful online script-based 3d modeler. You need to be familiar with
javascript syntax to make the most of this tutorial.

Step 1: Getting Familiar With OpenJSCAD

Let's get started!

First start OpenJSCAD by clicking on the following link : OpenJSCAD

The code editor on the right should display the following code :

function main() {
return union( 	difference(cube({size: 3, center: true}),
				   sphere({r:2, center: true})),
		intersection(sphere({r: 1.3, center: true}),
			     cube({size: 2.1, center: true}))
		).translate([0,0,1.5]).scale(10);
}

This script here generates the model shown in the 3d view. Notice that what gets
displayed is what you return from the main function. You can play with that code
if you wish, for example by modifying the sphere radius or cube size. For a
detailed description of the all available geometric primitives and transformations,
refer to the OpenJSCAD User Guide.

To see the result, regenerate the model by pressing Shift + Enter.

Next we'll start modelling our ping-pong ball.

Step 2: A Basic Ping-pong Ball

We start with the external envellope of the ball, which is a sphere with a 40mm diameter.

function main(){
    var ballDiameter = 40;
    var sphereRes = 50;
    
    var outerSphere = CSG.sphere({
      center: [0, 0, 0],
      radius: ballDiameter/2.,    // must be scalar
      resolution: sphereRes        // optional
    });
    
    return outerSphere;
}

You can find more info on generating spheres with OpenJSCAD in the user guide here.

After generating the model (Shift + Enter), you should see a sphere like in the picture above.

But wait, what we have here is a solid sphere, which would result in a pretty
heavy ball! To model an actual ping pong ball, we need to hollow out the
inside of our sphere.
To do that, we will use a boolean operation called a Difference, which is done
in OpenJSCAD by calling the difference function with two 3d objects as parameters.

Basic ping-pong ball script

function main(){
    var ballDiameter = 40;
    var wallThickness = 1.0;
    var sphereRes = 50;
    
    var outerSphere = CSG.sphere({
      center: [0, 0, 0],
      radius: ballDiameter/2.,    // must be scalar
      resolution: sphereRes        // optional
    });
    
    var innerSphere = CSG.sphere({
      center: [0, 0, 0],
      radius: ballDiameter/2. - wallThickness ,    // must be scalar
      resolution: sphereRes        // optional
    });
    
    var cuttingCube = CSG.cube({
        center: [0, -ballDiameter/2., 0],
        radius: [ballDiameter/2, ballDiameter/2, ballDiameter/2]
    });
    
    var ball = difference(outerSphere, innerSphere);
    return difference(ball, cuttingCube);
}

I used a cube to cut the sphere in half so you can better see that the ball is
indeed hollow. A wall thickness of 1mm ensures printability as well as solidity,
especially as we'll later add holes all over the surface of the ball.

Step 3: Geodesic Spheres

Now we'll look into how to subdivide a sphere into a determined number of
triangles of approximately similar shape. The method described here is
often used to build polygonal approximations of spheres, such as detailed
in this OpenGL tutorial : http://www.opengl.org.ru/docs/pg/0208.html.
In our case, this will be the building step to generate the holes that will
transfrom our regular ball into a geodeome-like one.

First we start with a regular isocahedron , which has twenty triangular faces, and
the interesting property that each of its vertices lie on a sphere.
Then for every triangle, we split each of the three edges at the middle, which
gives us three additional vertices which we use to subdivide said triangle
into four smaller triangles. The three vertices are then translated outwards
so they lie on the sphere's surface.

Isocahedron OpenJSCAD script

function main(){
    var X = 0.525731112119133606;
    var Z = 0.850650808352039932;
    var radius = 20;
    var vdata = [
        [-X, 0.0, Z], [ X, 0.0, Z ], [ -X, 0.0, -Z ], [ X, 0.0, -Z ],
        [ 0.0, Z, X ], [ 0.0, Z, -X ], [ 0.0, -Z, X ], [ 0.0, -Z, -X ],
        [ Z, X, 0.0 ], [ -Z, X, 0.0 ], [ Z, -X, 0.0 ], [ -Z, -X, 0.0 ]
    ]; // isocahedron vertex coordinates
    var tindices = [
        [0, 4, 1], [ 0, 9, 4 ], [ 9, 5, 4 ], [ 4, 5, 8 ], [ 4, 8, 1 ],
        [ 8, 10, 1 ], [ 8, 3, 10 ], [ 5, 3, 8 ], [ 5, 2, 3 ], [ 2, 7, 3 ],
        [ 7, 10, 3 ], [ 7, 6, 10 ], [ 7, 11, 6 ], [ 11, 0, 6 ], [ 0, 1, 6 ],
        [ 6, 1, 10 ], [ 9, 0, 11 ], [ 9, 11, 2 ], [ 9, 2, 5 ], [ 7, 2, 11 ]
    ]; // isocahedron triangles
    
    return polyhedron({      
      points: vdata,
      triangles: tindices
    }).scale([radius, radius, radius]);
}

Next we write the subdivision fonction for a unit sphere approximation :

The subdvide function

/* fn 	subdivide(v1, v2, v3, addPolyCb, depth)
* brief subdivides a triangle into 4 smaller ones
* params 	v1, v2, v3 : triangle vertices
*		addPolyCb : callback for add polygon notification
*		depth : number of remaining subdivision iterations
*/
function subdivide(v1, v2, v3, addPolyCb, depth)
{
    if(depth == 0) {
        addPolyCb(v1, v2, v3);
       return;
    }
    
    var v12 = v1.plus(v2).unit(); // middle of v1v2 edge projected on the unit sphere
    var v23 = v2.plus(v3).unit();
    var v31 = v3.plus(v1).unit();
    var newDepth = depth - 1;
    subdivide(v1, v12, v31, addPolyCb, newDepth);
    subdivide(v2, v23, v12, addPolyCb, newDepth);    
    subdivide(v3, v31, v23, addPolyCb, newDepth);
    subdivide(v12, v23, v31, addPolyCb, newDepth);
}

The subdivision process works recursively; starting from the 20 isocahedron
faces, each step of the subdivision multiplies the total number of faces by 4.
Consequently, a depth of 1 generates an 80 faces polyhedron.

We can now generate a sphere approximation by calling the subdivide function
iteratively over the isocahedron faces as follows :

80 faces geodesic sphere script

function main()
{
    var ballDiameter = 40;
    var ballRadius = ballDiameter/2.;
    var polygons = []; // list of polygons
    addPolyCb = function(v1, v2, v3) 
    { 
        polygons.push(new CSG.Polygon([
              new CSG.Vertex(new CSG.Vector3D(v3.x,v3.y,v3.z)),
              new CSG.Vertex(new CSG.Vector3D(v2.x,v2.y,v2.z)),
              new CSG.Vertex(new CSG.Vector3D(v1.x,v1.y,v1.z))
        ]));
    }
    
    createGeodesicSphere(addPolyCb, 1);
    var unitSphereApprox = CSG.fromPolygons(polygons);
    
    return unitSphereApprox.scale([ballRadius, ballRadius, ballRadius]);
}

// subdivides a unit isocahedron n=depth times and calls a callback for each created face
function createGeodesicSphere(addPolyCb, depth) 
{
    var X = 0.525731112119133606;
    var Z = 0.850650808352039932;
    var vdata = [
        [-X, 0.0, Z], [ X, 0.0, Z ], [ -X, 0.0, -Z ], [ X, 0.0, -Z ],
        [ 0.0, Z, X ], [ 0.0, Z, -X ], [ 0.0, -Z, X ], [ 0.0, -Z, -X ],
        [ Z, X, 0.0 ], [ -Z, X, 0.0 ], [ Z, -X, 0.0 ], [ -Z, -X, 0.0 ]
    ];
    var tindices = [
        [0, 4, 1], [ 0, 9, 4 ], [ 9, 5, 4 ], [ 4, 5, 8 ], [ 4, 8, 1 ],
        [ 8, 10, 1 ], [ 8, 3, 10 ], [ 5, 3, 8 ], [ 5, 2, 3 ], [ 2, 7, 3 ],
        [ 7, 10, 3 ], [ 7, 6, 10 ], [ 7, 11, 6 ], [ 11, 0, 6 ], [ 0, 1, 6 ],
        [ 6, 1, 10 ], [ 9, 0, 11 ], [ 9, 11, 2 ], [ 9, 2, 5 ], [ 7, 2, 11 ]
    ];

    // iterate over isocahedron triangles
    for(var i = 0; i < 20; i++)
        subdivide(  new CSG.Vector3D(vdata[tindices[i][0]]), 
                    new CSG.Vector3D(vdata[tindices[i][1]]), 
                    new CSG.Vector3D(vdata[tindices[i][2]]), 
                    addPolyCb, depth);
} 

To run this script, copy and paste the code in your favorite code editor, and save
the file with the .jscad extension. Then just drag & drop the file in the recangular
area at the bottom left corner of your OpenJSCAD browser window.

Step 4: Drilling Triangular Holes

In this section we'll look into how to generate a pattern of prisms from the geodesic sphere faces, that we'll then substract from our ball from Step 2 to generate the actual holes.

First we need to create prisms from the geodesic sphere triangles.
To do so, we first create a smaller triangle within each triangle of the geodesic
sphere by offsetting its edges inward by a fixed distance and computing
the intersection points of the translated edges which make up the vertices
of the new triangle (see picture above).

To generate the prism, I used the solidFromSlicesmethod to extrude the current triangle (i1 i2 i3) orthogonally to its surface.

The createPrism function

// generates a prism from a unit sphere triangle, a radius, and an offset
function createPrism(sphereTri, radius, offset)
{// compute the coordinates of the vertices of the input triangle
    var v1 = new CSG.Vector3D(scalar_mul(sphereTri[0], radius));
    var v2 = new CSG.Vector3D(scalar_mul(sphereTri[1], radius));
    var v3 = new CSG.Vector3D(scalar_mul(sphereTri[2], radius));
    
    // make plane base (v1, x, y)
    var xAxis = v2.minus(v1).unit();
    var v13 = v3.minus(v1);
    var yAxis = v13.minus(xAxis.times(v13.dot(xAxis))).unit();

    // retrieve 2d coordinates of the triangle vertices in that base
    var v1_2d = new CSG.Vector2D(0, 0);
    var v2_2d = new CSG.Vector2D(v2.minus(v1).dot(xAxis), 0);
    var v3_2d = new CSG.Vector2D(v3.minus(v1).dot(xAxis), v3.minus(v1).dot(yAxis));

    // get the middle of each segment in the plane
    var v12_2d = v2_2d.minus(v1_2d);
    var v23_2d = v3_2d.minus(v2_2d);
    var v31_2d = v1_2d.minus(v3_2d);

    // get unit vector perpendicular to i1i2 segment in the plane
    var ortho12 = new CSG.Vector2D(-v12_2d.y, v12_2d.x).unit();
    if(v3_2d.minus(v1_2d).dot(ortho12) <0)
        ortho12 = ortho12.times(-1);
        
    var ortho23 = new CSG.Vector2D(-v23_2d.y, v23_2d.x).unit();
    if(v1_2d.minus(v2_2d).dot(ortho23) <0)
        ortho23 = ortho23.times(-1);
        
    var ortho31 = new CSG.Vector2D(-v31_2d.y, v31_2d.x).unit();
    if(v2_2d.minus(v1_2d).dot(ortho31) <0)
        ortho31 = ortho31.times(-1);
        
    // translate all tri segments inward by the same offset
    var s12b = translate_segment([v1_2d, v2_2d], ortho12.times(offset));
    var s23b = translate_segment([v2_2d, v3_2d], ortho23.times(offset));
    var s31b = translate_segment([v3_2d, v1_2d], ortho31.times(offset));
    
    // compute intersection points of translated segments in the plane
    var i1 = intersect(s12b, s23b);
    var i2 = intersect(s23b, s31b);
    var i3 = intersect(s31b, s12b);

    var i1_3d = v1.plus(xAxis.times(i1.x)).plus(yAxis.times(i1.y));
    var i2_3d = v1.plus(xAxis.times(i2.x)).plus(yAxis.times(i2.y));
    var i3_3d = v1.plus(xAxis.times(i3.x)).plus(yAxis.times(i3.y));
    
    // create a polygon from the intersection points
    var tri =  new CSG.Polygon([
                new CSG.Vertex(i1_3d),
                new CSG.Vertex(i2_3d),
                new CSG.Vertex(i3_3d)
               ]);
               
    var zAxis = tri.plane.normal;
    return tri.solidFromSlices({
      numslices: 2,              // amount of slices
      loop: false,            // final CSG is closed by looping (start = end) like a torus
      callback: function(t,slice) {
          // echo("t:" + t)
         return this.translate( scalar_mul([zAxis.x, zAxis.y, zAxis.z], 4*t) );
      }
   }).translate(scalar_mul([zAxis.x, zAxis.y, zAxis.z], -2));
}

// multiplies a 3d vector by a scalar
function scalar_mul(v, c)
{
    return [c*v[0], c*v[1], c*v[2]];
}

// translates a 2d segment by a 2d vector
function translate_segment(seg, vec)
{
    s0b = seg[0].plus(vec);
    s1b = seg[1].plus(vec);
    
    return [s0b, s1b];
}

// computes the intersection of 2 2d segments
function intersect(s1, s2)
{
    var p = s1[0];
    var q = s2[0];
    var r = s1[1].minus(p);
    var s = s2[1].minus(q);
    
    var u = q.minus(p).cross(r)/r.cross(s);
    var t = q.minus(p).cross(s)/r.cross(s);
    
    if(r.cross(s) != 0 && u >= 0 && u<= 1 && t >= 0 && t<= 1)
        return p.plus(r.times(t));
   return null;
}


Making use of the createPrism function above, whe can now generate the full
hole pattern by iterating over the geodesic sphere triangles :

Hole pattern script

function main(){
    var ballDiameter = 40; // mm
    var segmentWidth = 2; // mm

    var sphereTris = []; // holds list of geodesic sphere triangles
    addPolyCb = function(v1, v2, v3) 
    { 
        sphereTris.push([
                            [v1.x, v1.y, v1.z],
                            [v2.x, v2.y, v2.z],
                            [v3.x, v3.y, v3.z]
        ]); 
    }
    
    createGeodesicSphere(addPolyCb, 1);
    
    var holePattern;
    for(j=0; j!=sphereTris.length; ++j)
    {
        var prism =  createPrism(sphereTris[j], ballDiameter/2., segmentWidth/2.);
        if(j==0)
        {
            holePattern = prism;
        }
        else
            holePattern = holePattern.union(prism);
    }
    
    return holePattern;
}

function subdivide(v1, v2, v3, addPolyCb, depth) 
{
    if(depth == 0) {
        addPolyCb(v1, v2, v3);
       return;
    }
    
    var v12 = v1.plus(v2).unit();
    var v23 = v2.plus(v3).unit();
    var v31 = v3.plus(v1).unit();
    var newDepth = depth - 1;
    subdivide(v1, v12, v31, addPolyCb, newDepth);
    subdivide(v2, v23, v12, addPolyCb, newDepth);    
    subdivide(v3, v31, v23, addPolyCb, newDepth);
    subdivide(v12, v23, v31, addPolyCb, newDepth);
}

function createGeodesicSphere(addPolyCb, depth) {
    var X = 0.525731112119133606;
    var Z = 0.850650808352039932;
    var vdata = [
        [-X, 0.0, Z], [ X, 0.0, Z ], [ -X, 0.0, -Z ], [ X, 0.0, -Z ],
        [ 0.0, Z, X ], [ 0.0, Z, -X ], [ 0.0, -Z, X ], [ 0.0, -Z, -X ],
        [ Z, X, 0.0 ], [ -Z, X, 0.0 ], [ Z, -X, 0.0 ], [ -Z, -X, 0.0 ]
    ];
    var tindices = [
        [0, 4, 1], [ 0, 9, 4 ], [ 9, 5, 4 ], [ 4, 5, 8 ], [ 4, 8, 1 ],
        [ 8, 10, 1 ], [ 8, 3, 10 ], [ 5, 3, 8 ], [ 5, 2, 3 ], [ 2, 7, 3 ],
        [ 7, 10, 3 ], [ 7, 6, 10 ], [ 7, 11, 6 ], [ 11, 0, 6 ], [ 0, 1, 6 ],
        [ 6, 1, 10 ], [ 9, 0, 11 ], [ 9, 11, 2 ], [ 9, 2, 5 ], [ 7, 2, 11 ]
    ];
    
    for(var i = 0; i < 20; i++)
        subdivide(    new CSG.Vector3D(vdata[tindices[i][0]]), 
                    new CSG.Vector3D(vdata[tindices[i][1]]), 
                    new CSG.Vector3D(vdata[tindices[i][2]]), 
                    addPolyCb, depth);
}

Step 5: Putting It All Together

This is where we combine the results of steps 2 and 4 to achieve the desired
outcome. Using the functions createGeodesicSphere and createPrism
encoutered in the previous steps, the end result is obtained by first substracting
each prism from the ball, and then hollowing out the ball as seen in Step 2.
I chose a subdivision depth of 2, which means our ball has 320 holes, which
is the best fit for a 3d print of that size.

Final script

function main(){
    // definitions
    var ballDiameter = 40;
    var ballRadius = ballDiameter/2.;
    var wallThickness = 1.0;
    var segmentWidth = 1.1; // width of an individual link of the structure
    var sphereRes = 33;
    var subdivisionDepth = 2;
    
    var outerSphere = CSG.sphere({
      center: [0, 0, 0],
      radius: ballRadius,    // must be scalar
      resolution: sphereRes        // optional
    });
   
    var innerSphere = CSG.sphere({
      center: [0, 0, 0],
      radius: ballRadius - wallThickness ,    // must be scalar
      resolution: sphereRes        // optional
    });
    
    var ball = outerSphere;
    var geoSphereTris = []; // list of geosphere triangles
    addPolyCb = function(v1, v2, v3) 
    { 
        geoSphereTris.push([
                            [v1.x, v1.y, v1.z],
                            [v2.x, v2.y, v2.z],
                            [v3.x, v3.y, v3.z]
        ]); 
    }
    
    createGeodesicSphere(addPolyCb, subdivisionDepth);

    // successively substract each prism from the ball
    for(j=0; j!=geoSphereTris.length; ++j)
    {
        var prism =  createPrism(geoSphereTris[j], ballRadius, segmentWidth/2.);
        ball = difference(ball, prism);
    }
    
    ball = difference(ball, innerSphere); // hollow out the inside
    
    return ball;
}


Once the model is generated, you can export it directly in STL format using the
'Generate STL' button under the 3d view. You can now get the model printed
through your favorite 3d printing service or local store.

Warning : this model cannot be printed as-is on FDM printers, it would need an additional removable support structure, which is beyond the scope of this tutorial. However, it can be printed on SLS or SLA printers effortlessly.

Thank you for reading this instructable, I hope you gained something valuable from it.

You can find the resulting STL files here :

http://www.thingiverse.com/thing:859499

http://www.thingiverse.com/thing:859513 ( w/ support structure for FDM printers )


Best wishes,

Vincent