Repository: pygfx/pygfx (python)

Issue:
`Geometry` accepts arrays with size 0
Came across this when working with real data, I have empty arrays with a shape, but `size = 0`.

It is possible to create this line, but methods that rely on computing the bounding box will fail since numpy raises an exception if the array size is zero.

I have two ideas:
1. Raise an exception if an array of size 0 is given to `Geometry`.
2. Allow Geometry with zero sized arrays, but ignore them when computing the bounding box. (I think I like this option)

Happy to PR for either :) 

This may be seen as an edge case, but libraries like matplotlib handle this just fine, it seems like it's using option #2. It doesn't raise an exception when creating a line with an empty array and displays a blank plot. If multiple lines are plotted it can still display the rest w.r.t. the bounding box of all the lines in the plot, so it must be ignoring the zero-size array's bounding box.

Example:

```python
import pygfx as gfx

positions = np.reshape([], (0, 3)).astype(np.float32)
print(f"shape: {positions.shape}, size: {positions.size}")
# shape: (0, 3), size: 0

line = gfx.Line(
    gfx.Geometry(positions=positions),
    gfx.LineMaterial(thickness=3, color="w"),
)
# everything works up to this point

# relies on `camera.show_object`, so it will not work.
gfx.show(line)
```

```python
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[111], line 11
      4 print(f"shape: {positions.shape}, size: {positions.size}")
      6 line = gfx.Line(
      7     gfx.Geometry(positions=positions),
      8     gfx.LineMaterial(thickness=3, color="w"),
      9 )
---> 11 gfx.show(line)

File ~/repos/pygfx/pygfx/utils/show.py:271, in show(object, up, canvas, renderer, controller, camera, before_render, after_render, draw_function)
    210 def show(
    211     object: WorldObject,
    212     up=None,
   (...)
    220     draw_function=None,
    221 ):
    222     """Display a WorldObject
    223 
    224     This function provides you with the basic scaffolding to visualize a given
   (...)
    266 
    267     """
    269     Display(
    270         canvas, renderer, controller, camera, before_render, after_render, draw_function
--> 271     ).show(object, up)

File ~/repos/pygfx/pygfx/utils/show.py:192, in Display.show(self, object, up)
    190 else:
    191     self.camera = PerspectiveCamera(70, 4 / 3)
--> 192     self.camera.show_object(object, up=up, scale=1.4)
    193 if custom_scene:
    194     cam_has_light = next(self.camera.iter(lambda x: isinstance(x, Light)), None)

File ~/repos/pygfx/pygfx/cameras/_perspective.py:367, in PerspectiveCamera.show_object(self, target, view_dir, up, scale)
    365 # Get bounding sphere from target
    366 if isinstance(target, WorldObject):
--> 367     bsphere = target.get_world_bounding_sphere()
    368     if bsphere is None:
    369         raise ValueError(
    370             "Given target does not have a bounding sphere, you should probably just provide a sphere (x, y, z, r) yourself."
    371         )

File ~/repos/pygfx/pygfx/objects/_base.py:510, in WorldObject.get_world_bounding_sphere(self)
    501 def get_world_bounding_sphere(self):
    502     """Bounding Sphere in world space.
    503 
    504     Returns
   (...)
    508 
    509     """
--> 510     return la.aabb_to_sphere(self.get_world_bounding_box())

File ~/repos/pygfx/pygfx/objects/_base.py:499, in WorldObject.get_world_bounding_box(self)
    490 def get_world_bounding_box(self):
    491     """Axis aligned bounding box in world space.
    492 
    493     Returns
   (...)
    497 
    498     """
--> 499     return la.aabb_transform(self.get_bounding_box(), self.world.matrix)

File ~/repos/pygfx/pygfx/objects/_base.py:472, in WorldObject.get_bounding_box(self)
    470     partial_aabb[idx] = la.aabb_transform(aabb, trafo)
    471 if include_self:
--> 472     partial_aabb[-1] = self.geometry.bounding_box()
    474 final_aabb = np.zeros((2, 3), dtype=float)
    475 final_aabb[0] = np.min(partial_aabb[:, 0, :], axis=0)

File ~/repos/pygfx/pygfx/geometries/_base.py:119, in Geometry.bounding_box(self)
    117     return self._aabb
    118 pos = self.positions.data
--> 119 self._aabb = np.array([np.nanmin(pos, axis=0), np.nanmax(pos, axis=0)])
    120 # If positions contains xy, but not z, assume z=0
    121 if self._aabb.shape[1] == 2:

File <__array_function__ internals>:180, in nanmin(*args, **kwargs)

File ~/python-venvs/mescore/lib/python3.11/site-packages/numpy/lib/nanfunctions.py:343, in nanmin(a, axis, out, keepdims, initial, where)
    338     kwargs['where'] = where
    340 if type(a) is np.ndarray and a.dtype != np.object_:
    341     # Fast, but not safe for subclasses of ndarray, or object arrays,
    342     # which do not implement isnan (gh-9009), or fmin correctly (gh-8975)
--> 343     res = np.fmin.reduce(a, axis=axis, out=out, **kwargs)
    344     if np.isnan(res).any():
    345         warnings.warn("All-NaN slice encountered", RuntimeWarning,
    346                       stacklevel=3)

ValueError: zero-size array to reduction operation fmin which has no identity
```

