From abfbf611078a023ce06b1c37a0fa0f8b59a4844c Mon Sep 17 00:00:00 2001 From: jdrotleff <151064457+jdrotleff@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:51:55 +0200 Subject: [PATCH 1/4] Fix squeezed single-particle trajectories in ZnVis This PR fixes a crash that occurs when ZnVis receives a single particle or vector field without an explicit entity dimension, e.g. `(n_times, 3)` instead of `(n_times, 1, 3)`. ZnVis already handled one-entity trajectories correctly when the particle dimension was preserved. The bug only showed up when that dimension was squeezed away. Normalize squeezed particle/vector-field frames with `np.atleast_2d`. Add regression coverage in the existing CI integration tests for: - single-particle trajectories - single-vector-field trajectories --- CI/integration_tests/test_different_shapes.py | 9 ++++++ .../test_simple_vector_fields.py | 10 ++++++ znvis/mesh/arrow.py | 32 +++++++++++-------- znvis/mesh/custom.py | 8 ++++- znvis/mesh/mesh.py | 8 ++++- znvis/particle/particle.py | 10 ++++-- znvis/particle/vector_field.py | 20 +++++------- 7 files changed, 68 insertions(+), 29 deletions(-) diff --git a/CI/integration_tests/test_different_shapes.py b/CI/integration_tests/test_different_shapes.py index cbef2fe..8af6e1f 100644 --- a/CI/integration_tests/test_different_shapes.py +++ b/CI/integration_tests/test_different_shapes.py @@ -113,6 +113,15 @@ def run_process(): vis.Particle(name="Custom", mesh=mesh, position=trajectory, static=True) ) + trajectory = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) + particle_list.append( + vis.Particle( + name="single", + mesh=vis.Sphere(radius=1.0), + position=trajectory, + ) + ) + # Create a bounding box bounding_box = vis.BoundingBox( center=np.array([0, 0, 0]), diff --git a/CI/integration_tests/test_simple_vector_fields.py b/CI/integration_tests/test_simple_vector_fields.py index 7a518e0..bc40877 100644 --- a/CI/integration_tests/test_simple_vector_fields.py +++ b/CI/integration_tests/test_simple_vector_fields.py @@ -110,6 +110,15 @@ def run_process(): "Dynamic", mesh=dyn_mesh, position=dynamic_pos, direction=dynamic_directors ) + position = np.array([[1.0, 2.0, 3.0]]) + direction = np.array([[0.0, 0.0, 1.0]]) + single_particle_field = vis.VectorField( + "single", + mesh=vis.Arrow(scale=1.0, material=Material()), + position=position, + direction=direction, + ) + # Create a bounding box bounding_box = vis.BoundingBox( center=np.array([0, 0, 0]), @@ -123,6 +132,7 @@ def run_process(): vector_field=[ static_field, dynamic_field, + single_particle_field, ], frame_rate=20, bounding_box=bounding_box, diff --git a/znvis/mesh/arrow.py b/znvis/mesh/arrow.py index 87b7f8c..a1cb91a 100644 --- a/znvis/mesh/arrow.py +++ b/znvis/mesh/arrow.py @@ -47,22 +47,28 @@ class Arrow(Mesh): scale: float = 1.0 resolution: int = 10 - def instantiate_mesh( - self, starting_position: np.ndarray, starting_orientation: np.ndarray = None - ) -> o3d.geometry.TriangleMesh: + def instantiate_mesh( + self, starting_position: np.ndarray, starting_orientation: np.ndarray = None + ) -> o3d.geometry.TriangleMesh: """ Create and correctly orient an arrow mesh. Overwrites the parent class """ - mesh = self.create_mesh(starting_orientation) - mesh.compute_vertex_normals() - if starting_orientation is not None: - matrix = rotation_matrix(np.array([0, 0, 1]), starting_orientation) - mesh.rotate(matrix, center=(0, 0, 0)) - - # Translate the arrow to the starting position and center the origin - mesh.translate(starting_position.astype(float)) - - return mesh + mesh = self.create_mesh(starting_orientation) + mesh.compute_vertex_normals() + if starting_orientation is not None: + matrix = rotation_matrix(np.array([0, 0, 1]), starting_orientation) + mesh.rotate(matrix, center=(0, 0, 0)) + + # Translate the arrow to the starting position and center the origin + starting_position = np.asarray(starting_position, dtype=float).reshape(-1) + if starting_position.size != 3: + raise ValueError( + "starting_position must describe exactly one 3D point, got " + f"shape {starting_position.shape}." + ) + mesh.translate(starting_position) + + return mesh def create_mesh(self, direction: np.ndarray) -> o3d.geometry.TriangleMesh: """ diff --git a/znvis/mesh/custom.py b/znvis/mesh/custom.py index 7d847b3..cfd556d 100644 --- a/znvis/mesh/custom.py +++ b/znvis/mesh/custom.py @@ -57,7 +57,13 @@ def instantiate_mesh( mesh = o3d.io.read_triangle_mesh(self.file) mesh.compute_vertex_normals() mesh.scale(self.scale, center=mesh.get_center()) - mesh.translate(starting_position.astype(float)) + starting_position = np.asarray(starting_position, dtype=float).reshape(-1) + if starting_position.size != 3: + raise ValueError( + "starting_position must describe exactly one 3D point, got " + f"shape {starting_position.shape}." + ) + mesh.translate(starting_position) if starting_orientation is not None: matrix = rotation_matrix(self.base_direction, starting_orientation) mesh.rotate(matrix) diff --git a/znvis/mesh/mesh.py b/znvis/mesh/mesh.py index 0d76e80..bb88bb2 100644 --- a/znvis/mesh/mesh.py +++ b/znvis/mesh/mesh.py @@ -100,7 +100,13 @@ def instantiate_mesh( """ mesh = self.create_mesh() mesh.compute_vertex_normals() - mesh.translate(starting_position.astype(float)) + starting_position = np.asarray(starting_position, dtype=float).reshape(-1) + if starting_position.size != 3: + raise ValueError( + "starting_position must describe exactly one 3D point, got " + f"shape {starting_position.shape}." + ) + mesh.translate(starting_position) if starting_orientation is not None: matrix = rotation_matrix(self.base_direction, starting_orientation) mesh.rotate(matrix) diff --git a/znvis/particle/particle.py b/znvis/particle/particle.py index 43e5e6e..161f660 100644 --- a/znvis/particle/particle.py +++ b/znvis/particle/particle.py @@ -193,9 +193,11 @@ def construct_mesh_list(self): for frame_index in track( range(n_time_steps), description=f"Building {self.name} Mesh" ): - frame_pos = self.position[frame_index] + frame_pos = np.atleast_2d(self.position[frame_index]) frame_dir = ( - self.director[frame_index] if self.director is not None else None + np.atleast_2d(self.director[frame_index]) + if self.director is not None + else None ) n_particles = frame_pos.shape[0] meshes = [] @@ -246,6 +248,10 @@ def get_mesh_for_frame(self, frame_index: int): if frame_dir is not None: frame_dir = frame_dir[idx] + frame_pos = np.atleast_2d(frame_pos) + if frame_dir is not None: + frame_dir = np.atleast_2d(frame_dir) + n_particles = frame_pos.shape[0] meshes = [] time_index = 0 if self.static else frame_index diff --git a/znvis/particle/vector_field.py b/znvis/particle/vector_field.py index 963b572..33da2a4 100644 --- a/znvis/particle/vector_field.py +++ b/znvis/particle/vector_field.py @@ -115,10 +115,8 @@ def construct_mesh_list(self): try: if not self.static: - n_particles = int(self.position.shape[1]) n_time_steps = int(self.position.shape[0]) else: - n_particles = int(self.position.shape[0]) n_time_steps = 1 self.position = self.position[np.newaxis, :, :] self.direction = self.direction[np.newaxis, :, :] @@ -131,19 +129,15 @@ def construct_mesh_list(self): new_mesh = True for i in track(range(n_time_steps), description=f"Building {self.name} Mesh"): - for j in range(n_particles): - if ( - np.max(np.abs(self.direction[i][j])) > 0 - ): # ignore vectors with length zero + pos_frame = np.atleast_2d(self.position[i]) + dir_frame = np.atleast_2d(self.direction[i]) + for j in range(pos_frame.shape[0]): + if np.max(np.abs(dir_frame[j])) > 0: # ignore vectors with length zero if new_mesh is True: - mesh = self._create_mesh( - self.position[i][j], self.direction[i][j], i, j - ) + mesh = self._create_mesh(pos_frame[j], dir_frame[j], i, j) new_mesh = False else: - mesh += self._create_mesh( - self.position[i][j], self.direction[i][j], i, j - ) + mesh += self._create_mesh(pos_frame[j], dir_frame[j], i, j) new_mesh = True @@ -172,6 +166,8 @@ def get_mesh_for_frame(self, frame_index: int): dir_frame = self.direction[frame_index] time_index = frame_index + pos_frame = np.atleast_2d(pos_frame) + dir_frame = np.atleast_2d(dir_frame) n_particles = int(pos_frame.shape[0]) new_mesh = True mesh = None From 3ee3cf88487edbe61d9fe7a3acd8b2d08724923a Mon Sep 17 00:00:00 2001 From: jdrotleff <151064457+jdrotleff@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:58:58 +0200 Subject: [PATCH 2/4] Add explicit empty-input guard for vector fields --- znvis/particle/vector_field.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/znvis/particle/vector_field.py b/znvis/particle/vector_field.py index 33da2a4..1de868d 100644 --- a/znvis/particle/vector_field.py +++ b/znvis/particle/vector_field.py @@ -108,14 +108,16 @@ def construct_mesh_list(self): """ self.mesh_list = [] - if self.position is None: - raise ValueError("Position data cannot be None.") - if self.direction is None: - raise ValueError("Director data cannot be None.") - - try: - if not self.static: - n_time_steps = int(self.position.shape[0]) + if self.position is None: + raise ValueError("Position data cannot be None.") + if self.direction is None: + raise ValueError("Director data cannot be None.") + if self.position.size == 0 or self.direction.size == 0: + raise IndexError("The provided data has an incompatible shape.") + + try: + if not self.static: + n_time_steps = int(self.position.shape[0]) else: n_time_steps = 1 self.position = self.position[np.newaxis, :, :] From 7b54ed38d703d727ef40c7eb181df9fa70a753c7 Mon Sep 17 00:00:00 2001 From: jdrotleff <151064457+jdrotleff@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:11:00 +0200 Subject: [PATCH 3/4] Run pre-commit --- znvis/mesh/arrow.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/znvis/mesh/arrow.py b/znvis/mesh/arrow.py index a1cb91a..4cdb6d1 100644 --- a/znvis/mesh/arrow.py +++ b/znvis/mesh/arrow.py @@ -47,28 +47,28 @@ class Arrow(Mesh): scale: float = 1.0 resolution: int = 10 - def instantiate_mesh( - self, starting_position: np.ndarray, starting_orientation: np.ndarray = None - ) -> o3d.geometry.TriangleMesh: + def instantiate_mesh( + self, starting_position: np.ndarray, starting_orientation: np.ndarray = None + ) -> o3d.geometry.TriangleMesh: """ Create and correctly orient an arrow mesh. Overwrites the parent class """ - mesh = self.create_mesh(starting_orientation) - mesh.compute_vertex_normals() - if starting_orientation is not None: - matrix = rotation_matrix(np.array([0, 0, 1]), starting_orientation) - mesh.rotate(matrix, center=(0, 0, 0)) - - # Translate the arrow to the starting position and center the origin - starting_position = np.asarray(starting_position, dtype=float).reshape(-1) - if starting_position.size != 3: - raise ValueError( - "starting_position must describe exactly one 3D point, got " - f"shape {starting_position.shape}." - ) - mesh.translate(starting_position) - - return mesh + mesh = self.create_mesh(starting_orientation) + mesh.compute_vertex_normals() + if starting_orientation is not None: + matrix = rotation_matrix(np.array([0, 0, 1]), starting_orientation) + mesh.rotate(matrix, center=(0, 0, 0)) + + # Translate the arrow to the starting position and center the origin + starting_position = np.asarray(starting_position, dtype=float).reshape(-1) + if starting_position.size != 3: + raise ValueError( + "starting_position must describe exactly one 3D point, got " + f"shape {starting_position.shape}." + ) + mesh.translate(starting_position) + + return mesh def create_mesh(self, direction: np.ndarray) -> o3d.geometry.TriangleMesh: """ From d6f482eabcaeb526fb554901480a3c0bd808ea8b Mon Sep 17 00:00:00 2001 From: jdrotleff <151064457+jdrotleff@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:54:33 +0200 Subject: [PATCH 4/4] Adapt to latest main status --- .../test_simple_vector_fields.py | 19 +++++++++++++++---- CI/unit_tests/particle/test_particle.py | 15 +++++++++++++++ CI/unit_tests/particle/test_vector_fields.py | 19 ++++++++++++++++--- znvis/particle/particle.py | 12 ++++++++++++ znvis/particle/vector_field.py | 16 ++++++++++------ 5 files changed, 68 insertions(+), 13 deletions(-) diff --git a/CI/integration_tests/test_simple_vector_fields.py b/CI/integration_tests/test_simple_vector_fields.py index bc40877..7914d4c 100644 --- a/CI/integration_tests/test_simple_vector_fields.py +++ b/CI/integration_tests/test_simple_vector_fields.py @@ -110,13 +110,23 @@ def run_process(): "Dynamic", mesh=dyn_mesh, position=dynamic_pos, direction=dynamic_directors ) - position = np.array([[1.0, 2.0, 3.0]]) - direction = np.array([[0.0, 0.0, 1.0]]) + single_position = np.array([[[1.0, 2.0, 3.0]]]) + single_direction = np.array([[[0.0, 1.0, 0.0]]]) + single_dynamic_position = np.repeat(single_position, n_frames, axis=0) + single_dynamic_direction = np.repeat(single_direction, n_frames, axis=0) + single_dynamic_field = vis.VectorField( + "single_dynamic", + mesh=vis.Arrow(scale=1.0, material=Material()), + position=single_dynamic_position, + direction=single_dynamic_direction, + ) + single_particle_field = vis.VectorField( "single", mesh=vis.Arrow(scale=1.0, material=Material()), - position=position, - direction=direction, + position=single_position + 1, + direction=single_direction, + static=True, ) # Create a bounding box @@ -132,6 +142,7 @@ def run_process(): vector_field=[ static_field, dynamic_field, + single_dynamic_field, single_particle_field, ], frame_rate=20, diff --git a/CI/unit_tests/particle/test_particle.py b/CI/unit_tests/particle/test_particle.py index 742ad8e..b6cf998 100644 --- a/CI/unit_tests/particle/test_particle.py +++ b/CI/unit_tests/particle/test_particle.py @@ -214,6 +214,21 @@ def test_construct_nan_mesh_list(self): str(context.exception), ) + def test_construct_squeezed_dynamic_mesh_list(self): + """ + Test that squeezed dynamic particle data is normalized. + """ + squeezed_particle = Particle( + name="squeezed_particle", + position=np.random.uniform(-5, 5, (10, 3)), + director=np.random.uniform(-5, 5, (10, 3)), + mesh=Sphere(), + ) + squeezed_particle.construct_mesh_list() + self.assertEqual(np.asarray(squeezed_particle.position).shape, (10, 1, 3)) + self.assertEqual(np.asarray(squeezed_particle.director).shape, (10, 1, 3)) + self.assertEqual(len(squeezed_particle.mesh_list), 10) + class TestVariableParticleCount(unittest.TestCase): """ diff --git a/CI/unit_tests/particle/test_vector_fields.py b/CI/unit_tests/particle/test_vector_fields.py index b48c13f..9641c26 100644 --- a/CI/unit_tests/particle/test_vector_fields.py +++ b/CI/unit_tests/particle/test_vector_fields.py @@ -199,9 +199,7 @@ def test_construct_empty_mesh_dict(self): with self.assertRaises(IndexError) as context: self.empty_vector_field.construct_mesh_list() # Check if error message is correct - self.assertEqual( - str(context.exception), "The provided data has an incompatible shape." - ) + self.assertIn("incompatible shape", str(context.exception)) def test_construct_nan_mesh_dict(self): """ @@ -248,6 +246,21 @@ def test_construct_none_dir_mesh_dict(self): # Check if error message is correct self.assertEqual(str(context.exception), "Director data cannot be None.") + def test_construct_squeezed_dynamic_mesh_dict(self): + """ + Test that squeezed dynamic vector field data is normalized. + """ + squeezed_field = VectorField( + name="squeezed_vector_field", + position=np.random.uniform(-5, 5, (10, 3)), + direction=np.random.uniform(-5, 5, (10, 3)), + mesh=Arrow(scale=10, material=self.material), + ) + squeezed_field.construct_mesh_list() + self.assertEqual(squeezed_field.position.shape, (10, 1, 3)) + self.assertEqual(squeezed_field.direction.shape, (10, 1, 3)) + self.assertEqual(len(squeezed_field.mesh_list), 10) + class TestVariableVectorFieldCount(unittest.TestCase): """ diff --git a/znvis/particle/particle.py b/znvis/particle/particle.py index 27bcab8..7690c01 100644 --- a/znvis/particle/particle.py +++ b/znvis/particle/particle.py @@ -134,8 +134,20 @@ def construct_mesh_list(self): "-------\nWARNING: The provided director array is empty." "Setting to None.\n-------", ) + if not self.static and self.position.ndim not in (2, 3): + raise IndexError("The provided data has an incompatible shape.") + if ( + not self.static + and self.director is not None + and self.director.ndim not in (2, 3) + ): + raise IndexError("The provided data has an incompatible shape.") # Static case differentiation if not self.static: + if self.position.ndim == 2: + self.position = self.position[:, np.newaxis, :] + if self.director is not None and self.director.ndim == 2: + self.director = self.director[:, np.newaxis, :] n_time_steps = self.position.shape[0] self.position = [self.position[i] for i in range(n_time_steps)] if self.director is not None: diff --git a/znvis/particle/vector_field.py b/znvis/particle/vector_field.py index b842f66..7a14f74 100644 --- a/znvis/particle/vector_field.py +++ b/znvis/particle/vector_field.py @@ -115,10 +115,6 @@ def construct_mesh_list(self): raise ValueError("Position data cannot be None.") if self.direction is None: raise ValueError("Director data cannot be None.") - if self.position.size == 0 or self.direction.size == 0: - raise IndexError("The provided data has an incompatible shape.") - - variable_particle_count = isinstance(self.position, list) variable_particle_count = isinstance(self.position, list) @@ -126,9 +122,19 @@ def construct_mesh_list(self): if variable_particle_count: n_time_steps = len(self.position) elif not self.static: + if self.position.ndim not in (2, 3) or self.direction.ndim not in ( + 2, + 3, + ): + raise IndexError("The provided data has an incompatible shape.") + if self.position.ndim == 2: + self.position = self.position[:, np.newaxis, :] + if self.direction.ndim == 2: + self.direction = self.direction[:, np.newaxis, :] n_particles = int(self.position.shape[1]) n_time_steps = int(self.position.shape[0]) else: + n_particles = int(self.position.shape[0]) n_time_steps = 1 self.position = self.position[np.newaxis, :, :] self.direction = self.direction[np.newaxis, :, :] @@ -183,8 +189,6 @@ def get_mesh_for_frame(self, frame_index: int): dir_frame = self.direction[frame_index] time_index = frame_index - pos_frame = np.atleast_2d(pos_frame) - dir_frame = np.atleast_2d(dir_frame) n_particles = int(pos_frame.shape[0]) new_mesh = True mesh = None