Repository: mikedh/trimesh

Issue:
Warning when instantiating primitive
Hi. With the most recent trimesh version `4.2.3` I get this:

```python
>>> import trimesh as tm
>>> sphere = tm.primitives.Sphere()
primitive faces are immutable: not setting!
```

I did only minimal testing but it seems that despite the warning I get the expected primitives.

There was one oddity though which I'm not sure is related to the warning:

```python
>>> sphere1 = tm.primitives.Sphere(radius=1, center=(0, 0, 0), subdivisions=2)
>>> sphere2 = tm.primitives.Sphere(radius=1, center=(5, 0, 0), subdivisions=2)
>>> sphere3 = tm.primitives.Sphere(radius=2, center=(10, 0, 0), subdivisions=2)
>>> sphere4 = tm.primitives.Sphere(radius=2, center=(15, 0, 0), subdivisions=0)
```
<img width="598" alt="Screenshot 2024-03-31 at 09 54 52" src="https://github.com/mikedh/trimesh/assets/7161148/1c70b941-abae-4841-a0ef-513ccbcc4492">

Sphere 1 through 4 are lined up left to right. As you can see `sphere4` (red) where we use `subdivisions=0` did not change the radius.


Discussion:

Patch:
diff --git a/pyproject.toml b/pyproject.toml
index 4d4bdc15..e3e7906f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ requires = ["setuptools >= 61.0", "wheel"]
 [project]
 name = "trimesh"
 requires-python = ">=3.7"
-version = "4.2.3"
+version = "4.2.4"
 authors = [{name = "Michael Dawson-Haggerty", email = "mikedh@kerfed.com"}]
 license = {file = "LICENSE.md"}
 description = "Import, export, process, analyze and view triangular meshes."
diff --git a/trimesh/base.py b/trimesh/base.py
index ce663732..0320530b 100644
--- a/trimesh/base.py
+++ b/trimesh/base.py
@@ -1937,8 +1937,9 @@ class Trimesh(Geometry3D):
 
     def subdivide(self, face_index: Optional[ArrayLike] = None) -> "Trimesh":
         """
-        Subdivide a mesh, with each subdivided face replaced
-        with four smaller faces.
+        Subdivide a mesh with each subdivided face replaced
+        with four smaller faces. Will return a copy of current
+        mesh with subdivided faces.
 
         Parameters
         ------------
diff --git a/trimesh/creation.py b/trimesh/creation.py
index e7afcec2..e194efb1 100644
--- a/trimesh/creation.py
+++ b/trimesh/creation.py
@@ -855,9 +855,12 @@ def icosphere(subdivisions: Integer = 3, radius: Number = 1.0, **kwargs):
     ico : trimesh.Trimesh
       Meshed sphere
     """
+    radius = float(radius)
+    subdivisions = int(subdivisions)
 
     ico = icosahedron()
     ico._validate = False
+
     for _ in range(subdivisions):
         ico = ico.subdivide()
         vectors = ico.vertices
@@ -865,6 +868,13 @@ def icosphere(subdivisions: Integer = 3, radius: Number = 1.0, **kwargs):
         unit = vectors / scalar.reshape((-1, 1))
         ico.vertices += unit * (radius - scalar).reshape((-1, 1))
 