Pull request:
Fix bounding box
Closes #549 

In particular, allow objects to return `None` to not participate in the bounding box.

This fixes a problem where repeated calls to `camera.show_object(scene)` (or `d.camera.show_object(d.scene)`)  caused the camera to increasingly zoom out.

This somewhat relates to #560, because when new data is added to a scene, you may want to reset the view repeatedly.

Patch:
diff --git a/examples/feature_demo/world_bounding_box.py b/examples/feature_demo/world_bounding_box.py
index f1d5c69..8a43775 100644
--- a/examples/feature_demo/world_bounding_box.py
+++ b/examples/feature_demo/world_bounding_box.py
@@ -34,7 +34,7 @@ camera = gfx.PerspectiveCamera(70, 16 / 9)
 camera.show_object(scene, view_dir=(-1, -1, -1), up=(0, 0, 1))
 controller = gfx.OrbitController(camera, register_events=renderer)
 
-print("World bounding box:", scene.get_world_bounding_box())
+print("World bounding box:\n", scene.get_world_bounding_box())
 
 
 def animate():
diff --git a/pygfx/cameras/_perspective.py b/pygfx/cameras/_perspective.py
index 80c0447..48537ef 100644
--- a/pygfx/cameras/_perspective.py
+++ b/pygfx/cameras/_perspective.py
@@ -388,7 +388,7 @@ class PerspectiveCamera(Camera):
 
         # Do the math ...
         view_pos = bsphere[:3]
-        radius = bsphere[3]
+        radius = max(0.0, bsphere[3]) or 1.0
         extent = radius * 2 * scale
         distance = fov_distance_factor(self.fov) * extent
 
diff --git a/pygfx/geometries/_base.py b/pygfx/geometries/_base.py
index 108af60..7966d4e 100644
--- a/pygfx/geometries/_base.py
+++ b/pygfx/geometries/_base.py
@@ -98,7 +98,7 @@ class Geometry(Trackable):
         x.extend(dict.keys(self._store))
         return x
 
-    def bounding_box(self):
+    def get_bounding_box(self):
         """Compute the axis-aligned bounding box.
 
         Computes the aabb based on either positions or the shape of the grid
@@ -107,23 +107,35 @@ class Geometry(Trackable):
 
         Returns
         -------
-        aabb : ndarray, [2, 3]
+        aabb : ndarray, [2, 3] or None
             The axis-aligned bounding box given by the "smallest" (lowest value)
-            and "largest" (highest value) corners.
+            and "largest" (highest value) corners. Is None when the geometry has
+            no finite positions.
 
         """
         if hasattr(self, "positions"):
             if self._aabb_rev == self.positions.rev:
                 return self._aabb
+            aabb = None
+            # Get positions and check expected shape
             pos = self.positions.data
-            self._aabb = np.array([np.nanmin(pos, axis=0), np.nanmax(pos, axis=0)])
-            # If positions contains xy, but not z, assume z=0
-            if self._aabb.shape[1] == 2:
-                self._aabb = np.column_stack([self._aabb, np.zeros((2, 1), np.float32)])
+            if pos.ndim == 2 and pos.shape[1] in (2, 3):
+                # Select finite positions
+                finite_mask = np.isfinite(pos).all(axis=1)
+                if finite_mask.sum() > 0:
+                    # Construct aabb
+                    pos_finite = pos[finite_mask]
+                    aabb = np.array(
+                        [pos_finite.min(axis=0), pos_finite.max(axis=0)], np.float32
+                    )
+                    # If positions contains xy, but not z, assume z=0
+                    if aabb.shape[1] == 2:
+                        aabb = np.column_stack([aabb, np.zeros((2, 1), np.float32)])
+            self._aabb = aabb
             self._aabb_rev = self.positions.rev
             return self._aabb
 
