diff --git a/fury/actor/core.py b/fury/actor/core.py index ea20411f2..aaa192add 100644 --- a/fury/actor/core.py +++ b/fury/actor/core.py @@ -262,35 +262,101 @@ def create_point(geometry, material): def create_text(text, material, **kwargs): - """Create a text object. + """Create a text object or a group of text objects. Parameters ---------- - text : str - The text content. - material : TextMaterial - The material object. + text : str or list of str + The text content or list of text contents. + material : TextMaterial or list of TextMaterial + The material object or list of material objects. **kwargs : dict - Additional properties like font_size, anchor, etc. + Additional properties like font_size, anchor, position, etc. + When ``text`` is a list, each kwarg can be either: + - A single value applied to all text objects, or + - A list of values (one per text) for per-text customization. Returns ------- - Text - The text object. + Text or Group + A single Text object if text is a string, or a Group of Text + objects if text is a list. Raises ------ TypeError - If text is not a string or material is not an instance of TextMaterial. + If text is not a string or list of strings, or material is not + an instance of TextMaterial or list of TextMaterial. + ValueError + If the length of material list or any kwarg list does not match + the length of the text list. + + Examples + -------- + >>> mat = TextMaterial() + >>> # Single text (unchanged behavior) + >>> t = create_text("Hello", mat) + + >>> # Multiple texts, shared material and kwargs + >>> t = create_text(["Hello", "World", "FURY"], mat) + + >>> # Multiple texts with individual materials and per-text positions + >>> t = create_text( + ... ["Hello", "World"], + ... [TextMaterial(), TextMaterial()], + ... position=[(0, 0, 0), (1, 0, 0)], + ... ) """ + if isinstance(text, list): + if not all(isinstance(t, str) for t in text): + raise TypeError("All items in text list must be strings.") + + n = len(text) + + if isinstance(material, list): + if len(material) != n: + raise ValueError( + "Length of material list must match length of text list." + ) + if not all(isinstance(m, TextMaterial) for m in material): + raise TypeError( + "All items in material list must be instances of TextMaterial." + ) + materials = material + else: + if not isinstance(material, TextMaterial): + raise TypeError("material must be an instance of TextMaterial.") + materials = [material] * n + + # Normalize kwargs: expand scalars to per-text lists + norm_kwargs = {} + for k, v in kwargs.items(): + if isinstance(v, list): + if len(v) != n: + raise ValueError( + f"Length of kwarg '{k}' list must match length of text list." + ) + norm_kwargs[k] = v + else: + norm_kwargs[k] = [v] * n + + group = Group() + for i, (t, m) in enumerate(zip(text, materials, strict=True)): + per_text_kwargs = {k: norm_kwargs[k][i] for k in norm_kwargs} + position = per_text_kwargs.pop("position", None) + actor = Text(text=t, material=m, **per_text_kwargs) + if position is not None: + actor.local.position = position + group.add(actor) + return group + if not isinstance(text, str): raise TypeError("text must be a string.") if not isinstance(material, TextMaterial): raise TypeError("material must be an instance of TextMaterial.") - text = Text(text=text, material=material, **kwargs) - return text + return Text(text=text, material=material, **kwargs) def create_image(image_input, material, **kwargs): diff --git a/fury/actor/tests/test_core.py b/fury/actor/tests/test_core.py index 136000f88..ea2fdcc54 100644 --- a/fury/actor/tests/test_core.py +++ b/fury/actor/tests/test_core.py @@ -1,190 +1,262 @@ -from PIL import Image -import numpy as np -import pytest - -from fury import actor, geometry, material, window -from fury.actor import Actor, Image as ActorImage, Text -from fury.actor.tests._helpers import validate_actors - - -def test_actor_rotate_sets_quaternion_on_local_rotation(sphere_actor): - sphere_actor.rotate((90, 0, 0)) - - assert sphere_actor.local.rotation is not None - np.testing.assert_array_almost_equal( - sphere_actor.local.rotation, - Actor._euler_to_quaternion(np.radians([90, 0, 0])), - ) - - -@pytest.mark.parametrize( - "invalid_rotation", - [ - (0, 0), # too short - (0, 0, 0, 0), # too long - ], -) -def test_actor_rotate_invalid_input_raises(sphere_actor, invalid_rotation): - with pytest.raises(ValueError, match="Rotation must contain three angles"): - sphere_actor.rotate(invalid_rotation) - - -def test_actor_translate_updates_local_translation_vector(sphere_actor): - sphere_actor.translate((1, 2, 3)) - - np.testing.assert_array_equal( - sphere_actor.local.position, np.array([1, 2, 3], dtype=np.float32) - ) - - -@pytest.mark.parametrize( - "scale_input,expected", - [ - (2, np.array([2, 2, 2], dtype=np.float32)), - ((1, 2, 3), np.array([1, 2, 3], dtype=np.float32)), - ], -) -def test_actor_scale_accepts_scalar_and_vector_inputs( - sphere_actor, scale_input, expected -): - sphere_actor.scale(scale_input) - - np.testing.assert_array_equal(sphere_actor.local.scale, expected) - - -@pytest.mark.parametrize( - "invalid_scale", - [ - (1, 2), - (1, 2, 3, 4), - ], -) -def test_actor_scale_invalid_shape(sphere_actor, invalid_scale): - with pytest.raises(ValueError, match="Scale must contain three values"): - sphere_actor.scale(invalid_scale) - - -def test_actor_transform_accepts_4x4_matrix(sphere_actor): - matrix = np.eye(4, dtype=np.float32) - - sphere_actor.transform(matrix) - - np.testing.assert_array_equal(sphere_actor.local.matrix, matrix) - - -@pytest.mark.parametrize( - "invalid_matrix", - [ - np.eye(3), - np.ones((4, 3)), - np.zeros((5, 5)), - ], -) -def test_actor_transform_invalid_matrix_shape(sphere_actor, invalid_matrix): - with pytest.raises(ValueError, match="Transformation matrix must be of shape"): - sphere_actor.transform(invalid_matrix) - - -@pytest.mark.parametrize( - "opacity", - [0, 0.5, 1], -) -def test_actor_opacity_sets_material(sphere_actor, opacity): - sphere_actor.opacity = opacity - assert sphere_actor.material.opacity == opacity - - -@pytest.mark.parametrize( - "invalid_opacity", - [ - -0.5, # negative - 1.5, # greater than 1 - ], -) -def test_actor_opacity_invalid_values_raise(sphere_actor, invalid_opacity): - with pytest.raises(ValueError, match="Opacity must be"): - sphere_actor.opacity = invalid_opacity - - -def test_create_mesh(): - positions = np.array([[0, 0, 0], [1, 1, 1], [2, 2, 2]]).astype("float32") - geo = geometry.buffer_to_geometry(positions) - mat = material._create_mesh_material( - material="phong", color=(1, 0, 0), opacity=0.5, mode="auto" - ) - mesh = actor.create_mesh(geometry=geo, material=mat) - assert mesh.geometry == geo - assert mesh.material == mat - - -def test_create_point(): - vertices = np.array([[0, 0, 0], [1, 1, 1], [2, 2, 2]]).astype("float32") - geo = geometry.buffer_to_geometry(vertices) - mat = material._create_points_material( - material="basic", color=(1, 0, 0), opacity=0.5, mode="auto" - ) - point = actor.create_point(geometry=geo, material=mat) - assert point.geometry == geo - assert point.material == mat - - -def test_create_text(): - text = "FURY" - mat = material._create_text_material(color=(1, 0, 0), opacity=0.5) - text_obj = actor.create_text(text=text, material=mat) - assert text_obj.material == mat - assert isinstance(text_obj, Text) - - -def test_create_image(): - img_data = np.random.rand(128, 128) - mat = material._create_image_material() - image_obj = actor.create_image(image_input=img_data, material=mat) - assert image_obj.material == mat - assert isinstance(image_obj, ActorImage) - assert image_obj.geometry.grid.dim == 2 - - -def test_line(): - lines_points = np.array([[[0, 0, 0], [1, 1, 1]], [[1, 1, 1], [2, 2, 2]]]) - colors = np.array([[[1, 0, 0]], [[0, 1, 0]]]) - validate_actors(lines=lines_points, colors=colors, actor_type="line", prim_count=2) - - line = np.array([[0, 0, 0], [1, 1, 1]]) - colors = None - validate_actors(lines=line, colors=colors, actor_type="line", prim_count=2) - - line = np.array([[0, 0, 0], [1, 1, 1]]) - actor.line(line, colors=colors) - actor.line(line) - actor.line(line, colors=colors) - actor.line(line, colors=colors, material="basic") - actor.line(line, colors=line, material="basic") - - -def test_arrow(): - centers = np.array([[0, 0, 0]]) - colors = np.array([[1, 0, 0]]) - validate_actors(centers=centers, colors=colors, actor_type="arrow") - - -def test_axes(): - scene = window.Scene() - axes_actor = actor.axes() - scene.add(axes_actor) - - assert axes_actor.prim_count == 3 - - fname = "axes_test.png" - window.snapshot(scene=scene, fname=fname) - img = Image.open(fname) - img_array = np.array(img) - mean_r, mean_g, mean_b, _mean_a = np.mean( - img_array.reshape(-1, img_array.shape[2]), axis=0 - ) - assert np.isclose(mean_r, mean_g, atol=0.02) - assert 0 < mean_r < 255 - assert 0 < mean_g < 255 - assert 0 < mean_b < 255 - - scene.remove(axes_actor) +from PIL import Image +import numpy as np +import pytest + +from fury import actor, geometry, material, window +from fury.actor import Actor, Image as ActorImage, Text +from fury.actor.tests._helpers import validate_actors + + +def test_actor_rotate_sets_quaternion_on_local_rotation(sphere_actor): + sphere_actor.rotate((90, 0, 0)) + + assert sphere_actor.local.rotation is not None + np.testing.assert_array_almost_equal( + sphere_actor.local.rotation, + Actor._euler_to_quaternion(np.radians([90, 0, 0])), + ) + + +@pytest.mark.parametrize( + "invalid_rotation", + [ + (0, 0), # too short + (0, 0, 0, 0), # too long + ], +) +def test_actor_rotate_invalid_input_raises(sphere_actor, invalid_rotation): + with pytest.raises(ValueError, match="Rotation must contain three angles"): + sphere_actor.rotate(invalid_rotation) + + +def test_actor_translate_updates_local_translation_vector(sphere_actor): + sphere_actor.translate((1, 2, 3)) + + np.testing.assert_array_equal( + sphere_actor.local.position, np.array([1, 2, 3], dtype=np.float32) + ) + + +@pytest.mark.parametrize( + "scale_input,expected", + [ + (2, np.array([2, 2, 2], dtype=np.float32)), + ((1, 2, 3), np.array([1, 2, 3], dtype=np.float32)), + ], +) +def test_actor_scale_accepts_scalar_and_vector_inputs( + sphere_actor, scale_input, expected +): + sphere_actor.scale(scale_input) + + np.testing.assert_array_equal(sphere_actor.local.scale, expected) + + +@pytest.mark.parametrize( + "invalid_scale", + [ + (1, 2), + (1, 2, 3, 4), + ], +) +def test_actor_scale_invalid_shape(sphere_actor, invalid_scale): + with pytest.raises(ValueError, match="Scale must contain three values"): + sphere_actor.scale(invalid_scale) + + +def test_actor_transform_accepts_4x4_matrix(sphere_actor): + matrix = np.eye(4, dtype=np.float32) + + sphere_actor.transform(matrix) + + np.testing.assert_array_equal(sphere_actor.local.matrix, matrix) + + +@pytest.mark.parametrize( + "invalid_matrix", + [ + np.eye(3), + np.ones((4, 3)), + np.zeros((5, 5)), + ], +) +def test_actor_transform_invalid_matrix_shape(sphere_actor, invalid_matrix): + with pytest.raises(ValueError, match="Transformation matrix must be of shape"): + sphere_actor.transform(invalid_matrix) + + +@pytest.mark.parametrize( + "opacity", + [0, 0.5, 1], +) +def test_actor_opacity_sets_material(sphere_actor, opacity): + sphere_actor.opacity = opacity + assert sphere_actor.material.opacity == opacity + + +@pytest.mark.parametrize( + "invalid_opacity", + [ + -0.5, # negative + 1.5, # greater than 1 + ], +) +def test_actor_opacity_invalid_values_raise(sphere_actor, invalid_opacity): + with pytest.raises(ValueError, match="Opacity must be"): + sphere_actor.opacity = invalid_opacity + + +def test_create_mesh(): + positions = np.array([[0, 0, 0], [1, 1, 1], [2, 2, 2]]).astype("float32") + geo = geometry.buffer_to_geometry(positions) + mat = material._create_mesh_material( + material="phong", color=(1, 0, 0), opacity=0.5, mode="auto" + ) + mesh = actor.create_mesh(geometry=geo, material=mat) + assert mesh.geometry == geo + assert mesh.material == mat + + +def test_create_point(): + vertices = np.array([[0, 0, 0], [1, 1, 1], [2, 2, 2]]).astype("float32") + geo = geometry.buffer_to_geometry(vertices) + mat = material._create_points_material( + material="basic", color=(1, 0, 0), opacity=0.5, mode="auto" + ) + point = actor.create_point(geometry=geo, material=mat) + assert point.geometry == geo + assert point.material == mat + + +def test_create_text(): + text = "FURY" + mat = material._create_text_material(color=(1, 0, 0), opacity=0.5) + text_obj = actor.create_text(text=text, material=mat) + assert text_obj.material == mat + assert isinstance(text_obj, Text) + + +def test_create_image(): + img_data = np.random.rand(128, 128) + mat = material._create_image_material() + image_obj = actor.create_image(image_input=img_data, material=mat) + assert image_obj.material == mat + assert isinstance(image_obj, ActorImage) + assert image_obj.geometry.grid.dim == 2 + + +def test_line(): + lines_points = np.array([[[0, 0, 0], [1, 1, 1]], [[1, 1, 1], [2, 2, 2]]]) + colors = np.array([[[1, 0, 0]], [[0, 1, 0]]]) + validate_actors(lines=lines_points, colors=colors, actor_type="line", prim_count=2) + + line = np.array([[0, 0, 0], [1, 1, 1]]) + colors = None + validate_actors(lines=line, colors=colors, actor_type="line", prim_count=2) + + line = np.array([[0, 0, 0], [1, 1, 1]]) + actor.line(line, colors=colors) + actor.line(line) + actor.line(line, colors=colors) + actor.line(line, colors=colors, material="basic") + actor.line(line, colors=line, material="basic") + + +def test_arrow(): + centers = np.array([[0, 0, 0]]) + colors = np.array([[1, 0, 0]]) + validate_actors(centers=centers, colors=colors, actor_type="arrow") + + +def test_axes(): + scene = window.Scene() + axes_actor = actor.axes() + scene.add(axes_actor) + + assert axes_actor.prim_count == 3 + + fname = "axes_test.png" + window.snapshot(scene=scene, fname=fname) + img = Image.open(fname) + img_array = np.array(img) + mean_r, mean_g, mean_b, _mean_a = np.mean( + img_array.reshape(-1, img_array.shape[2]), axis=0 + ) + assert np.isclose(mean_r, mean_g, atol=0.02) + assert 0 < mean_r < 255 + assert 0 < mean_g < 255 + assert 0 < mean_b < 255 + + scene.remove(axes_actor) + + +def test_create_text_list(): + """List of strings should return a Group.""" + from fury.actor.core import Group, create_text + from fury.lib import TextMaterial + + mat = TextMaterial() + t = create_text(["Hello", "World", "FURY"], mat) + assert isinstance(t, Group) + assert len(t.children) == 3 + + +def test_create_text_list_materials(): + """List of strings with list of materials should return a Group.""" + from fury.actor.core import Group, create_text + from fury.lib import TextMaterial + + materials = [TextMaterial(), TextMaterial(), TextMaterial()] + t = create_text(["Hello", "World", "FURY"], materials) + assert isinstance(t, Group) + assert len(t.children) == 3 + + +def test_create_text_invalid_type(): + """Non-string text should raise TypeError.""" + from fury.actor.core import create_text + from fury.lib import TextMaterial + + with pytest.raises(TypeError): + create_text(123, TextMaterial()) + + +def test_create_text_list_invalid_items(): + """List with non-string items should raise TypeError.""" + from fury.actor.core import create_text + from fury.lib import TextMaterial + + with pytest.raises(TypeError): + create_text(["Hello", 123], TextMaterial()) + + +def test_create_text_material_length_mismatch(): + """Mismatched material list length should raise ValueError.""" + from fury.actor.core import create_text + from fury.lib import TextMaterial + + with pytest.raises(ValueError): + create_text(["Hello", "World", "FURY"], [TextMaterial(), TextMaterial()]) + + +def test_create_text_list_shared_kwargs(): + """Single kwarg value should be broadcast to all text objects.""" + from fury.actor.core import Group, create_text + from fury.lib import TextMaterial + + t = create_text(["Hello", "World"], TextMaterial(), font_size=24) + assert isinstance(t, Group) + assert len(t.children) == 2 + + +def test_create_text_kwarg_length_mismatch(): + """Kwarg list length mismatch should raise ValueError.""" + from fury.actor.core import create_text + from fury.lib import TextMaterial + + with pytest.raises(ValueError): + create_text( + ["Hello", "World", "FURY"], + TextMaterial(), + position=[(0, 0, 0), (1, 0, 0)], + )