Repository: vispy/vispy

Issue:
ENH: More uniform tessellation of spheres
Right now our sphere tessellation is done using latitute-longitute type calculations, which are not uniform. I have some code for more even tessellation of sphere that I or someone else could add if they feel like it.



Discussion:
A very good approach is to start with a tetrahedon and subdividing it N times (until its smooth enough): https://code.google.com/p/visvis/source/browse/functions/solidSphere.py#14

I have pretty efficient icosahedron and octahedron subdividing, I don't know if those are better or worse

I think the only difference is the exact level of smoothness that you can reach. Each time you do a subdivision, the mesh will be "twice as smooth", you cannot go in between. But you could if you chose a different initial geometry. Don't think that this point will actually be an issue. I'd just pick one shape and go with that :P

I can take this up if nobody else is assigned. I already have code for sphere generation by subdividing an icosahedron. Btw, will the existing `create_sphere()` function be replaced or do we create a new function say `create_icosphere(radius, subdivisions)`?

I think `create_sphere` could just be enhanced with a `method` argument or so


Resolving patch:
diff --git a/examples/basics/scene/sphere.py b/examples/basics/scene/sphere.py
--- a/examples/basics/scene/sphere.py
+++ b/examples/basics/scene/sphere.py
@@ -11,14 +11,28 @@
 import sys
 
 from vispy import scene
-canvas = scene.SceneCanvas(keys='interactive', size=(800, 600), show=True)
+from vispy.visuals.transforms import STTransform
+
+canvas = scene.SceneCanvas(keys='interactive', bgcolor='white',
+                           size=(800, 600), show=True)
 
 view = canvas.central_widget.add_view()
 view.camera = 'arcball'
-view.padding = 100
 
-sphere = scene.visuals.Sphere(radius=1, parent=view.scene,
-                              edge_color='black')
+sphere1 = scene.visuals.Sphere(radius=1, method='latitude', parent=view.scene,
+                               edge_color='black')
+
+sphere2 = scene.visuals.Sphere(radius=1, method='ico', parent=view.scene,
+                               edge_color='black')
+
+sphere3 = scene.visuals.Sphere(radius=1, rows=10, cols=10, depth=10,
+                               method='cube', parent=view.scene,
+                               edge_color='black')
+
+sphere1.transform = STTransform(translate=[-2.5, 0, 0])
+sphere3.transform = STTransform(translate=[2.5, 0, 0])
+
+view.camera.set_range(x=[-3, 3])
 
 if __name__ == '__main__' and sys.flags.interactive == 0:
     canvas.app.run()