-        if hasattr(self, "grid"):
+        elif hasattr(self, "grid"):
             if self._aabb_rev == self.grid.rev:
                 return self._aabb
             # account for multi-channel image data
@@ -141,12 +153,10 @@ class Geometry(Trackable):
             self._aabb = aabb
             self._aabb_rev = self.grid.rev
             return self._aabb
+        else:
+            return None
 
-        raise ValueError(
-            "No positions or grid buffer available for bounding volume computation"
-        )
-
-    def bounding_sphere(self):
+    def get_bounding_sphere(self):
         """Compute a bounding sphere.
 
         Uses the geometry's axis-aligned bounding box, to estimate a sphere
@@ -154,8 +164,9 @@ class Geometry(Trackable):
 
         Returns
         -------
-        sphere : ndarray, [4]
+        sphere : ndarray, [4] or None
             A sphere given by it's center and radius. Format: ``(x, y, z, radius)``.
+            Is None when the geometry has no finite positions.
 
         Notes
         -----
@@ -163,9 +174,38 @@ class Geometry(Trackable):
         be the minimally binding sphere.
 
         """
-        if self._bsphere is not None and self._bsphere_rev == self._aabb_rev:
+
+        if hasattr(self, "positions"):
+            if self._bsphere_rev == self.positions.rev:
+                return self._bsphere
+            bsphere = None
+            # Get positions and check expected shape
+            pos = self.positions.data
+            if pos.ndim == 2 and pos.shape[1] in (2, 3):
+                # Select finite positions
+                finite_mask = np.isfinite(pos).all(axis=1)
+                if finite_mask.sum() > 0:
+                    # Construct aabb
+                    pos_finite = pos[finite_mask]
+                    center = pos_finite.mean(axis=0)
+                    distances = np.linalg.norm(pos_finite - center, axis=0)
+                    radius = float(distances.max())
+                    if len(center) == 2:
+                        bsphere = np.array(
+                            [center[0], center[1], 0.0, radius], np.float32
+                        )
+                    else:
+                        bsphere = np.array(
+                            [center[0], center[1], center[1], radius], np.float32
+                        )
+            self._bsphere = bsphere
+            self._bsphere_rev = self.positions.rev
             return self._bsphere
 
-        self._bsphere = la.aabb_to_sphere(self.bounding_box())
-        self._bsphere_rev = self._aabb_rev
-        return self._bsphere
+        else:
+            if self._bsphere_rev == self._aabb_rev:
+                return self._bsphere
+            aabb = self.get_bounding_box()
+            self._bsphere = None if aabb is None else la.aabb_to_sphere(aabb)
+            self._bsphere_rev = self._aabb_rev
+            return self._bsphere
diff --git a/pygfx/geometries/_text.py b/pygfx/geometries/_text.py
index adfcf00..b6b183f 100644
--- a/pygfx/geometries/_text.py
+++ b/pygfx/geometries/_text.py
@@ -549,15 +549,20 @@ class TextGeometry(Geometry):
 
     # %%%%% Layout
 
-    def bounding_box(self):
+    def get_bounding_box(self):
         if self.screen_space:
-            # The space occupied by the text is essentially a point
+            # There is no sensible bounding box for text in screen
+            # space, except for the anchor point. Although the point
+            # has no volume, it does contribute to e.g. the scene's
+            # bounding box.
             return np.array([[0, 0, 0], [0, 0, 0]], np.float32)
         else:
             if self._aabb_rev == self.positions.rev:
                 return self._aabb
             pos = self.positions.data
-            aabb_2d = np.array([pos.min(axis=0), pos.max(axis=0)], np.float32)
+            aabb_2d = np.array(
+                [np.nanmin(pos, axis=0), np.nanmax(pos, axis=0)], np.float32
+            )
             self._aabb[1, 0] += self.font_size  # positions do not include char width
             self._aabb = np.column_stack([aabb_2d, np.zeros((2, 1), np.float32)])
             self._aabb_rev = self.positions.rev