+    # if we didn't subdivide we still need to refine the radius
+    if subdivisions <= 0:
+        vectors = ico.vertices
+        scalar = np.sqrt(np.dot(vectors**2, [1, 1, 1]))
+        unit = vectors / scalar.reshape((-1, 1))
+        ico.vertices += unit * (radius - scalar).reshape((-1, 1))
+
     if "color" in kwargs:
         warnings.warn(
             "`icosphere(color=...)` is deprecated and will "
diff --git a/trimesh/primitives.py b/trimesh/primitives.py
index 64408d29..7718d9c7 100644
--- a/trimesh/primitives.py
+++ b/trimesh/primitives.py
@@ -16,6 +16,7 @@ from . import caching, creation, inertia, sample, triangles, util
 from . import transformations as tf
 from .base import Trimesh
 from .constants import log, tol
+from .typed import ArrayLike, Integer, Number, Optional
 
 # immutable identity matrix for checks
 _IDENTITY = np.eye(4)
@@ -57,7 +58,8 @@ class Primitive(Trimesh):
 
     @faces.setter
     def faces(self, values):
-        log.warning("primitive faces are immutable: not setting!")
+        if values is not None:
+            raise ValueError("primitive faces are immutable: not setting!")
 
     @property
     def vertices(self):
@@ -71,7 +73,7 @@ class Primitive(Trimesh):
     @vertices.setter
     def vertices(self, values):
         if values is not None:
-            log.warning("primitive vertices are immutable: not setting!")
+            raise ValueError("primitive vertices are immutable: not setting!")
 
     @property
     def face_normals(self):
@@ -550,28 +552,32 @@ class Capsule(Primitive):
 
 class Sphere(Primitive):
     def __init__(
-        self, radius=1.0, center=None, transform=None, subdivisions=3, mutable=True
+        self,
+        radius: Number = 1.0,
+        center: Optional[ArrayLike] = None,
+        transform: Optional[ArrayLike] = None,
+        subdivisions: Integer = 3,
+        mutable: bool = True,
     ):
         """
         Create a Sphere Primitive, a subclass of Trimesh.
 
         Parameters
         ----------
-        radius : float
+        radius
           Radius of sphere
         center : None or (3,) float
           Center of sphere.
         transform : None or (4, 4) float
           Full homogeneous transform. Pass `center` OR `transform.
-        subdivisions : int
+        subdivisions
           Number of subdivisions for icosphere.
-        mutable : bool
+        mutable
           Are extents and transform mutable after creation.
         """
 
         super().__init__()
 
-        defaults = {"radius": 1.0, "transform": np.eye(4), "subdivisions": 3}
         constructor = {"radius": float(radius), "subdivisions": int(subdivisions)}
         # center is a helper method for "transform"
         # since a sphere is rotationally symmetric
@@ -586,7 +592,10 @@ class Sphere(Primitive):
 
         # create the attributes object
         self.primitive = PrimitiveAttributes(
-            self, defaults=defaults, kwargs=constructor, mutable=mutable
+            self,
+            defaults={"radius": 1.0, "transform": np.eye(4), "subdivisions": 3},
+            kwargs=constructor,
+            mutable=mutable,
         )
 
     @property


Test patch:
diff --git a/tests/test_primitives.py b/tests/test_primitives.py
index 7983cf60..af57b910 100644
--- a/tests/test_primitives.py
+++ b/tests/test_primitives.py
@@ -330,6 +330,29 @@ class PrimitiveTest(g.unittest.TestCase):
             assert g.np.allclose(box.center_mass, box_copy.center_mass)
             assert box.metadata["foo"] == box_copy.metadata["foo"]
 
+    def test_sphere_subdivisions(self):
+        # make sure we don't subdivide when asked not to
+        a = g.trimesh.creation.icosphere(subdivisions=0)
+        assert a.faces.shape == g.trimesh.creation.icosahedron().faces.shape
+
+        # now apply the subdivisions ourself
+        a = a.subdivide().subdivide()
+
+        # should have the same subdivisions as manually doing this
+        b = g.trimesh.primitives.Sphere(subdivisions=2)
+
+        assert a.faces.shape == b.faces.shape
+
+    def test_sphere_subdivisions_radius(self):
+        tf = list(g.random_transforms(4))
+        for r in [1e-4, 1.1, 3, 1213.22]:
+            for subd in range(4):
+                s = g.trimesh.primitives.Sphere(
+                    radius=r, transform=tf[subd], subdivisions=subd
+                )
+                rc = g.np.linalg.norm(s.vertices - s.center_mass, axis=1)
+                assert g.np.isclose(rc.mean(), r)
+
 
 if __name__ == "__main__":
     g.trimesh.util.attach_to_log()