Source code for embodichain.lab.sim.shapes

# ----------------------------------------------------------------------------
# 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 math
import warnings
from dataclasses import MISSING
from numbers import Integral
from typing import Any, Dict, List, Literal, Sequence, TYPE_CHECKING

import numpy as np

from embodichain.utils import configclass, is_configclass, logger

if TYPE_CHECKING:
    from embodichain.lab.sim.material import VisualMaterialCfg

__all__ = [
    "MeshCollisionApproximation",
    "MeshCollisionCfg",
    "LoadOption",
    "ShapeCfg",
    "MeshCfg",
    "CubeCfg",
    "SphereCfg",
]


MeshCollisionApproximation = Literal[
    "convex_hull",
    "convex_decomposition",
    "triangle_mesh",
    "sdf",
]
"""Supported collision representations for a triangle mesh."""


[docs] @configclass class MeshCollisionCfg: """Collision-geometry construction for :class:`MeshCfg`. The approximation is explicit. Strategy-specific fields are rejected when they do not apply, so changing a numerical cooking value cannot silently select a different collision representation. """ approximation: MeshCollisionApproximation = "convex_hull" """Collision representation built from the source triangle mesh.""" max_hulls: int | None = None """Maximum hull count for ``convex_decomposition``; must be at least two.""" acd_method: Literal["visacd", "coacd", "vhacd"] | None = None """Approximate-convex-decomposition implementation. ``None`` selects ``visacd`` when compiling mesh collision geometry. VisACD requires a DexSim build with CUDA/OptiX support. """ sdf_resolution: int | None = None """Maximum SDF grid resolution; valid only for the ``sdf`` strategy.""" is_hydroelastic: bool | None = None """Whether Newton uses the generated SDF for hydroelastic contact.""" sdf_narrow_band_range: tuple[float, float] | None = None """Inner and outer signed-distance limits of the Newton SDF band [m].""" sdf_target_voxel_size: float | None = None """Target Newton sparse-SDF voxel size [m], alternative to resolution.""" sdf_texture_format: Literal["uint16", "float32", "uint8"] | None = None """Newton SDF voxel storage format.""" sdf_padding: float | None = None """Extra padding used while Newton builds the mesh SDF [m].""" @property def max_convex_hull_num(self) -> int: """Deprecated compatibility view of :attr:`max_hulls`.""" return self.max_hulls or 1 def __post_init__(self) -> None: """Validate strategy-specific mesh-cooking fields.""" supported = { "convex_hull", "convex_decomposition", "triangle_mesh", "sdf", } if self.approximation not in supported: raise ValueError( "MeshCollisionCfg.approximation must be one of " f"{sorted(supported)}, got {self.approximation!r}." ) if self.approximation == "convex_decomposition": if ( not isinstance(self.max_hulls, Integral) or isinstance(self.max_hulls, bool) or self.max_hulls < 2 ): raise ValueError( "convex_decomposition requires max_hulls to be an integer " "of at least 2." ) if self.acd_method not in (None, "visacd", "coacd", "vhacd"): raise ValueError("acd_method must be 'visacd', 'coacd', or 'vhacd'.") elif self.max_hulls is not None or self.acd_method is not None: raise ValueError( "max_hulls and acd_method are valid only for convex_decomposition." ) sdf_values = { "sdf_resolution": self.sdf_resolution, "is_hydroelastic": self.is_hydroelastic, "sdf_narrow_band_range": self.sdf_narrow_band_range, "sdf_target_voxel_size": self.sdf_target_voxel_size, "sdf_texture_format": self.sdf_texture_format, "sdf_padding": self.sdf_padding, } configured_sdf_fields = [ name for name, value in sdf_values.items() if value is not None ] if self.approximation != "sdf" and configured_sdf_fields: raise ValueError( f"{configured_sdf_fields} are valid only for the sdf approximation." ) if self.sdf_resolution is not None and ( not isinstance(self.sdf_resolution, Integral) or isinstance(self.sdf_resolution, bool) or self.sdf_resolution <= 0 ): raise ValueError("sdf_resolution must be a positive integer.") if self.sdf_target_voxel_size is not None and ( not math.isfinite(self.sdf_target_voxel_size) or self.sdf_target_voxel_size <= 0.0 ): raise ValueError("sdf_target_voxel_size must be finite and positive.") if self.sdf_resolution is not None and self.sdf_target_voxel_size is not None: raise ValueError( "Configure only one of sdf_resolution and sdf_target_voxel_size." ) if self.sdf_padding is not None and ( not math.isfinite(self.sdf_padding) or self.sdf_padding < 0.0 ): raise ValueError("sdf_padding must be finite and non-negative.") if self.is_hydroelastic is not None and not isinstance( self.is_hydroelastic, bool ): raise TypeError("is_hydroelastic must be a boolean when configured.") if self.sdf_texture_format not in (None, "uint16", "float32", "uint8"): raise ValueError( "sdf_texture_format must be 'uint16', 'float32', or 'uint8'." ) if self.sdf_narrow_band_range is not None: if len(self.sdf_narrow_band_range) != 2: raise ValueError("sdf_narrow_band_range must contain two values.") inner, outer = (float(value) for value in self.sdf_narrow_band_range) if not math.isfinite(inner) or not math.isfinite(outer): raise ValueError("sdf_narrow_band_range values must be finite.") if inner > outer: raise ValueError( "sdf_narrow_band_range inner value cannot exceed the outer value." )
[docs] @classmethod def from_dict(cls, init_dict: Dict[str, Any]) -> MeshCollisionCfg: """Parse a mesh-collision mapping, including deprecated field names.""" data = dict(init_dict) legacy_fields = { "max_convex_hull_num", "force_sdf", "sdf_max_resolution", } has_legacy_fields = bool(legacy_fields.intersection(data)) if has_legacy_fields: warnings.warn( "Legacy mesh collision fields are deprecated; use an explicit " "approximation with max_hulls or sdf_resolution.", DeprecationWarning, stacklevel=2, ) legacy_max_hulls = data.pop("max_convex_hull_num", None) legacy_force_sdf = data.pop("force_sdf", None) legacy_sdf_resolution = data.pop("sdf_max_resolution", None) if legacy_sdf_resolution is not None: if "sdf_resolution" in data: raise ValueError( "sdf_max_resolution and sdf_resolution cannot both be configured." ) data["sdf_resolution"] = legacy_sdf_resolution if "approximation" not in data and has_legacy_fields: sdf_requested = bool(legacy_force_sdf) or ( data.get("sdf_resolution") is not None and int(data["sdf_resolution"]) > 0 ) if sdf_requested: data["approximation"] = "sdf" data.pop("max_hulls", None) data.pop("acd_method", None) elif legacy_max_hulls is not None and int(legacy_max_hulls) > 1: data["approximation"] = "convex_decomposition" data["max_hulls"] = int(legacy_max_hulls) else: data["approximation"] = "convex_hull" data.pop("acd_method", None) elif legacy_max_hulls is not None: if "max_hulls" in data: raise ValueError( "max_convex_hull_num and max_hulls cannot both be configured." ) data["max_hulls"] = int(legacy_max_hulls) if data.get("sdf_resolution") == 0: data.pop("sdf_resolution") return cls(**data)
_mesh_collision_cfg_init = MeshCollisionCfg.__init__ def _mesh_collision_cfg_init_with_legacy_max_hulls( self: MeshCollisionCfg, approximation: MeshCollisionApproximation | None = None, max_hulls: int | None = None, acd_method: Literal["visacd", "coacd", "vhacd"] | None = None, sdf_resolution: int | None = None, is_hydroelastic: bool | None = None, sdf_narrow_band_range: tuple[float, float] | None = None, sdf_target_voxel_size: float | None = None, sdf_texture_format: Literal["uint16", "float32", "uint8"] | None = None, sdf_padding: float | None = None, *, max_convex_hull_num: int | None = None, ) -> None: """Initialize with the deprecated hull-count spelling at the API boundary.""" if max_convex_hull_num is not None: warnings.warn( "max_convex_hull_num is deprecated; use max_hulls with an explicit " "approximation.", DeprecationWarning, stacklevel=2, ) if max_hulls is not None: raise ValueError( "max_convex_hull_num and max_hulls cannot both be configured." ) if ( not isinstance(max_convex_hull_num, Integral) or isinstance(max_convex_hull_num, bool) or max_convex_hull_num < 1 ): raise ValueError("max_convex_hull_num must be a positive integer.") if approximation is None: approximation = ( "convex_decomposition" if max_convex_hull_num > 1 else "convex_hull" ) max_hulls = ( None if approximation == "convex_hull" and max_convex_hull_num == 1 else max_convex_hull_num ) _mesh_collision_cfg_init( self, approximation="convex_hull" if approximation is None else approximation, max_hulls=max_hulls, acd_method=acd_method, sdf_resolution=sdf_resolution, is_hydroelastic=is_hydroelastic, sdf_narrow_band_range=sdf_narrow_band_range, sdf_target_voxel_size=sdf_target_voxel_size, sdf_texture_format=sdf_texture_format, sdf_padding=sdf_padding, ) MeshCollisionCfg.__init__ = _mesh_collision_cfg_init_with_legacy_max_hulls
[docs] @configclass class LoadOption: rebuild_normals: bool = False """Whether to rebuild normals for the shape. Defaults to False.""" rebuild_tangent: bool = False """Whether to rebuild tangents for the shape. Defaults to False.""" rebuild_3rdnormal: bool = False """Whether to rebuild the normal for the shape using 3rd party library. Defaults to False.""" rebuild_3rdtangent: bool = False """Whether to rebuild the tangent for the shape using 3rd party library. Defaults to False.""" smooth: float = -1.0 """Angle threshold (in degrees) for smoothing normals. Defaults to -1.0 (no smoothing).""" gltfloader: bool = False """Whether to force DexSim's glTF loader. Defaults to False."""
[docs] @classmethod def from_dict(cls, init_dict: Dict[str, Any]) -> LoadOption: """Initialize the configuration from a dictionary.""" cfg = cls() for key, value in init_dict.items(): if hasattr(cfg, key): setattr(cfg, key, value) else: logger.log_warning( f"Key '{key}' not found in {cfg.__class__.__name__}." ) return cfg
[docs] @configclass class ShapeCfg: shape_type: str = MISSING """Type of the shape. Must be specified in subclasses.""" visual_material: VisualMaterialCfg | None = None """Configuration parameters for the visual material of the shape. Defaults to None."""
[docs] @classmethod def from_dict(cls, init_dict: Dict[str, Any]) -> ShapeCfg: """Initialize the configuration from a dictionary.""" from embodichain.utils.utility import get_class_instance data = dict(init_dict) if "shape_type" not in data: logger.log_error("shape type must be specified in the configuration.") cfg = get_class_instance( "embodichain.lab.sim.shapes", data["shape_type"] + "Cfg" )() legacy_mesh_fields = { "max_convex_hull_num", "acd_method", "sdf_resolution", } if isinstance(cfg, MeshCfg): configured_legacy = legacy_mesh_fields.intersection(data) if configured_legacy: if data.get("collision") is not None: raise ValueError( "MeshCfg collision cannot be combined with deprecated flat " f"mesh fields {sorted(configured_legacy)}." ) legacy_collision = { key: data.pop(key) for key in tuple(configured_legacy) } # Route through the legacy normalizer. Presence of this old hull # name also makes the deprecation warning deterministic. legacy_collision.setdefault("max_convex_hull_num", 1) data["collision"] = legacy_collision for key, value in data.items(): if hasattr(cfg, key): attr = getattr(cfg, key) if key == "visual_material" and isinstance(value, dict): from embodichain.lab.sim.material import VisualMaterialCfg setattr( cfg, key, VisualMaterialCfg.from_dict(value), ) elif key == "collision" and isinstance(cfg, MeshCfg): if value is not None and not isinstance(value, MeshCollisionCfg): if not isinstance(value, dict): raise TypeError( "MeshCfg.collision must be a mapping, " "MeshCollisionCfg, or None." ) value = MeshCollisionCfg.from_dict(value) setattr(cfg, key, value) elif is_configclass(attr): setattr(cfg, key, attr.from_dict(value)) else: setattr(cfg, key, value) else: logger.log_warning( f"Key '{key}' not found in {cfg.__class__.__name__}." ) return cfg
[docs] @configclass class MeshCfg(ShapeCfg): """Configuration parameters for a triangle mesh shape.""" shape_type: str = "Mesh" fpath: str | None = None """File path to the shape mesh file. Provide either this path or both :attr:`vertices` and :attr:`triangles`. """ vertices: Sequence[Sequence[float]] | np.ndarray | None = None """Optional array-backed mesh vertices with shape ``(N, 3)``. Array-backed meshes preserve vertex order, which is useful when per-node deformable flags or kinematic trajectories refer to stable node indices. """ triangles: Sequence[Sequence[int]] | np.ndarray | None = None """Optional array-backed triangle indices with shape ``(M, 3)``.""" normals: Sequence[Sequence[float]] | np.ndarray | None = None """Optional per-vertex normals with shape ``(N, 3)``.""" uv_coords: Sequence[Sequence[float]] | np.ndarray | None = None """Optional per-vertex texture coordinates with shape ``(N, 2)``.""" load_option: LoadOption = LoadOption() """Options for loading and processing the shape.""" compute_uv: bool = False """Whether to compute UV coordinates for the shape. Defaults to False. If the shape already has UV coordinates, setting this to True will recompute and overwrite them. """ project_direction: List[float] = [1.0, 1.0, 1.0] """Direction to project the UV coordinates. Defaults to [1.0, 1.0, 1.0].""" collision: MeshCollisionCfg | None = None """Optional collision representation and cooking parameters. ``None`` uses a single convex hull. Mesh collision construction belongs to the geometry because it cannot be applied meaningfully to primitive shapes or to articulation links without a named source-shape overlay. """
[docs] @configclass class CubeCfg(ShapeCfg): """Configuration parameters for a cube shape.""" shape_type: str = "Cube" size: List[float] = [1.0, 1.0, 1.0] """Size of the cube (in m) as [length, width, height]."""
[docs] @configclass class SphereCfg(ShapeCfg): """Configuration parameters for a sphere shape.""" shape_type: str = "Sphere" radius: float = 1.0 """Radius of the sphere (in m).""" resolution: int = 20 """Resolution of the sphere mesh. Defaults to 20."""