diff --git a/pygfx/helpers/_box.py b/pygfx/helpers/_box.py
index 317b301..bbba394 100644
--- a/pygfx/helpers/_box.py
+++ b/pygfx/helpers/_box.py
@@ -137,7 +137,7 @@ class BoxHelper(Line):
         if space == "world":
             aabb = object.get_world_bounding_box()
         elif space == "local" and object.geometry is not None:
-            aabb = object.geometry.bounding_box()
+            aabb = object.geometry.get_bounding_box()
         if aabb is None:
             raise ValueError(
                 "No bounding box could be determined "
diff --git a/pygfx/helpers/_gizmo.py b/pygfx/helpers/_gizmo.py
index e262057..f8755ff 100644
--- a/pygfx/helpers/_gizmo.py
+++ b/pygfx/helpers/_gizmo.py
@@ -288,7 +288,7 @@ class TransformGizmo(WorldObject):
 
         # work out local extent
         self._local_extents = np.empty((6, 3), dtype=float)
-        scales = self.get_bounding_box().ravel()
+        scales = self.get_bounding_box().ravel()  # we know our bb is not None
         self._local_extents[:3] = np.diag(scales[3:])
         self._local_extents[3:] = np.diag(scales[:3])
 
diff --git a/pygfx/objects/_base.py b/pygfx/objects/_base.py
index 99ab31f..16d6e01 100644
--- a/pygfx/objects/_base.py
+++ b/pygfx/objects/_base.py
@@ -452,28 +452,32 @@ class WorldObject(EventTarget, RootTrackable):
 
         Returns
         -------
-        aabb : ndarray, [2, 3]
-            An axis-aligned bounding box.
-
+        aabb : ndarray, [2, 3] or None
+            An axis-aligned bounding box, or None when the object does
+            not take up a particular space.
         """
-        include_self = self.geometry is not None
-        n_partials = len(self.children) + int(include_self)
-
-        if n_partials == 0:
-            # empty object with no mesh
-            return np.zeros((2, 3), dtype=float)
 
-        partial_aabb = np.zeros((n_partials, 2, 3), dtype=float)
-        for idx, child in enumerate(self.children):
+        # Collect bounding boxes
+        aabbs = []
+        for child in self.children:
             aabb = child.get_bounding_box()
-            trafo = child.local.matrix
-            partial_aabb[idx] = la.aabb_transform(aabb, trafo)
-        if include_self:
-            partial_aabb[-1] = self.geometry.bounding_box()
-
-        final_aabb = np.zeros((2, 3), dtype=float)
-        final_aabb[0] = np.min(partial_aabb[:, 0, :], axis=0)
-        final_aabb[1] = np.max(partial_aabb[:, 1, :], axis=0)
+            if aabb is not None:
+                trafo = child.local.matrix
+                aabbs.append(la.aabb_transform(aabb, trafo))
+        if self.geometry is not None:
+            aabb = self.geometry.get_bounding_box()
+            if aabb is not None:
+                aabbs.append(aabb)
+
+        # Combine
+        if aabbs:
+            aabbs = np.stack(aabbs)
+            final_aabb = np.zeros((2, 3), dtype=float)
+            final_aabb[0] = np.min(aabbs[:, 0, :], axis=0)
+            final_aabb[1] = np.max(aabbs[:, 1, :], axis=0)
+        else:
+            final_aabb = None
+
         return final_aabb
 
     def get_bounding_sphere(self):
@@ -481,33 +485,39 @@ class WorldObject(EventTarget, RootTrackable):
 
         Returns
         -------
-        bounding_shere : ndarray, [4]
-            A sphere (x, y, z, radius).
+        bounding_shere : ndarray, [4] or None
+            A sphere (x, y, z, radius), or None when the object does
+            not take up a particular space.
 
         """
-        return la.aabb_to_sphere(self.get_bounding_box())
+        aabb = self.get_bounding_box()
+        return None if aabb is None else la.aabb_to_sphere(aabb)
 
     def get_world_bounding_box(self):
         """Axis aligned bounding box in world space.
 
         Returns
         -------
-        aabb : ndarray, [2, 3]
-            The transformed axis-aligned bounding box.
+        aabb : ndarray, [2, 3] or None
+            The transformed axis-aligned bounding box, or None when the
+            object does not take up a particular space.
 
         """
-        return la.aabb_transform(self.get_bounding_box(), self.world.matrix)
+        aabb = self.get_bounding_box()
+        return None if aabb is None else la.aabb_transform(aabb, self.world.matrix)
 
     def get_world_bounding_sphere(self):
         """Bounding Sphere in world space.
 
         Returns
         -------
-        bounding_shere : ndarray, [4]
-            A sphere (x, y, z, radius).
+        bounding_shere : ndarray, [4] or None
+            A sphere (x, y, z, radius), or None when the object does
+            not take up a particular space.
 
         """
-        return la.aabb_to_sphere(self.get_world_bounding_box())
+        aabb = self.get_world_bounding_box()
+        return None if aabb is None else la.aabb_to_sphere(aabb)
 
     def _wgpu_get_pick_info(self, pick_value):
         # In most cases the material handles this.


Test patch:
diff --git a/tests/cameras/test_cameras.py b/tests/cameras/test_cameras.py
index 23321b8..e0709c3 100644
--- a/tests/cameras/test_cameras.py
+++ b/tests/cameras/test_cameras.py
@@ -200,3 +200,35 @@ def test_frustum():
     frustum_corners = camera.frustum
 
     assert np.allclose(frustum_corners, expected_corners.reshape(2, 4, 3))
+
+
+def test_repeated_show():
+    # Repeated calls to show_object should keep the same camera
+    # position. At some point it did not, because the camera contributed
+    # to the scene's bounding box, and since it's positioned away from
+    # the (rest of the) scene, repeated calles, just made the scene's
+    # bounding box bigger.
+
+    mesh = gfx.Mesh(
+        gfx.sphere_geometry(),
+        gfx.MeshPhongMaterial(),
+    )
+    camera = gfx.PerspectiveCamera()
+
+    scene = gfx.Scene()
+    scene.add(mesh, camera.add(gfx.DirectionalLight()))
+
+    camera.show_object(scene)
+
+    cam_width = camera.width
+    scene_radius = scene.get_world_bounding_sphere()[3]
+
+    # If you want to run this test interactively, you can uncommentt below lines
+    # d= gfx.Display(camera=camera)
+    # d.show(scene, )
+
+    for _ in range(9):
+        camera.show_object(scene)
+
+    assert cam_width == camera.width
+    assert scene_radius == scene.get_world_bounding_sphere()[3]
diff --git a/tests/geometries/test_bounding_box.py b/tests/geometries/test_bounding_box.py
new file mode 100644
index 0000000..bbbb4ed
--- /dev/null
+++ b/tests/geometries/test_bounding_box.py
@@ -0,0 +1,51 @@
+import pygfx as gfx
+import numpy as np
+import pylinalg as la
+
+
+def test_bounding_box():
+    pos = np.array([(0, 0, 0), (1, 1, 1), (3, 3, 3)], np.float32)
+    geo = gfx.Geometry(positions=pos)
+    assert geo.get_bounding_box().tolist() == [[0, 0, 0], [3, 3, 3]]
+
+    pos = np.array([(0, 1, 3), (3, 0, 1), (1, 3, 0)], np.float32)
+    geo = gfx.Geometry(positions=pos)
+    assert geo.get_bounding_box().tolist() == [[0, 0, 0], [3, 3, 3]]
+
+    pos = np.array([(1, 1, 1), (1, 1, 1), (1, 1, 1)], np.float32)
+    geo = gfx.Geometry(positions=pos)
+    assert geo.get_bounding_box().tolist() == [[1, 1, 1], [1, 1, 1]]
+
+    pos = np.array([(0, 1, 2), (0, 1, 2), (0, 1, 2)], np.float32)
+    geo = gfx.Geometry(positions=pos)
+    assert geo.get_bounding_box().tolist() == [[0, 1, 2], [0, 1, 2]]
+
+    pos = np.array([(0, 0, np.nan), (1, 1, 1), (2, 2, 2)], np.float32)
+    geo = gfx.Geometry(positions=pos)
+    assert geo.get_bounding_box().tolist() == [[1, 1, 1], [2, 2, 2]]
+
+    pos = np.array([(0, np.inf, 0), (1, 1, 1), (2, 2, 2)], np.float32)
+    geo = gfx.Geometry(positions=pos)
+    assert geo.get_bounding_box().tolist() == [[1, 1, 1], [2, 2, 2]]
+
+    pos = np.array([(-np.inf, 0, 0), (1, 1, 1), (2, 2, 2)], np.float32)
+    geo = gfx.Geometry(positions=pos)
+    assert geo.get_bounding_box().tolist() == [[1, 1, 1], [2, 2, 2]]
+
+    pos = np.zeros((0, 3), np.float32)
+    geo = gfx.Geometry(positions=pos)
+    assert geo.get_bounding_box() is None
+
+    pos = np.array([(-np.inf, 0, 0), (1, np.nan, 1)], np.float32)
+    geo = gfx.Geometry(positions=pos)
+    assert geo.get_bounding_box() is None
+
+
+def test_bounding_sphere():
+    pos = np.array([(0, 0, -8), (1, 1, 1), (2, 2, 10)], np.float32)
+    geo = gfx.Geometry(positions=pos)
+    bsphere = geo.get_bounding_sphere()
+    bsphere_via_aabb = la.aabb_to_sphere(geo.get_bounding_box())
+
+    assert np.allclose(bsphere, [1, 1, 1, 12.7279])
+    assert np.allclose(bsphere_via_aabb, [1, 1, 1, 9.1104])
diff --git a/tests/objects/test_world_object.py b/tests/objects/test_world_object.py
index 135a40c..51527f5 100644
--- a/tests/objects/test_world_object.py
+++ b/tests/objects/test_world_object.py
@@ -330,3 +330,63 @@ def test_reference_up():
     assert np.allclose(obj1.world.reference_up, la.vec_normalize((1, 2, 3)))
     assert np.allclose(obj2.world.reference_up, (isqrt2, 0, isqrt2))
     assert np.allclose(obj3.world.reference_up, (0, 1, 0))
+
+
+def test_bounding_box():
+    scene = gfx.Group()
+
+    # Start out without a bounding box
+
+    assert scene.get_bounding_box() is None
+
+    # Add a camera, still no box
+    scene.add(gfx.PerspectiveCamera())
+    assert scene.get_bounding_box() is None
+
+    # Add a light, still no box
+    scene.add(gfx.DirectionalLight())
+    assert scene.get_bounding_box() is None
+
+    # Add a point, we've got a bbox with no volume
+    ob = gfx.Points(
+        gfx.Geometry(positions=np.array([(0, 0, 0)], np.float32)),
+        gfx.PointsMaterial,
+    )
+    scene.add(ob)
+    assert scene.get_bounding_box().tolist() == [[0, 0, 0], [0, 0, 0]]
+
+    # Add another point to get a larger bbox
+    ob = gfx.Points(
+        gfx.Geometry(positions=np.array([(0, 3, 1)], np.float32)),
+        gfx.PointsMaterial,
+    )
+    scene.add(ob)
+    assert scene.get_bounding_box().tolist() == [[0, 0, 0], [0, 3, 1]]
+
+    # Adding a point with no valid positions ... has no effect
+    ob = gfx.Points(
+        gfx.Geometry(positions=np.array([(99, np.nan, 99)], np.float32)),
+        gfx.PointsMaterial,
+    )
+    scene.add(ob)
+    assert ob.get_bounding_box() is None
+    assert ob.get_world_bounding_box() is None
+    assert scene.get_bounding_box().tolist() == [[0, 0, 0], [0, 3, 1]]
+
+    # Add a point that is transformed, to make sure that is taken into account
+    ob = gfx.Points(
+        gfx.Geometry(positions=np.array([(-1, 0, 2)], np.float32)),
+        gfx.PointsMaterial,
+    )
+    ob.local.scale = 3, 3, 3
+    ob.local.x = -2
+    scene.add(ob)
+    assert scene.get_bounding_box().tolist() == [[-5, 0, 0], [0, 3, 6]]
+
+    # Create a point with all of the above ... to make sure the own geo is taken into account
+    point_with_children = gfx.Points(
+        gfx.Geometry(positions=np.array([(9, 1, 0)], np.float32)),
+        gfx.PointsMaterial,
+    )
+    point_with_children.add(scene)
+    assert point_with_children.get_bounding_box().tolist() == [[-5, 0, 0], [9, 3, 6]]