diff --git a/vispy/geometry/generation.py b/vispy/geometry/generation.py
--- a/vispy/geometry/generation.py
+++ b/vispy/geometry/generation.py
@@ -297,25 +297,7 @@ def create_box(width=1, height=1, depth=1, width_segments=1, height_segments=1,
     return vertices, faces, outline
 
 
-def create_sphere(rows, cols, radius=1.0, offset=True):
-    """Create a sphere
-
-    Parameters
-    ----------
-    rows : int
-        Number of rows.
-    cols : int
-        Number of columns.
-    radius : float
-        Sphere radius.
-    offset : bool
-        Rotate each row by half a column.
-
-    Returns
-    -------
-    sphere : MeshData
-        Vertices and faces computed for a spherical surface.
-    """
+def _latitude(rows, cols, radius, offset):
     verts = np.empty((rows+1, cols, 3), dtype=np.float32)
 
     # compute vertices
@@ -355,6 +337,118 @@ def create_sphere(rows, cols, radius=1.0, offset=True):
     return MeshData(vertices=verts, faces=faces)
 
 
+def _ico(radius, subdivisions):
+    # golden ratio
+    t = (1.0 + np.sqrt(5.0))/2.0
+
+    # vertices of a icosahedron
+    verts = [(-1, t, 0),
+             (1, t, 0),
+             (-1, -t, 0),
+             (1, -t, 0),
+             (0, -1, t),
+             (0, 1, t),
+             (0, -1, -t),
+             (0, 1, -t),
+             (t, 0, -1),
+             (t, 0, 1),
+             (-t, 0, -1),
+             (-t, 0, 1)]
+
+    # faces of the icosahedron
+    faces = [(0, 11, 5),
+             (0, 5, 1),
+             (0, 1, 7),
+             (0, 7, 10),
+             (0, 10, 11),
+             (1, 5, 9),
+             (5, 11, 4),
+             (11, 10, 2),
+             (10, 7, 6),
+             (7, 1, 8),
+             (3, 9, 4),
+             (3, 4, 2),
+             (3, 2, 6),
+             (3, 6, 8),
+             (3, 8, 9),
+             (4, 9, 5),
+             (2, 4, 11),
+             (6, 2, 10),
+             (8, 6, 7),
+             (9, 8, 1)]
+
+    def midpoint(v1, v2):
+        return ((v1[0]+v2[0])/2, (v1[1]+v2[1])/2, (v1[2]+v2[2])/2)
+
+    # subdivision
+    for _ in range(subdivisions):
+        for idx in range(len(faces)):
+            i, j, k = faces[idx]
+            a, b, c = verts[i], verts[j], verts[k]
+            ab, bc, ca = midpoint(a, b), midpoint(b, c), midpoint(c, a)
+            verts += [ab, bc, ca]
+            ij, jk, ki = len(verts)-3, len(verts)-2, len(verts)-1
+            faces.append([i, ij, ki])
+            faces.append([ij, j, jk])
+            faces.append([ki, jk, k])
+            faces[idx] = [jk, ki, ij]
+    verts = np.array(verts)
+    faces = np.array(faces)
+
+    # make each vertex to lie on the sphere
+    lengths = np.sqrt((verts*verts).sum(axis=1))
+    verts /= lengths[:, np.newaxis]/radius
+    return MeshData(vertices=verts, faces=faces)
+
+
+def _cube(rows, cols, depth, radius):
+    # vertices and faces of tessellated cube
+    verts, faces, _ = create_box(1, 1, 1, rows, cols, depth)
+    verts = verts['position']
+
+    # make each vertex to lie on the sphere
+    lengths = np.sqrt((verts*verts).sum(axis=1))
+    verts /= lengths[:, np.newaxis]/radius
+    return MeshData(vertices=verts, faces=faces)
+
+
+def create_sphere(rows=10, cols=10, depth=10, radius=1.0, offset=True,
+                  subdivisions=3, method='latitude'):
+    """Create a sphere
+    Parameters
+    ----------
+    rows : int
+        Number of rows (for method='latitude' and 'cube').
+    cols : int
+        Number of columns (for method='latitude' and 'cube').
+    depth : int
+        Number of depth segments (for method='cube').
+    radius : float
+        Sphere radius.
+    offset : bool
+        Rotate each row by half a column (for method='latitude').
+    subdivisions : int
+        Number of subdivisions to perform (for method='ico')
+    method : str
+        Method for generating sphere. Accepts 'latitude' for latitude-
+        longitude, 'ico' for icosahedron, and 'cube' for cube based
+        tessellation.
+
+    Returns
+    -------
+    sphere : MeshData
+        Vertices and faces computed for a spherical surface.
+    """
+    if method == 'latitude':
+        return _latitude(rows, cols, radius, offset)
+    elif method == 'ico':
+        return _ico(radius, subdivisions)
+    elif method == 'cube':
+        return _cube(rows, cols, depth, radius)
+    else:
+        raise Exception("Invalid method. Accepts: 'latitude', 'ico', 'cube'")
+
+
 def create_cylinder(rows, cols, radius=[1.0, 1.0], length=1.0, offset=False):
     """Create a cylinder
 
diff --git a/vispy/visuals/sphere.py b/vispy/visuals/sphere.py
--- a/vispy/visuals/sphere.py
+++ b/vispy/visuals/sphere.py
@@ -17,7 +17,17 @@ class SphereVisual(CompoundVisual):
     radius : float
         The size of the sphere.
     cols, rows : int
-        Number of rows and cols that make up the sphere mesh.
+        Number of rows and cols that make up the sphere mesh
+        (for method='latitude' and 'cube').
+    depth : int
+        Number of depth segments that make up the sphere mesh
+        (for method='cube').
+    subdivisions : int
+        Number of subdivisions to perform (for method='ico').
+    method : str
+        Method for generating sphere. Accepts 'latitude' for
+        latitude-longitude, 'ico' for icosahedron, and 'cube'
+        for cube based tessellation.
     vertex_colors : ndarray
         Same as for `MeshVisual` class.
         See `create_sphere` for vertex ordering.
@@ -30,11 +40,12 @@ class SphereVisual(CompoundVisual):
         The `Color` to use when drawing the sphere edges. If `None`, then no
         sphere edges are drawn.
     """
-    def __init__(self, radius=1.0, cols=30, rows=30, vertex_colors=None,
-                 face_colors=None, color=(0.5, 0.5, 1, 1), edge_color=None,
-                 **kwargs):
+    def __init__(self, radius=1.0, cols=30, rows=30, depth=30, subdivisions=3,
+                 method='latitude', vertex_colors=None, face_colors=None,
+                 color=(0.5, 0.5, 1, 1), edge_color=None, **kwargs):
 
-        mesh = create_sphere(cols, rows, radius=radius)
+        mesh = create_sphere(cols, rows, depth, radius=radius,
+                             subdivisions=subdivisions, method=method)
 
         self._mesh = MeshVisual(vertices=mesh.get_vertices(),
                                 faces=mesh.get_faces(),


Test patch:
diff --git a/vispy/geometry/tests/test_generation.py b/vispy/geometry/tests/test_generation.py
--- a/vispy/geometry/tests/test_generation.py
+++ b/vispy/geometry/tests/test_generation.py
@@ -25,7 +25,13 @@ def test_cube():
 
 def test_sphere():
     """Test sphere function"""
-    md = create_sphere(10, 20, radius=10)
+    md = create_sphere(rows=10, cols=20, radius=10, method='latitude')
+    radii = np.sqrt((md.get_vertices() ** 2).sum(axis=1))
+    assert_allclose(radii, np.ones_like(radii) * 10)
+    md = create_sphere(subdivisions=5, radius=10, method='ico')
+    radii = np.sqrt((md.get_vertices() ** 2).sum(axis=1))
+    assert_allclose(radii, np.ones_like(radii) * 10)
+    md = create_sphere(rows=20, cols=20, depth=20, radius=10, method='cube')
     radii = np.sqrt((md.get_vertices() ** 2).sum(axis=1))
     assert_allclose(radii, np.ones_like(radii) * 10)