# ----------------------------------------------------------------------------
# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ----------------------------------------------------------------------------
from __future__ import annotations
import copy
import math
import torch
import dexsim
import numpy as np
from dataclasses import dataclass
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from dexsim.engine import MaterialInst, Material
from embodichain.utils import configclass, logger
if TYPE_CHECKING:
from dexsim.spawn import MaterialDesc
from ._material_presets import _AssetMaterialDesc
__all__ = [
"VisualMaterialCfg",
"VisualMaterial",
"VisualMaterialInst",
"ReuseSegmentState",
]
[docs]
@configclass
class VisualMaterialCfg:
"""Manual PBR properties or explicit overrides of a default material preset.
With ``preset=None``, unspecified common fields receive the historical
defaults at construction. With a preset, ``None`` inherits the authored
resource value. Serialization retains that distinction. Use
:meth:`from_preset` to construct a new preset configuration; ``replace``
preserves existing fields rather than switching configuration sources.
Native scattering is selected by optical parameters, including
``parameters={"transmission": 1.0}`` for a transmitting material. Explicit
BRDF/BTDF/BSDF model selection is not part of this configuration.
"""
uid: str | None = None
"""Explicit shared identity, or None for an automatically named material."""
# Basic PBR properties
base_color: list[float] | None = None
"""Base color/diffuse color (RGBA)"""
metallic: float | None = None
"""Metallic factor (0.0 = dielectric, 1.0 = metallic)"""
roughness: float | None = None
"""Surface roughness (0.0 = smooth, 1.0 = rough)"""
# Additional PBR properties
emissive: list[float] | None = None # Emissive color (RGB)
emissive_intensity: float | None = None # Emissive intensity multiplier
# Texture maps
base_color_texture: str | None = None
"""Base color texture map"""
metallic_texture: str | None = None
"""Metallic map"""
roughness_texture: str | None = None
"""Roughness map"""
normal_texture: str | None = None
"""Normal map"""
ao_texture: str | None = None
"""Ambient occlusion map"""
# Ray tracing specific properties
ior: float | None = None
"""Index of refraction for PBR materials, only used in ray tracing."""
preset: str | None = None
"""Default material name. Presets select scattering from native parameters."""
parameters: dict[str, Any] = {}
"""Native schema-v1 advanced parameters; cannot duplicate common fields."""
def __post_init__(self) -> None:
if self.preset is not None or self.parameters:
from ._material_presets import _canonical_cfg_parameters
self.parameters = _canonical_cfg_parameters(self)
if self.preset is None:
defaults = {
"base_color": [0.5, 0.5, 0.5, 1.0],
"metallic": 0.0,
"roughness": 0.7,
"emissive": [0.0, 0.0, 0.0],
"emissive_intensity": 1.0,
"ior": 1.5,
}
for key, value in defaults.items():
if getattr(self, key) is None:
setattr(self, key, copy.deepcopy(value))
else:
from ._material_presets import _asset_desc_from_cfg
_asset_desc_from_cfg(self, self.uid or "preset")
[docs]
@classmethod
def from_preset(
cls,
name: str,
*,
uid: str | None = None,
parameters: dict[str, Any] | None = None,
**properties: Any,
) -> VisualMaterialCfg:
"""Construct explicit overrides of an authored default material.
Args:
name: Case-sensitive catalog name, or ``SoapBubbleInterior``.
uid: Optional shared identity within the simulation World.
parameters: Advanced native parameters, such as volume absorption.
**properties: Common fields such as ``ior`` or ``roughness``.
Returns:
A configuration retaining ``None`` for inherited common fields.
Raises:
ValueError: A preset, override, duplicate property or texture edit
is invalid.
TypeError: An unknown configuration field is supplied.
"""
from embodichain.data.default_materials import get_default_material_info
get_default_material_info(name)
return cls(
preset=name,
uid=uid,
parameters={} if parameters is None else parameters,
**properties,
)
[docs]
@classmethod
def from_dict(cls, cfg_dict: dict[str, Any]) -> VisualMaterialCfg:
"""Construct manual defaults or preset overrides from Python/YAML data.
Args:
cfg_dict: Serialized configuration, including optional ``preset``.
Returns:
Configuration with inheritance preserved before defaults are filled.
Raises:
ValueError: The removed scattering selector or an unknown preset
field is supplied. Describe optical properties explicitly.
"""
from dataclasses import fields
if "material_type" in cfg_dict:
raise ValueError(
"VisualMaterialCfg.material_type has been removed. Use material "
"parameters such as transmission, roughness, metallic and ior; "
"legacy BTDF/BSDF choices require explicit optical parameters."
)
names = {item.name for item in fields(cls)}
values = {}
for k, v in cfg_dict.items():
if k in names:
values[k] = v
else:
if cfg_dict.get("preset") is not None:
raise ValueError(
f"Unknown field {k!r} in preset VisualMaterialCfg."
)
logger.log_warning(f"Unknown field '{k}' in VisualMaterialCfg.")
return cls(**values)
def _material_scalar(
value: float, name: str, *, lower: float = 0.0, upper: float | None = None
) -> float:
value = float(value)
if (
not math.isfinite(value)
or value < lower
or (upper is not None and value > upper)
):
raise ValueError(f"Visual material {name} has an invalid value: {value}.")
return value
def _material_color(value: Sequence[float], name: str, size: int) -> tuple[float, ...]:
if len(value) != size:
raise ValueError(f"Visual material {name} must have {size} components.")
return tuple(_material_scalar(v, name) for v in value)
def _material_desc_from_cfg(
cfg: VisualMaterialCfg, uid: str
) -> MaterialDesc | _AssetMaterialDesc:
"""Normalize both declaration and runtime material creation at one boundary."""
from dexsim.spawn import MaterialDesc
if not isinstance(uid, str) or not uid.strip():
raise ValueError("Visual material uid must be a nonempty string.")
if not isinstance(cfg.parameters, dict):
raise ValueError("Visual material parameters must be a dictionary.")
if cfg.preset is not None:
from ._material_presets import _asset_desc_from_cfg
return _asset_desc_from_cfg(cfg, uid)
if cfg.parameters:
from ._material_presets import _asset_desc_from_cfg
return _asset_desc_from_cfg(cfg, uid)
color = _material_color(cfg.base_color, "base_color (RGBA)", 4)
_material_scalar(color[3], "base_color alpha", upper=1.0)
emissive = _material_color(cfg.emissive, "emissive (RGB)", 3)
intensity = _material_scalar(cfg.emissive_intensity, "emissive_intensity")
ior = _material_scalar(cfg.ior, "ior")
if ior == 0:
raise ValueError("Visual material ior must be positive.")
return MaterialDesc(
name=uid,
base_color=color,
base_color_map=cfg.base_color_texture,
metallic=_material_scalar(cfg.metallic, "metallic", upper=1.0),
metallic_map=cfg.metallic_texture,
roughness=_material_scalar(cfg.roughness, "roughness", upper=1.0),
roughness_map=cfg.roughness_texture,
normal_map=cfg.normal_texture,
ao_map=cfg.ao_texture,
emissive=(
*(_material_scalar(v * intensity, "emissive") for v in emissive),
1.0,
),
ior=ior,
has_alpha=True,
)
[docs]
class VisualMaterial:
"""Visual material definition in the simulation environment.
A visual material is actually a material template from which material instances can be created.
It holds multiple material instances, which is used to assign to different objects in the environment.
"""
[docs]
def __init__(
self,
cfg: VisualMaterialCfg,
mat: Material,
*,
initialize_default: bool = True,
mat_inst: MaterialInst | None = None,
) -> None:
"""Create a template wrapper or borrow an already configured native one.
Args:
cfg: Defaults for newly created material instances.
mat: Native template owned by the simulation.
initialize_default: Apply configuration to the native default instance.
Disable for a known existing template to preserve runtime edits.
mat_inst: Exact child instance of an immutable native resource template.
"""
self.uid = cfg.uid or f"visual_material_{uuid4().hex}"
self._definition = _material_desc_from_cfg(cfg, self.uid)
self._uses_manifest = cfg.preset is not None or bool(cfg.parameters)
self.cfg = copy.deepcopy(cfg)
self.cfg.uid = self.uid
self._mat = mat
self._instances: dict[str, VisualMaterialInst] = {}
self._native_instance_aliases: dict[str, VisualMaterialInst] = {}
# Spawn resolves a named template through its native default instance.
# Keep that entry configured too when runtime and declarations share a UID.
if mat_inst is None and self._uses_manifest:
mat_inst = self._mat.get_inst(f"ec_instance_{uuid4().hex}")
self._default_mat_inst = VisualMaterialInst(
self.uid,
self._mat,
mat_inst=mat_inst if mat_inst is not None else self._mat.get_inst(),
)
if initialize_default and not self._uses_manifest:
self.set_default_properties(self._default_mat_inst, self.cfg)
self._instances[self.uid] = self._default_mat_inst
if self._uses_manifest:
self._native_instance_aliases[self._default_mat_inst.mat.get_name()] = (
self._default_mat_inst
)
@property
def mat(self) -> Material:
return self._mat
@property
def inst(self) -> VisualMaterialInst:
return self._default_mat_inst
[docs]
def set_default_properties(
self, mat_inst: VisualMaterialInst, cfg: VisualMaterialCfg
) -> None:
"""Explicitly apply validated configuration to an existing instance.
Args:
mat_inst: Instance whose properties will be replaced.
cfg: Material properties to apply.
"""
desc = _material_desc_from_cfg(cfg, self.uid)
if self._uses_manifest:
if desc != self._definition:
raise ValueError(
"Resource material reset requires its original definition; create a variant before binding."
)
self._mat.reset_inst(mat_inst.mat)
mat_inst._emissive_rgb = None
mat_inst._emissive_intensity = 1.0
mat_inst.base_color_texture = mat_inst.mat.get_base_color_map()
mat_inst.metallic_texture = None
mat_inst.roughness_texture = None
mat_inst.normal_texture = None
mat_inst.ao_texture = None
return
mat_inst.set_base_color(desc.base_color)
mat_inst.set_metallic(desc.metallic)
mat_inst.set_roughness(desc.roughness)
mat_inst._set_emissive(cfg.emissive, cfg.emissive_intensity)
mat_inst.set_base_color_texture(cfg.base_color_texture)
mat_inst.set_metallic_texture(cfg.metallic_texture)
mat_inst.set_roughness_texture(cfg.roughness_texture)
mat_inst.set_normal_texture(cfg.normal_texture)
mat_inst.set_ao_texture(cfg.ao_texture)
mat_inst.set_ior(desc.ior)
[docs]
def create_instance(self, uid: str) -> VisualMaterialInst:
"""Create a new material instance from this material template.
Note:
- If the uid already exists, the existing instance will be returned.
Args:
uid (str): Unique identifier for the material instance.
Returns:
VisualMaterialInst: The created material instance.
"""
if uid in self._instances:
return self._instances[uid]
if self._uses_manifest:
# Distinct public templates may share a native immutable template.
# Keep child names private so their user-provided UIDs cannot alias.
inst = VisualMaterialInst(
uid,
self._mat,
mat_inst=self._mat.get_inst(f"ec_instance_{uuid4().hex}"),
)
else:
inst = VisualMaterialInst(uid, self._mat)
self.set_default_properties(inst, self.cfg)
self._instances[uid] = inst
if self._uses_manifest:
self._native_instance_aliases[inst.mat.get_name()] = inst
return inst
[docs]
def get_default_instance(self) -> VisualMaterialInst:
"""Get the default material instance created with the same uid as the material template.
Returns:
VisualMaterialInst: The default material instance.
"""
return self._default_mat_inst
[docs]
def get_instance(self, uid: str) -> VisualMaterialInst:
"""Get an existing material instance by its uid.
Args:
uid (str): Unique identifier for the material instance.
Returns:
VisualMaterialInst: The material instance.
"""
if uid in self._instances:
return self._instances[uid]
if uid in self._native_instance_aliases:
return self._native_instance_aliases[uid]
if uid not in self._instances:
logger.log_error(f"Material instance with uid '{uid}' does not exist.")
return self._instances[uid]
[docs]
class VisualMaterialInst:
"""Instance of a visual material in the simulation environment."""
[docs]
def __init__(
self,
uid: str,
mat: Material,
mat_inst: MaterialInst | None = None,
) -> None:
self.uid = uid
self._mat = mat
self._mat_inst = mat_inst if mat_inst is not None else mat.get_inst(uid)
self._emissive_rgb: tuple[float, ...] | None = None
self._emissive_intensity = 1.0
self.base_color_texture = self.mat.get_base_color_map()
self.metallic_texture = None
self.roughness_texture = None
self.normal_texture = None
self.ao_texture = None
[docs]
@classmethod
def from_existing(cls, mat_inst: MaterialInst) -> VisualMaterialInst:
"""Wrap an existing dexsim material instance without copying it.
Args:
mat_inst: Existing dexsim material instance parsed from an asset.
Returns:
The EmbodiChain wrapper for the existing instance.
Raises:
ValueError: If the material instance has no template.
"""
mat = mat_inst.get_template()
if mat is None:
raise ValueError("Cannot wrap a material instance without a template.")
return cls(uid=mat_inst.get_name(), mat=mat, mat_inst=mat_inst)
@property
def mat(self) -> MaterialInst:
return self._mat_inst
@property
def base_color(self) -> list[float]:
"""Current native display-sRGB color (RGB); DexSim exposes no alpha getter."""
return [float(v) for v in self.mat.get_base_color()[:3]]
@property
def metallic(self) -> float:
"""Current native metallic factor."""
return float(self.mat.get_metallic())
@property
def roughness(self) -> float:
"""Current native roughness."""
return float(self.mat.get_roughness())
@property
def ior(self) -> float:
"""Current native index of refraction."""
return float(self.mat.get_ior())
@property
def emissive(self) -> list[float]:
"""Unscaled emissive RGB, including when intensity is zero."""
if self._emissive_intensity == 0 and self._emissive_rgb is not None:
return list(self._emissive_rgb)
return [
float(v) / self._emissive_intensity for v in self.mat.get_emissive()[:3]
]
@property
def emissive_intensity(self) -> float:
"""Multiplier applied to emissive RGB by this wrapper."""
return self._emissive_intensity
[docs]
def set_base_color(self, color: Sequence[float]) -> None:
"""Set base color/diffuse color."""
color = _material_color(color, "base_color (RGBA)", 4)
_material_scalar(color[3], "base_color alpha", upper=1.0)
self.mat.set_base_color(list(color))
[docs]
def set_roughness(self, roughness: float) -> None:
"""Set surface roughness."""
self.mat.set_roughness(_material_scalar(roughness, "roughness", upper=1.0))
[docs]
def set_emissive(self, emissive: Sequence[float]) -> None:
"""Set emissive color."""
self._set_emissive(emissive, self._emissive_intensity)
def _set_emissive(self, emissive: Sequence[float], intensity: float) -> None:
rgb = _material_color(emissive, "emissive (RGB)", 3)
intensity = _material_scalar(intensity, "emissive_intensity")
value = [_material_scalar(v * intensity, "emissive") for v in rgb]
self.mat.set_emissive(np.asarray([*value, 1.0]))
self._emissive_rgb = rgb
self._emissive_intensity = intensity
[docs]
def set_emissive_intensity(self, intensity: float) -> None:
"""Set emissive intensity multiplier."""
self._set_emissive(self.emissive, intensity)
[docs]
def set_base_color_texture(
self,
texture_path: str = None,
texture_data: torch.Tensor | None = None,
texture_obj=None,
) -> None:
"""Set base color texture from file path, tensor data, or a pre-created Texture.
Args:
texture_path: Path to texture file.
texture_data: Texture data as a torch.Tensor (uploaded each call).
texture_obj: A pre-created dexsim ``Texture`` object. When provided, it is
bound directly without re-uploading (priority over ``texture_data``).
"""
if texture_path is not None and (
texture_data is not None or texture_obj is not None
):
logger.log_warning(
"Both texture_path and another texture source are provided. Using texture_path."
)
if texture_path is not None:
self.base_color_texture = texture_path
inst = self.mat
inst.set_base_color_map(texture_path)
elif texture_obj is not None:
self.base_color_texture = texture_obj
inst = self.mat
inst.set_base_color_map(texture_obj)
elif texture_data is not None:
self.base_color_texture = texture_data
inst = self.mat
# TODO: Optimize texture creation method.
world = dexsim.default_world()
env = world.get_env()
color_texture = env.create_color_texture(
texture_data.cpu().numpy(), has_alpha=True
)
inst.set_base_color_map(color_texture)
[docs]
def set_metallic_texture(
self, texture_path: str = None, texture_data: torch.Tensor | None = None
) -> None:
"""Set metallic texture from file path or texture data.
Args:
texture_path: Path to texture file
texture_data: Texture data as a torch.Tensor
"""
if texture_path is not None and texture_data is not None:
logger.log_warning(
"Both texture_path and texture_data are provided. Using texture_path."
)
if texture_path is not None:
self.metallic_texture = texture_path
inst = self.mat
inst.set_metallic_map(texture_path)
elif texture_data is not None:
self.metallic_texture = texture_data
inst = self.mat
# TODO: Optimize texture creation method.
world = dexsim.default_world()
env = world.get_env()
metallic_texture = env.create_color_texture(
texture_data.cpu().numpy(), has_alpha=False
)
inst.set_metallic_map(metallic_texture)
[docs]
def set_roughness_texture(
self, texture_path: str = None, texture_data: torch.Tensor | None = None
) -> None:
"""Set roughness texture from file path or texture data.
Args:
texture_path: Path to texture file
texture_data: Texture data as a torch.Tensor
"""
if texture_path is not None and texture_data is not None:
logger.log_warning(
"Both texture_path and texture_data are provided. Using texture_path."
)
if texture_path is not None:
self.roughness_texture = texture_path
inst = self.mat
inst.set_roughness_map(texture_path)
elif texture_data is not None:
self.roughness_texture = texture_data
inst = self.mat
# TODO: Optimize texture creation method.
world = dexsim.default_world()
env = world.get_env()
roughness_texture = env.create_color_texture(
texture_data.cpu().numpy(), has_alpha=False
)
inst.set_roughness_map(roughness_texture)
[docs]
def set_normal_texture(
self, texture_path: str = None, texture_data: torch.Tensor | None = None
) -> None:
"""Set normal texture from file path or texture data.
Args:
texture_path: Path to texture file
texture_data: Texture data as a torch.Tensor
"""
if texture_path is not None and texture_data is not None:
logger.log_warning(
"Both texture_path and texture_data are provided. Using texture_path."
)
if texture_path is not None:
self.normal_texture = texture_path
inst = self.mat
inst.set_normal_map(texture_path)
elif texture_data is not None:
self.normal_texture = texture_data
inst = self.mat
# TODO: Optimize texture creation method.
world = dexsim.default_world()
env = world.get_env()
normal_texture = env.create_color_texture(
texture_data.cpu().numpy(), has_alpha=False
)
inst.set_normal_map(normal_texture)
[docs]
def set_ao_texture(
self, texture_path: str = None, texture_data: torch.Tensor | None = None
) -> None:
"""Set ambient occlusion texture from file path or texture data.
Args:
texture_path: Path to texture file
texture_data: Texture data as a torch.Tensor
"""
if texture_path is not None and texture_data is not None:
logger.log_warning(
"Both texture_path and texture_data are provided. Using texture_path."
)
if texture_path is not None:
self.ao_texture = texture_path
inst = self.mat
inst.set_ao_map(texture_path)
elif texture_data is not None:
self.ao_texture = texture_data
inst = self.mat
# TODO: Optimize texture creation method.
world = dexsim.default_world()
env = world.get_env()
ao_texture = env.create_color_texture(
texture_data.cpu().numpy(), has_alpha=False
)
inst.set_ao_map(ao_texture)
[docs]
def set_ior(self, ior: float) -> None:
"""Set index of refraction."""
ior = _material_scalar(ior, "ior")
if ior == 0:
raise ValueError("Visual material ior must be positive.")
self.mat.set_pbr_param("ior", ior)
[docs]
@dataclass
class ReuseSegmentState:
"""Reuse state for one render-body segment of a parsed object.
Used by ``randomize_visual_material`` to randomize on top of the material dexsim
parsed from the asset. It creates a working material instance from the existing
template, but does not create a new material template.
Attributes:
mesh_id: The render-body segment index.
original_inst: The dexsim ``MaterialInst`` parsed from the asset. Kept immutable
and swapped back onto the render body for the "original" tier.
working_inst: A ``VisualMaterialInst`` created from the first segment's
template and shared by sibling segments; mutated in place for the
"library"/"solid" tiers.
"""
mesh_id: int
original_inst: MaterialInst
working_inst: VisualMaterialInst
def _capture_render_materials(render_body) -> list[MaterialInst | None]:
"""Capture the material attached to every render-body mesh segment."""
return [
render_body.get_material(mesh_id)
for mesh_id in range(render_body.get_mesh_count())
]
def _wrap_first_render_material(
materials: list[MaterialInst | None],
) -> VisualMaterialInst | None:
"""Wrap the first material instance that has a template."""
for mat_inst in materials:
if mat_inst is not None and mat_inst.get_template() is not None:
return VisualMaterialInst.from_existing(mat_inst)
return None
def _is_same_material_inst(
current_inst: MaterialInst | None, original_inst: MaterialInst | None
) -> bool:
"""Return whether two handles refer to the same material instance."""
if current_inst is original_inst:
return True
if current_inst is None or original_inst is None:
return False
return current_inst.get_material_id() == original_inst.get_material_id()
def _set_render_material(render_body, mesh_id: int, mat_inst: MaterialInst) -> None:
"""Set a segment material only when it is not already attached."""
if _is_same_material_inst(render_body.get_material(mesh_id), mat_inst):
return
render_body.set_material(mesh_id, mat_inst)
def _restore_render_materials(
render_body, original_materials: list[MaterialInst | None]
) -> None:
"""Restore a render body's captured per-segment material assignments."""
# A null original can only be recovered through the render body's default
# material. Cleaning is unnecessary when every null segment is already null.
if any(
original_inst is None and render_body.get_material(mesh_id) is not None
for mesh_id, original_inst in enumerate(original_materials)
):
render_body.clean_material()
for mesh_id, original_inst in enumerate(original_materials):
if original_inst is not None:
_set_render_material(render_body, mesh_id, original_inst)