From 2d6b26cdcfa599ac2316d243a747fae1a59d59b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Eertmans?= Date: Sun, 26 Jul 2026 04:05:34 +0200 Subject: [PATCH 1/3] feat(rm): allow registering custom radio mat. prior to loading scenes --- doc/source/api/radio_materials.rst | 4 + .../developer/dev_custom_radio_materials.rst | 121 +++++++++ src/sionna/rt/radio_materials/__init__.py | 4 +- src/sionna/rt/radio_materials/itu_material.py | 29 +- .../rt/radio_materials/radio_material.py | 16 ++ src/sionna/rt/scene.py | 28 +- src/sionna/rt/scene_utils.py | 19 ++ test/unit/test_scene_utils.py | 254 +++++++++++++++++- 8 files changed, 468 insertions(+), 7 deletions(-) diff --git a/doc/source/api/radio_materials.rst b/doc/source/api/radio_materials.rst index d0c259c8..73dfb9e7 100644 --- a/doc/source/api/radio_materials.rst +++ b/doc/source/api/radio_materials.rst @@ -126,6 +126,10 @@ Moreover, by default, the scattering coefficient, :math:`S`, of these materials .. autoclass:: sionna.rt.ITURadioMaterial :members: +.. autofunction:: sionna.rt.register_radio_material + +.. autofunction:: sionna.rt.register_itu_radio_material + Scattering Patterns ------------------- diff --git a/doc/source/developer/dev_custom_radio_materials.rst b/doc/source/developer/dev_custom_radio_materials.rst index ef504fc7..b95d7448 100644 --- a/doc/source/developer/dev_custom_radio_materials.rst +++ b/doc/source/developer/dev_custom_radio_materials.rst @@ -1160,3 +1160,124 @@ reflected and a transmitted path. {'custom-mat-instance': EnhancedCustomRadioMaterial[g=[0.7]]} Tracing paths can be done as for the previous example. + +Registering Custom Radio Materials Before Loading Scenes +******************************************************** + +When loading scene files (e.g. exported from Blender), shapes may reference custom radio materials or new ITU material names. +To allow Sionna RT to recognize and assign these materials automatically during scene loading, you can register them prior to calling :func:`~sionna.rt.load_scene`. + +Registering Custom RadioMaterial Instances +========================================== + +You can register a :class:`~sionna.rt.RadioMaterialBase` instance using :func:`~sionna.rt.register_radio_material`: + +.. code-block:: python + + from sionna.rt import RadioMaterial, register_radio_material, load_scene + + # Instantiate custom radio material + my_mat = RadioMaterial("my_custom_mat", relative_permittivity=5.0, conductivity=0.01) + + # Register it so scenes can reference "my_custom_mat" + register_radio_material(my_mat) + + # Load scene file referencing "my_custom_mat" + scene = load_scene("my_scene.xml") + +In the scene file (e.g., ``my_scene.xml``), the BSDF node can explicitly use ``type="radio-material"`` (or a generic BSDF type matching the registered material name): + +.. code-block:: xml + + + + + + + + + +Registering Custom ITU Radio Materials +====================================== + +To define a new ITU material or update parameters of existing ITU materials, use :func:`~sionna.rt.register_itu_radio_material`: + +.. code-block:: python + + from sionna.rt import register_itu_radio_material, load_scene + + # Register a custom ITU material with frequency parameters (a, b, c, d) and color + register_itu_radio_material( + "custom_wood", + {(0.1, 100.0): (2.5, 0.0, 0.005, 1.0)}, + (0.4, 0.2, 0.1) + ) + + # Load scene referencing "itu_custom_wood" + scene = load_scene("my_itu_scene.xml") + +In the XML scene file, ITU radio materials can be declared using the explicit ``type="itu-radio-material"`` syntax, specifying the ITU material name via the ``type`` property: + +.. code-block:: xml + + + + + + + + + + + +Note that Sionna RT also supports legacy or Blender exported scenes where the BSDF element ID starts with ``itu_`` or ``mat-itu_`` (e.g. ````), which is automatically converted to an ITU radio material during scene preprocessing. + +Multiple Material Customizations Referencing the Same ITU Material Type +----------------------------------------------------------------------- + +Using the explicit XML syntax (``type="itu-radio-material"``), you can define multiple distinct BSDF nodes with unique IDs that all reference the same ITU material type (whether built-in or custom registered), while providing different overrides such as thickness or color: + +.. code-block:: xml + + + + + + + + + + + + + + + + + + + + + + +Overriding Material Attributes (Thickness & Color) in Scene Files +----------------------------------------------------------------- + +For both custom registered :class:`~sionna.rt.RadioMaterialBase` instances and ITU radio materials, specifying material attributes directly inside the XML scene file (such as ```` or ````) overrides any predefined or registered material attributes: + +.. code-block:: xml + + + + + + + + + + + + + + + diff --git a/src/sionna/rt/radio_materials/__init__.py b/src/sionna/rt/radio_materials/__init__.py index b7d8494c..bfb49122 100644 --- a/src/sionna/rt/radio_materials/__init__.py +++ b/src/sionna/rt/radio_materials/__init__.py @@ -5,8 +5,8 @@ """Module implementing radio materials for the Sionna RT""" from .radio_material_base import RadioMaterialBase -from .radio_material import RadioMaterial -from .itu_material import ITURadioMaterial +from .radio_material import RadioMaterial, radio_material_registry, register_radio_material +from .itu_material import ITURadioMaterial, register_itu_radio_material from .scattering_pattern import register_scattering_pattern, \ scattering_pattern_registry, \ ScatteringPattern, \ diff --git a/src/sionna/rt/radio_materials/itu_material.py b/src/sionna/rt/radio_materials/itu_material.py index f84110b3..c2f2d06d 100644 --- a/src/sionna/rt/radio_materials/itu_material.py +++ b/src/sionna/rt/radio_materials/itu_material.py @@ -5,7 +5,7 @@ """ITU radio materials""" import mitsuba as mi -from typing import Tuple, Callable +from typing import Tuple, Callable, Mapping from .itu import itu_material, ITU_MATERIALS_PROPERTIES from .radio_material import RadioMaterial @@ -109,14 +109,19 @@ def __init__( # 2. `color`, `reflectance` or `base_color` property specified in the # props (scene dictionary or XML file). # 3. Default color from `ITU_MATERIAL_COLORS`. + # 4. Set color to :py:class:`None`, which results in a random color being used. if color is None: - color = ITURadioMaterial.ITU_MATERIAL_COLORS[itu_type] if has_props: for pname in ("color", "reflectance", "base_color"): if pname in props: color = tuple(props[pname]) del props[pname] - props["color"] = mi.ScalarColor3f(color) + break + if color is None: + color = ITURadioMaterial.ITU_MATERIAL_COLORS.get(itu_type, None) # Color is allowed to be left unspecified (e.g., for custom user-defined ITU materials) + + if color is not None and has_props: + props["color"] = mi.ScalarColor3f(color) # Frequency update callback def cb(f: float): @@ -161,3 +166,21 @@ def to_string(self) -> str: mi.register_bsdf("itu-radio-material", lambda props: ITURadioMaterial(props=props)) + + +def register_itu_radio_material( + name: str, + parameters: Mapping[Tuple[float, float], Tuple[float, float, float, float]], + color: Tuple[float, float, float] | None = None +) -> None: + # pylint: disable=line-too-long + r""" + Registers a custom ITU radio material or updates an existing ITU material definition. + + :param name: Name of the ITU radio material to register. + :param parameters: A mapping of frequency ranges in GHz ``(f_min, f_max)`` to tuples of ITU parameters ``(a, b, c, d)`` as defined in recommendation ITU-R P.2040. + :param color: Optional RGB (red, green, blue) color tuple for rendering/previewing, where each component is in :math:`[0, 1]`. If set to :py:class:`None`, then a random color is used. + """ + ITU_MATERIALS_PROPERTIES[name] = dict(parameters) + if color is not None: + ITURadioMaterial.ITU_MATERIAL_COLORS[name] = color diff --git a/src/sionna/rt/radio_materials/radio_material.py b/src/sionna/rt/radio_materials/radio_material.py index e9018adf..6ba67700 100644 --- a/src/sionna/rt/radio_materials/radio_material.py +++ b/src/sionna/rt/radio_materials/radio_material.py @@ -17,9 +17,25 @@ from .radio_material_base import RadioMaterialBase from .scattering_pattern import scattering_pattern_registry, \ ScatteringPattern +from ..registry import Registry from scipy.constants import speed_of_light + +# Registry for custom radio materials +radio_material_registry = Registry() + + +def register_radio_material(rm: RadioMaterialBase) -> None: + # pylint: disable=line-too-long + r""" + Registers a custom radio material instance to be used when loading scene files. + + :param rm: An instance of :class:`~sionna.rt.RadioMaterialBase` (or a subclass) + """ + radio_material_registry.register(rm, rm.name) + + class RadioMaterial(RadioMaterialBase): # pylint: disable=line-too-long r""" diff --git a/src/sionna/rt/scene.py b/src/sionna/rt/scene.py index 5a0f7c69..a28ea89b 100644 --- a/src/sionna/rt/scene.py +++ b/src/sionna/rt/scene.py @@ -25,7 +25,7 @@ from .constants import DEFAULT_FREQUENCY, DEFAULT_BANDWIDTH, \ DEFAULT_TEMPERATURE, \ DEFAULT_PREVIEW_BACKGROUND_COLOR -from .radio_materials import RadioMaterialBase +from .radio_materials import RadioMaterialBase, radio_material_registry from .antenna_array import AntennaArray from .camera import Camera from .preview import Previewer @@ -934,6 +934,32 @@ def _load_scene_objects(self, remove_duplicate_vertices: bool): f"Found shape \"{s.id()}\" without an associated radio" " material while loading the scene." ) + + # Check if this BSDF matches a material registered in radio_material_registry + mat_id = bsdf.id() + reg_name = None + if mat_id in radio_material_registry.list(): + reg_name = mat_id + elif mat_id.startswith("mat-") and mat_id[4:] in radio_material_registry.list(): + reg_name = mat_id[4:] + + if reg_name is not None: + registered_rm = radio_material_registry.get(reg_name) + mi_bsdf = s.bsdf() + if isinstance(mi_bsdf, sionna.rt.RadioMaterialBase): + registered_rm.color = mi_bsdf.color + registered_rm.thickness = mi_bsdf.thickness + elif hasattr(mi_bsdf, "properties"): + props = mi_bsdf.properties() + for pname in ("color", "reflectance", "base_color"): + if props.has_property(pname): + registered_rm.color = tuple(props[pname]) + break + if props.has_property("thickness"): + registered_rm.thickness = float(props["thickness"]) + s.set_bsdf(registered_rm) + bsdf = s.bsdf() + if not isinstance(bsdf, sionna.rt.RadioMaterialBase): raise ValueError( f"Found shape \"{s.id()}\" with associated material" diff --git a/src/sionna/rt/scene_utils.py b/src/sionna/rt/scene_utils.py index d6feaad6..5a1c5b19 100644 --- a/src/sionna/rt/scene_utils.py +++ b/src/sionna/rt/scene_utils.py @@ -17,6 +17,7 @@ import sionna from .constants import DEFAULT_THICKNESS from .radio_materials.itu import ITU_MATERIALS_PROPERTIES +from .radio_materials.radio_material import radio_material_registry from .scene_object import SceneObject from .utils.meshes import remove_mesh_duplicate_vertices @@ -112,6 +113,24 @@ def process_xml(xml_string: str, bsdf.attrib["id"] = mat_id for k, (t, v) in props.items(): bsdf.append(ET.Element(t, {"name": k, "value": str(v)})) + elif ((name in radio_material_registry.list()) + or (mat_id in radio_material_registry.list())): + color_prop = None + for pname in ("color", "reflectance", "base_color"): + color_prop = bsdf.find(f".//rgb[@name='{pname}']") + if color_prop is not None: + break + + thickness_prop = bsdf.find("float[@name='thickness']") + + bsdf.clear() + bsdf.attrib["type"] = "radio-material" + if mat_id is not None: + bsdf.attrib["id"] = mat_id + if color_prop is not None: + bsdf.append(color_prop) + if thickness_prop is not None: + bsdf.append(thickness_prop) elif (bsdf_type != "itu-radio-material") \ and (name in ITU_MATERIALS_PROPERTIES): raise ValueError( diff --git a/test/unit/test_scene_utils.py b/test/unit/test_scene_utils.py index c0938139..8825af58 100644 --- a/test/unit/test_scene_utils.py +++ b/test/unit/test_scene_utils.py @@ -14,7 +14,9 @@ import drjit as dr from sionna import rt from sionna.rt import load_scene, load_scene_from_string, SceneObject, \ - RadioMaterial, RadioMaterialBase, ITURadioMaterial + RadioMaterial, RadioMaterialBase, ITURadioMaterial, \ + register_itu_radio_material, register_radio_material, \ + radio_material_registry def register_custom_radio_material(): @@ -523,3 +525,253 @@ def test07_scene_loading_error_messages(): """) + + +def test08_register_itu_radio_material(): + # Register the material + register_itu_radio_material( + "custom_unknown_wood", + {(0.1, 100.0): (2.5, 0.0, 0.01, 1.0)}, + (0.2, 0.4, 0.6) + ) + + # 1. Loading with diffuse BSDF without 'itu_' or 'mat-' prefix must fail + xml_str_no_prefix = """ + + + + + + + """ + with pytest.raises(ValueError, match=r".*ITU material names must start with \"itu_\".*"): + load_scene_from_string(xml_str_no_prefix) + + # 2. Loading with newer explicit syntax () + xml_str_new_syntax = """ + + + + + + + + + """ + scene1 = load_scene_from_string(xml_str_new_syntax) + assert "itu_custom_unknown_wood" in scene1.radio_materials + assert isinstance(scene1.radio_materials["itu_custom_unknown_wood"], ITURadioMaterial) + + # 3. Loading with newer explicit syntax with id without 'itu_' prefix + xml_str_new_syntax2 = """ + + + + + + + + + """ + scene2 = load_scene_from_string(xml_str_new_syntax2) + assert "custom_unknown_wood" in scene2.radio_materials + assert isinstance(scene2.radio_materials["custom_unknown_wood"], ITURadioMaterial) + + # 4. Loading with legacy/Blender syntax () + xml_str_legacy = """ + + + + + + + """ + scene3 = load_scene_from_string(xml_str_legacy) + assert "itu_custom_unknown_wood" in scene3.radio_materials + assert isinstance(scene3.radio_materials["itu_custom_unknown_wood"], ITURadioMaterial) + + # 5. Loading with legacy/Blender syntax with 'mat-' prefix () + xml_str_legacy_mat = """ + + + + + + + """ + scene4 = load_scene_from_string(xml_str_legacy_mat) + assert "itu_custom_unknown_wood" in scene4.radio_materials + # 6. Loading multiple custom material IDs referencing the same ITU material type with different thicknesses + xml_str_multiple_ids = """ + + + + + + + + + + + + + + + + + """ + scene_multiple = load_scene_from_string(xml_str_multiple_ids, merge_shapes=False) + assert "my_custom_thick_wood" in scene_multiple.radio_materials + assert "my_custom_thin_wood" in scene_multiple.radio_materials + mat_thick = scene_multiple.radio_materials["my_custom_thick_wood"] + mat_thin = scene_multiple.radio_materials["my_custom_thin_wood"] + assert mat_thick.itu_type == "custom_unknown_wood" + assert mat_thin.itu_type == "custom_unknown_wood" + assert dr.allclose(mat_thick.thickness, 0.25) + assert dr.allclose(mat_thin.thickness, 0.02) + + # 7. Loading ITU material with XML color override + xml_str_color_override = """ + + + + + + + + + + """ + scene_color = load_scene_from_string(xml_str_color_override) + mat_color = scene_color.radio_materials["itu_custom_unknown_wood"] + assert dr.allclose(mat_color.color, (0.9, 0.1, 0.1), atol=1e-3) + + # Register material without specifying color (color=None) + register_itu_radio_material( + "custom_no_color_wood", + {(0.1, 100.0): (2.0, 0.0, 0.01, 1.0)} + ) + xml_str_no_color = """ + + + + + + + + + """ + scene_no_color = load_scene_from_string(xml_str_no_color) + assert "itu_custom_no_color_wood" in scene_no_color.radio_materials + mat_no_color = scene_no_color.radio_materials["itu_custom_no_color_wood"] + assert mat_no_color.color is not None + + +def test10_register_radio_material(): + xml_str_unregistered = """ + + + + + + + """ + + # Loading without registration must fail because "my_custom_rm" is not a radio material + with pytest.raises(ValueError, match=r".*which is not a radio material.*"): + load_scene_from_string(xml_str_unregistered) + + # 1. Loading with new explicit syntax () + custom_mat1 = RadioMaterial(name="my_custom_rm1", relative_permittivity=4.5, conductivity=0.03) + register_radio_material(custom_mat1) + xml_str_new_syntax = """ + + + + + + + """ + scene1 = load_scene_from_string(xml_str_new_syntax, merge_shapes=False) + assert "my_custom_rm1" in scene1.radio_materials + assert scene1.radio_materials["my_custom_rm1"] is custom_mat1 + assert scene1.objects["shape1"].radio_material is custom_mat1 + radio_material_registry.unregister("my_custom_rm1") + + # 2. Loading with legacy/Blender syntax () + custom_mat2 = RadioMaterial(name="my_custom_rm2", relative_permittivity=4.5, conductivity=0.03) + register_radio_material(custom_mat2) + xml_str_legacy = """ + + + + + + + """ + scene2 = load_scene_from_string(xml_str_legacy, merge_shapes=False) + assert "my_custom_rm2" in scene2.radio_materials + assert scene2.radio_materials["my_custom_rm2"] is custom_mat2 + radio_material_registry.unregister("my_custom_rm2") + + # 3. Loading with 'mat-' prefix syntax () + custom_mat3 = RadioMaterial(name="my_custom_rm3", relative_permittivity=4.5, conductivity=0.03) + register_radio_material(custom_mat3) + xml_str_legacy_mat = """ + + + + + + + """ + scene3 = load_scene_from_string(xml_str_legacy_mat, merge_shapes=False) + assert "my_custom_rm3" in scene3.radio_materials + assert scene3.radio_materials["my_custom_rm3"] is custom_mat3 + radio_material_registry.unregister("my_custom_rm3") + + # 4. Loading custom RadioMaterial with XML color override + custom_mat4 = RadioMaterial(name="my_custom_rm4", relative_permittivity=4.5, conductivity=0.03, color=(0.1, 0.1, 0.1)) + register_radio_material(custom_mat4) + xml_str_color_override = """ + + + + + + + + + """ + scene4 = load_scene_from_string(xml_str_color_override, merge_shapes=False) + assert dr.allclose(scene4.radio_materials["my_custom_rm4"].color, (0.8, 0.2, 0.2), atol=1e-3) + radio_material_registry.unregister("my_custom_rm4") + + # 5. Loading custom RadioMaterial with XML thickness and color overrides + custom_mat5 = RadioMaterial(name="my_custom_rm5", relative_permittivity=4.5, conductivity=0.03, thickness=0.1, color=(0.1, 0.1, 0.1)) + register_radio_material(custom_mat5) + xml_str_override = """ + + + + + + + + + + """ + scene5 = load_scene_from_string(xml_str_override, merge_shapes=False) + mat5 = scene5.radio_materials["my_custom_rm5"] + assert dr.allclose(mat5.thickness, 0.35) + assert dr.allclose(mat5.color, (0.7, 0.3, 0.3), atol=1e-3) + radio_material_registry.unregister("my_custom_rm5") + + + + + + + + + From c997bcff769678bc0119c22fc14e208a8d2311eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Eertmans?= Date: Wed, 12 Aug 2026 10:34:48 +0900 Subject: [PATCH 2/3] chore(docs): style and minor comments --- .../developer/dev_custom_radio_materials.rst | 28 +++++++++---------- test/unit/test_scene_utils.py | 7 ++--- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/doc/source/developer/dev_custom_radio_materials.rst b/doc/source/developer/dev_custom_radio_materials.rst index b95d7448..7373ce09 100644 --- a/doc/source/developer/dev_custom_radio_materials.rst +++ b/doc/source/developer/dev_custom_radio_materials.rst @@ -9,7 +9,7 @@ They implement all necessary components to simulate the interaction between radio waves and objects composed of specific materials. Modifying Parameters of Radio Materials -****************************************** +*************************************** To show how to modify the parameters of radio materials, we start by loading a scene that consists only of a single reflector. @@ -96,7 +96,7 @@ We can see how the reflected path gain increases as the conductivity of the refl is set to higher values. Calibrating Material Parameters Through Gradient Descent -************************************************************ +******************************************************** We consider a simple example in which we aim to retrieve the conductivity of the a radio material through gradient descent. @@ -234,7 +234,7 @@ obtained using the reference scene instantiated at the beginning of this guide. :width: 70 % Custom Radio Materials -************************ +********************** Compared to what was done in the previous section, we will now implement a scattering model by defining a new class that inherits from the @@ -247,7 +247,7 @@ Sionna RT. It is highly recommended to first read the radio wave propagation. Representation of Jones vector and Matrices -============================================= +=========================================== As detailed in the `Primer on Electromagnetics <../em_primer.html>`_, a wave phasor is typically represented by a Jones vector :math:`\mathbf{E} \in \mathbb{C}^2`. @@ -279,7 +279,7 @@ write equations. However, all implementations use the real-valued representation. Implicit Basis -=============== +============== A wave phasor :math:`\mathbf{E}` is expressed by two arbitrary orthogonal polarization directions S and P: @@ -313,7 +313,7 @@ Moreover, it is required that the result of applying this Jones matrix is a Jones vector that also describes the scattered wave using the implicit basis. The Local Interaction Basis -============================= +=========================== Computing the Jones matrix and direction of propagation of the scattered wave resulting from an interaction is facilitated in Sionna RT by defining a local @@ -332,13 +332,13 @@ in the local coordinate system, we therefore have :width: 100 % Mandatory Subclass Methods -============================ +========================== Implementing a custom radio material requires defining a class that inherits from :class:`~sionna.rt.RadioMaterialBase` and implements the following methods: `sample()` ------------ +---------- Samples an interaction type and the direction of propagation of the scattered wave (which typically depend on the sampled interaction type). This function must return, among others, the sample interaction type, direction @@ -356,7 +356,7 @@ of incident rays interacting with the material resulting in independently sample scattered rays that model well the scattered field. `eval()` ---------- +-------- Evaluates the Jones matrix for a given interaction type, direction of incidence, and direction of scattering. Compared to :meth:`~sionna.rt.RadioMaterialBase.sample`, this method does not sample the material. @@ -367,17 +367,17 @@ Returns the probability that a given interaction type and direction of scatterin are sampled conditioned on a given direction of incidence. `traverse()` -------------- +------------ Traverses the attributes and objects of the material. This method is used to record the material parameters, and especially the differentiable parameters. `to_string()` --------------- +------------- Returns a string describing the material. This is used to "print" the material in a humanly readable way. Implementation of a Simple Radio Material Model -================================================= +=============================================== For simplicity, we will start by implementing a scattering model that only reflects incident radio waves specularly, and such that the energy of the reflected @@ -807,7 +807,7 @@ As expected, the gradient is positive as increasing the path gain requires increasing :math:`g`. A More Complex Material Model -=============================== +============================= Let's now enhance the previous radio material model by incorporating support for refraction, which refers to radio waves passing through the material. @@ -1165,7 +1165,7 @@ Registering Custom Radio Materials Before Loading Scenes ******************************************************** When loading scene files (e.g. exported from Blender), shapes may reference custom radio materials or new ITU material names. -To allow Sionna RT to recognize and assign these materials automatically during scene loading, you can register them prior to calling :func:`~sionna.rt.load_scene`. +To allow Sionna RT to recognize and assign these materials automatically during scene loading, you can register them prior to calling :func:`~sionna.rt.load_scene`. While it is possible to register a custom radio material by registering a custom BSDF plugin (see above), it is recommended, for simpler use cases, to use the :func:`~sionna.rt.register_radio_material` function to register a :class:`~sionna.rt.RadioMaterialBase` instance or the :func:`~sionna.rt.register_itu_radio_material` function to register a new ITU material. Registering Custom RadioMaterial Instances ========================================== diff --git a/test/unit/test_scene_utils.py b/test/unit/test_scene_utils.py index 8825af58..f48aa544 100644 --- a/test/unit/test_scene_utils.py +++ b/test/unit/test_scene_utils.py @@ -20,6 +20,7 @@ def register_custom_radio_material(): + # Register a custom radio material with a custom property `some_param` that is not part of the built-in radio material. class MyTestRadioMaterial(RadioMaterial): def __init__(self, props: mi.Properties | None = None): self.some_param = props.get("some_param", 0.0) @@ -183,10 +184,6 @@ def test03_scene_add_remove(): def test04_scene_radio_materials(): tmp_path = join(tempfile.gettempdir(), "test_scene_04.xml") - # We need to support several ways to specify radio materials: - # - `diffuse` BSDF with a special name (typically from a Blender export) - # - `itu-radio-material` or other built-in radio material - # - A user-defined custom radio material registered before loading the scene. custom_rm_type, MyCustomRadioMaterial = register_custom_radio_material() scene_content = \ @@ -667,7 +664,7 @@ def test08_register_itu_radio_material(): assert mat_no_color.color is not None -def test10_register_radio_material(): +def test09_register_radio_material(): xml_str_unregistered = """ From 0c2d1de823ac12ca8d1379a4f9e314b4ecc0d71d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Eertmans?= Date: Wed, 12 Aug 2026 12:09:51 +0900 Subject: [PATCH 3/3] feat(lib): implement property override logic --- .../developer/dev_custom_radio_materials.rst | 30 +++- src/sionna/rt/radio_materials/itu_material.py | 31 ++++- .../rt/radio_materials/radio_material.py | 29 ++++ .../rt/radio_materials/radio_material_base.py | 26 ++++ src/sionna/rt/scene.py | 96 ++++++++++--- src/sionna/rt/scene_utils.py | 46 ++++++- test/unit/test_scene_utils.py | 128 +++++++++++++++--- 7 files changed, 339 insertions(+), 47 deletions(-) diff --git a/doc/source/developer/dev_custom_radio_materials.rst b/doc/source/developer/dev_custom_radio_materials.rst index 7373ce09..aa824b3a 100644 --- a/doc/source/developer/dev_custom_radio_materials.rst +++ b/doc/source/developer/dev_custom_radio_materials.rst @@ -1263,7 +1263,12 @@ Using the explicit XML syntax (``type="itu-radio-material"``), you can define mu Overriding Material Attributes (Thickness & Color) in Scene Files ----------------------------------------------------------------- -For both custom registered :class:`~sionna.rt.RadioMaterialBase` instances and ITU radio materials, specifying material attributes directly inside the XML scene file (such as ```` or ````) overrides any predefined or registered material attributes: +For ITU radio materials, since each BSDF node with an ``itu-radio-material`` +type (or the legacy ``itu_``/``mat-itu_`` naming convention) creates its own, +independent, material instance, specifying attributes directly inside the +XML scene file (such as ```` or +````) freely overrides the predefined +attributes for that instance: .. code-block:: xml @@ -1274,10 +1279,29 @@ For both custom registered :class:`~sionna.rt.RadioMaterialBase` instances and I + + +For custom materials registered with :func:`~sionna.rt.register_radio_material`, +however, only ``color`` can be overridden this way: + +.. code-block:: xml - + + - + +This is because a registered material is a single, shared, Python instance +that may be referenced by many shapes across many scenes. ``color`` is +purely a visual attribute that never affects ray tracing results, so +overriding it per shape simply returns an independent copy of the material +(see :meth:`~sionna.rt.RadioMaterialBase.with_color`); the registered +instance itself, and every other shape using it, is left untouched. Other +attributes, such as ``thickness``, do affect ray tracing results, so they +cannot be overridden this way: doing so would make shapes that are +supposed to share one physical material behave differently depending on +which scene happened to load last. Set those attributes directly on the +registered material instead, e.g. through :func:`~sionna.rt.register_radio_material` +or :func:`~sionna.rt.register_itu_radio_material`. diff --git a/src/sionna/rt/radio_materials/itu_material.py b/src/sionna/rt/radio_materials/itu_material.py index c2f2d06d..3d8f36eb 100644 --- a/src/sionna/rt/radio_materials/itu_material.py +++ b/src/sionna/rt/radio_materials/itu_material.py @@ -151,6 +151,33 @@ def itu_type(self): """ return self._itu_type + def with_color(self, color: tuple[float, float, float]) -> "ITURadioMaterial": + r""" + Returns a new :class:`ITURadioMaterial`, identical to this one + except for its ``color`` + + See :meth:`~sionna.rt.RadioMaterialBase.with_color` for details. + + :param color: RGB (red, green, blue) color of the returned material + + :return: New :class:`ITURadioMaterial` with the same properties as this one, but with the specified ``color``. + """ + new = ITURadioMaterial( + name=self.name, + itu_type=self.itu_type, + thickness=self.thickness, + scattering_coefficient=self.scattering_coefficient, + xpd_coefficient=self.xpd_coefficient, + color=color, + ) + # `scattering_pattern` cannot be passed to the constructor above: + # it expects the *name* of a registered scattering pattern factory, + # whereas the `scattering_pattern` attribute returns the + # already-built `ScatteringPattern` instance. It must therefore be + # copied onto the new instance directly instead. + new.scattering_pattern = self.scattering_pattern + return new + def to_string(self) -> str: r""" Returns a string describing the object @@ -170,8 +197,8 @@ def to_string(self) -> str: def register_itu_radio_material( name: str, - parameters: Mapping[Tuple[float, float], Tuple[float, float, float, float]], - color: Tuple[float, float, float] | None = None + parameters: Mapping[tuple[float, float], tuple[float, float, float, float]], + color: tuple[float, float, float] | None = None ) -> None: # pylint: disable=line-too-long r""" diff --git a/src/sionna/rt/radio_materials/radio_material.py b/src/sionna/rt/radio_materials/radio_material.py index 6ba67700..f275e437 100644 --- a/src/sionna/rt/radio_materials/radio_material.py +++ b/src/sionna/rt/radio_materials/radio_material.py @@ -278,6 +278,35 @@ def scattering_pattern(self, sp): raise ValueError("Not an instance of ScatteringPattern") self._scattering_pattern = sp + def with_color(self, color: tuple[float, float, float]) -> "RadioMaterial": + r""" + Returns a new :class:`RadioMaterial`, identical to this one except + for its ``color`` + + See :meth:`RadioMaterialBase.with_color` for details. + + :param color: RGB (red, green, blue) color of the returned material + + :return: New :class:`RadioMaterial` with the same properties as this one, but with the specified ``color``. + """ + new = RadioMaterial( + name=self.name, + thickness=self.thickness, + relative_permittivity=self.relative_permittivity, + conductivity=self.conductivity, + scattering_coefficient=self.scattering_coefficient, + xpd_coefficient=self.xpd_coefficient, + frequency_update_callback=self.frequency_update_callback, + color=color, + ) + # `scattering_pattern` cannot be passed to the constructor above: + # it expects the *name* of a registered scattering pattern factory, + # whereas the `scattering_pattern` attribute returns the + # already-built `ScatteringPattern` instance. It must therefore be + # copied onto the new instance directly instead. + new.scattering_pattern = self.scattering_pattern + return new + @property def frequency_update_callback(self): # pylint: disable=line-too-long diff --git a/src/sionna/rt/radio_materials/radio_material_base.py b/src/sionna/rt/radio_materials/radio_material_base.py index 4179b352..1e724fca 100644 --- a/src/sionna/rt/radio_materials/radio_material_base.py +++ b/src/sionna/rt/radio_materials/radio_material_base.py @@ -99,6 +99,32 @@ def color(self, new_color: Tuple[float, float, float]): raise ValueError("Color components must be in the range (0,1)") self._color = (new_color[0], new_color[1], new_color[2]) + def with_color(self, color: tuple[float, float, float]) -> "RadioMaterialBase": + r""" + Returns a new radio material, identical to this one except for its + ``color`` + + Unlike setting :attr:`color` directly, this does not mutate the + current instance: it is meant to let a single registered material + be shared by multiple scene objects, or across multiple scenes, + while allowing individual scene files to request a different + display color without affecting other users of the same material. + This is safe because ``color`` is purely a visual attribute: it has + no effect on ray tracing results. + + Subclasses that support this operation must override this method. + The default implementation is a no-op, consistently with the other + methods of this interface (:meth:`sample`, :meth:`eval`, etc.): + callers that rely on a working override, such as + :meth:`~sionna.rt.Scene._load_scene_objects` when applying a + ``color`` override found in a scene file, are responsible for + checking the returned value and raising an appropriate error if a + subclass does not implement this method. + + :param color: RGB (red, green, blue) color of the returned material + """ + return ... + @property def is_used(self) -> bool: r""" diff --git a/src/sionna/rt/scene.py b/src/sionna/rt/scene.py index a28ea89b..e222deab 100644 --- a/src/sionna/rt/scene.py +++ b/src/sionna/rt/scene.py @@ -62,10 +62,16 @@ class Scene: :align: center :param mi_scene: A Mitsuba scene + + :param radio_material_overrides: Per-shape overrides of registered + radio material properties, as returned by + :func:`~sionna.rt.scene_utils.process_xml`. Only used internally + when loading a scene from an XML file. """ def __init__(self, mi_scene: mi.Scene | None = None, - remove_duplicate_vertices: bool = False): + remove_duplicate_vertices: bool = False, + radio_material_overrides: dict | None = None): # Transmitter antenna array self._tx_array = None @@ -107,7 +113,8 @@ def __init__(self, mi_scene: mi.Scene | None = None, # instantiated when loading the Mitsuba scene. # Note that when the radio material is instantiated, it is added # to the this scene. - self._load_scene_objects(remove_duplicate_vertices) + self._load_scene_objects(remove_duplicate_vertices, + radio_material_overrides or {}) @property def frequency(self): @@ -912,14 +919,30 @@ def use_mi_scene(self, scene: mi.Scene): yield self._scene = old_scene - def _load_scene_objects(self, remove_duplicate_vertices: bool): + def _load_scene_objects(self, remove_duplicate_vertices: bool, + radio_material_overrides: dict): """ Builds Sionna SceneObject instances from the Mistuba scene + + :param remove_duplicate_vertices: If set to `True`, duplicate + vertices are removed from the scene objects. + + :param radio_material_overrides: Per-shape overrides of registered + radio material properties, as returned by + :func:`~sionna.rt.scene_utils.process_xml`. """ # List of shapes shapes = self._scene.shapes() + # Materials cloned to apply a per-BSDF `color` override (see below), + # keyed by BSDF ID. Several shapes can reference the very same BSDF + # node (e.g., through Mitsuba's ``), in which case they must + # all reuse the same clone: otherwise, they would end up with + # distinct :class:`RadioMaterialBase` instances sharing the same + # `name`, which is not allowed when added to the scene. + cloned_materials = {} + # Parse all shapes in the scene for s in shapes: @@ -945,21 +968,50 @@ def _load_scene_objects(self, remove_duplicate_vertices: bool): if reg_name is not None: registered_rm = radio_material_registry.get(reg_name) - mi_bsdf = s.bsdf() - if isinstance(mi_bsdf, sionna.rt.RadioMaterialBase): - registered_rm.color = mi_bsdf.color - registered_rm.thickness = mi_bsdf.thickness - elif hasattr(mi_bsdf, "properties"): - props = mi_bsdf.properties() - for pname in ("color", "reflectance", "base_color"): - if props.has_property(pname): - registered_rm.color = tuple(props[pname]) - break - if props.has_property("thickness"): - registered_rm.thickness = float(props["thickness"]) - s.set_bsdf(registered_rm) - bsdf = s.bsdf() - + + # Properties explicitly overridden by this BSDF's XML node, + # if any. Note that `radio_material_registry` entries are + # shared, possibly across several scenes, so they must never + # be mutated here: only `color` is a purely visual property + # (it does not affect ray tracing results), so it is the + # only one that can be safely overridden by returning an + # independent copy of the registered material. Any other + # requested override would silently make shapes that are + # supposed to share one physical material behave + # differently, so we reject it instead. + overrides = radio_material_overrides.get(mat_id, {}) + unsupported = overrides.keys() - {"color"} + if unsupported: + raise ValueError( + f"Shape \"{s.id()}\" overrides" + f" {sorted(unsupported)} of registered radio" + f" material \"{reg_name}\" directly in the scene" + " file. Only `color` can be overridden this way." + " Other properties affect ray tracing results and" + " must be set on the material itself, e.g. via" + " `register_radio_material()` or" + " `register_itu_radio_material()`." + ) + + if "color" in overrides: + if mat_id not in cloned_materials: + cloned = registered_rm.with_color(overrides["color"]) + if not isinstance(cloned, sionna.rt.RadioMaterialBase): + raise NotImplementedError( + f"Radio material \"{reg_name}\" (an" + f" instance of" + f" {type(registered_rm).__name__}) does not" + " support overriding its `color` through a" + f" scene file. Implement `with_color` on" + f" {type(registered_rm).__name__} to" + " support this." + ) + cloned_materials[mat_id] = cloned + bsdf = cloned_materials[mat_id] + else: + bsdf = registered_rm + s.set_bsdf(bsdf) + if not isinstance(bsdf, sionna.rt.RadioMaterialBase): raise ValueError( f"Found shape \"{s.id()}\" with associated material" @@ -1166,12 +1218,14 @@ def load_scene_from_string( :param remove_duplicate_vertices: If set to `True`, duplicate vertices are removed from the scene objects. """ - processed = process_xml(xml_string, merge_shapes=merge_shapes, - merge_shapes_exclude_regex=merge_shapes_exclude_regex) + processed, radio_material_overrides = process_xml( + xml_string, merge_shapes=merge_shapes, + merge_shapes_exclude_regex=merge_shapes_exclude_regex) mi_scene = mi.load_string(processed, optimize=False) return Scene(mi_scene=mi_scene, - remove_duplicate_vertices=remove_duplicate_vertices) + remove_duplicate_vertices=remove_duplicate_vertices, + radio_material_overrides=radio_material_overrides) # diff --git a/src/sionna/rt/scene_utils.py b/src/sionna/rt/scene_utils.py index 5a1c5b19..2f766d1f 100644 --- a/src/sionna/rt/scene_utils.py +++ b/src/sionna/rt/scene_utils.py @@ -22,10 +22,25 @@ from .utils.meshes import remove_mesh_duplicate_vertices +def _parse_rgb_value(value: str) -> tuple[float, float, float]: + """ + Parses the ``value`` attribute of an ```` XML node + + :param value: Comma-separated RGB components, or a single value to be + used for all three components. + + :return: Tuple of three RGB components. + """ + components = tuple(float(v) for v in value.split(",")) + if len(components) == 1: + components = components * 3 + return components + + def process_xml(xml_string: str, merge_shapes: bool = True, merge_shapes_exclude_regex: str | None = None, - default_thickness: float = DEFAULT_THICKNESS) -> str: + default_thickness: float = DEFAULT_THICKNESS) -> tuple[str, dict]: """ Preprocess the XML string describing the scene @@ -41,6 +56,15 @@ def process_xml(xml_string: str, merging. Only used if ``merge_shapes`` is set to `True`. :param default_thickness: Default thickness [m] of radio materials + + :return: The processed XML string, and a dictionary mapping the ID of + BSDFs that reference a material registered in + ``radio_material_registry`` to the set of properties (``color`` + and/or ``thickness``) that are explicitly overridden by that BSDF + node in the scene file. A key is only present in this dictionary if + the corresponding property was explicitly set in ``xml_string``, so + that its absence unambiguously means "no override was requested", + as opposed to "the requested value happens to match the default". """ # Compile the regex if not 'None' @@ -51,6 +75,13 @@ def process_xml(xml_string: str, root = ET.fromstring(xml_string) + # Overrides of registered radio materials that are explicitly requested + # in the scene file, keyed by the BSDF ID that requests them. + # Only `color` is a supported override (see `_load_scene_objects`), but + # we also track `thickness` overrides here so that a clear error can be + # raised instead of silently ignoring/misapplying them. + radio_material_overrides = {} + # 1. Replace BSDFs with radio BSDFs # If a BSDF node in the XML scene has a special name starting with # `mat-itu_` or `itu_`, we automatically convert that BSDF to an @@ -104,9 +135,6 @@ def process_xml(xml_string: str, props["type"] = ("string", itu_type) props["thickness"] = ("float", thickness) - # TODO: we could consider saving some information about the original - # "visual" BSDFs if that allows users to customize the look of their - # scenes easily from Blender. bsdf.clear() bsdf.attrib["type"] = bsdf_type if mat_id is not None: @@ -123,6 +151,14 @@ def process_xml(xml_string: str, thickness_prop = bsdf.find("float[@name='thickness']") + overrides = {} + if color_prop is not None: + overrides["color"] = _parse_rgb_value(color_prop.get("value")) + if thickness_prop is not None: + overrides["thickness"] = float(thickness_prop.get("value")) + if overrides: + radio_material_overrides[mat_id] = overrides + bsdf.clear() bsdf.attrib["type"] = "radio-material" if mat_id is not None: @@ -156,7 +192,7 @@ def process_xml(xml_string: str, root.append(merge_node) ET.indent(root, space=" ") - return ET.tostring(root).decode("utf-8") + return ET.tostring(root).decode("utf-8"), radio_material_overrides def edit_scene_shapes( scene: sionna.rt.Scene, diff --git a/test/unit/test_scene_utils.py b/test/unit/test_scene_utils.py index f48aa544..8a9b6d21 100644 --- a/test/unit/test_scene_utils.py +++ b/test/unit/test_scene_utils.py @@ -727,7 +727,10 @@ def test09_register_radio_material(): assert scene3.radio_materials["my_custom_rm3"] is custom_mat3 radio_material_registry.unregister("my_custom_rm3") - # 4. Loading custom RadioMaterial with XML color override + # 4. Loading custom RadioMaterial with XML color override: the override + # must apply to an independent clone of the material, and must NOT + # mutate the registered material itself, since it may be shared by + # other shapes or scenes. custom_mat4 = RadioMaterial(name="my_custom_rm4", relative_permittivity=4.5, conductivity=0.03, color=(0.1, 0.1, 0.1)) register_radio_material(custom_mat4) xml_str_color_override = """ @@ -741,13 +744,19 @@ def test09_register_radio_material(): """ scene4 = load_scene_from_string(xml_str_color_override, merge_shapes=False) - assert dr.allclose(scene4.radio_materials["my_custom_rm4"].color, (0.8, 0.2, 0.2), atol=1e-3) + mat4 = scene4.radio_materials["my_custom_rm4"] + assert dr.allclose(mat4.color, (0.8, 0.2, 0.2), atol=1e-3) + assert mat4 is not custom_mat4 + assert dr.allclose(custom_mat4.color, (0.1, 0.1, 0.1), atol=1e-3) radio_material_registry.unregister("my_custom_rm4") - # 5. Loading custom RadioMaterial with XML thickness and color overrides + # 5. Overriding `thickness` (or any property other than `color`) of a + # registered material through the scene file must raise: silently + # allowing it would let shapes that are supposed to share one physical + # material behave differently depending on the order scenes are loaded. custom_mat5 = RadioMaterial(name="my_custom_rm5", relative_permittivity=4.5, conductivity=0.03, thickness=0.1, color=(0.1, 0.1, 0.1)) register_radio_material(custom_mat5) - xml_str_override = """ + xml_str_thickness_override = """ @@ -758,17 +767,104 @@ def test09_register_radio_material(): """ - scene5 = load_scene_from_string(xml_str_override, merge_shapes=False) - mat5 = scene5.radio_materials["my_custom_rm5"] - assert dr.allclose(mat5.thickness, 0.35) - assert dr.allclose(mat5.color, (0.7, 0.3, 0.3), atol=1e-3) + with pytest.raises(ValueError, match=r".*overrides \['thickness'\].*"): + load_scene_from_string(xml_str_thickness_override, merge_shapes=False) + # The registered material must be untouched even though loading failed. + assert dr.allclose(custom_mat5.thickness, 0.1) + assert dr.allclose(custom_mat5.color, (0.1, 0.1, 0.1), atol=1e-3) radio_material_registry.unregister("my_custom_rm5") - - - - - - - - + # 6. Loading a registered material with no override at all must not + # change any of its properties (this used to silently reset `color` + # to a random value and `thickness` to the default). + custom_mat6 = RadioMaterial(name="my_custom_rm6", relative_permittivity=4.5, conductivity=0.03, thickness=0.42, color=(0.3, 0.4, 0.5)) + register_radio_material(custom_mat6) + xml_str_no_override = """ + + + + + + + """ + scene6 = load_scene_from_string(xml_str_no_override, merge_shapes=False) + assert scene6.radio_materials["my_custom_rm6"] is custom_mat6 + assert dr.allclose(custom_mat6.thickness, 0.42) + assert dr.allclose(custom_mat6.color, (0.3, 0.4, 0.5), atol=1e-3) + radio_material_registry.unregister("my_custom_rm6") + + # 7. Multiple shapes referencing the same BSDF node (through ``) + # that requests a color override must share a single cloned material, + # rather than each getting their own, unrelated, clone. + custom_mat7 = RadioMaterial(name="my_custom_rm7", relative_permittivity=4.5, conductivity=0.03, color=(0.1, 0.1, 0.1)) + register_radio_material(custom_mat7) + xml_str_shared_override = """ + + + + + + + + + + + + """ + scene7 = load_scene_from_string(xml_str_shared_override, merge_shapes=False) + assert scene7.objects["shape1"].radio_material is scene7.objects["shape2"].radio_material + assert dr.allclose(scene7.objects["shape1"].radio_material.color, (0.2, 0.7, 0.2), atol=1e-3) + assert dr.allclose(custom_mat7.color, (0.1, 0.1, 0.1), atol=1e-3) + radio_material_registry.unregister("my_custom_rm7") + + # 8. Same as 4., but for a registered `ITURadioMaterial`: its + # `with_color` override must also return an independent, working, + # clone (it cannot pass `scattering_pattern` back into the constructor + # as-is, unlike every other overridden property). + custom_mat8 = ITURadioMaterial(name="my_custom_rm8", itu_type="concrete", + thickness=0.15, color=(0.1, 0.1, 0.1)) + register_radio_material(custom_mat8) + xml_str_itu_color_override = """ + + + + + + + + + """ + scene8 = load_scene_from_string(xml_str_itu_color_override, merge_shapes=False) + mat8 = scene8.radio_materials["my_custom_rm8"] + assert isinstance(mat8, ITURadioMaterial) + assert mat8.itu_type == "concrete" + assert dr.allclose(mat8.thickness, 0.15) + assert dr.allclose(mat8.color, (0.4, 0.6, 0.1), atol=1e-3) + assert mat8 is not custom_mat8 + assert dr.allclose(custom_mat8.color, (0.1, 0.1, 0.1), atol=1e-3) + radio_material_registry.unregister("my_custom_rm8") + + # 9. A custom RadioMaterialBase subclass that does not implement + # `with_color` must raise a clear error when a color override is + # requested, instead of silently propagating a broken material. + class BareRadioMaterial(RadioMaterialBase): + def __init__(self, name): + props = mi.Properties("radio-material") + props.set_id(name) + super().__init__(props) + + custom_mat9 = BareRadioMaterial("my_custom_rm9") + register_radio_material(custom_mat9) + xml_str_unsupported_color_override = """ + + + + + + + + + """ + with pytest.raises(NotImplementedError, match=r".*does not support overriding.*"): + load_scene_from_string(xml_str_unsupported_color_override, merge_shapes=False) + radio_material_registry.unregister("my_custom_rm9")