Source code for embodichain.lab.sim.objects.deformable.base
# ----------------------------------------------------------------------------
# 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.
# ----------------------------------------------------------------------------
"""Common Newton facade for volume and surface deformable objects."""
from __future__ import annotations
from abc import abstractmethod
from copy import deepcopy
from typing import TYPE_CHECKING, Any, Literal, Sequence
import numpy as np
import torch
from dexsim.scene import Scene
from embodichain.lab.sim.cfg import DeformableObjectCfg
from embodichain.lab.sim.common import BatchEntity
from embodichain.lab.sim.material import (
VisualMaterial,
VisualMaterialInst,
_capture_render_materials,
_restore_render_materials,
_wrap_first_render_material,
)
from embodichain.utils import logger
from embodichain.utils.math import matrix_from_euler, xyz_quat_to_4x4_matrix
from .data import DeformableObjectData
if TYPE_CHECKING:
from dexsim.scene import SpawnedParticleSet
__all__ = ["DeformableObject"]
[docs]
class DeformableObject(BatchEntity):
"""Common facade over a batch of Newton particle-set deformables.
Volume and surface objects retain EmbodiChain's public nodal contract, but
their runtime ownership is exclusively DexSim Spawn's Newton scene. The
Default backend and direct native soft/cloth body buffers are unsupported.
"""
@property
@abstractmethod
def deformable_type(self) -> Literal["volume", "surface"]:
"""Physical topology supplied by the concrete object class."""
[docs]
def __init__(
self,
cfg: DeformableObjectCfg,
device: torch.device = torch.device("cpu"),
) -> None:
"""Create an unregistered deformable facade.
``SpawnScene`` supplies the replicated instance count when declaring
this facade and the finalized ``Scene`` when binding it.
"""
if cfg.deformable_type != self.deformable_type:
raise ValueError(
f"{type(self).__name__} requires deformable_type="
f"{self.deformable_type!r}, got {cfg.deformable_type!r}."
)
self._initialize_unregistered(cfg, device)
def _initialize_unregistered(
self,
cfg: DeformableObjectCfg,
device: torch.device,
) -> None:
"""Initialize state that is independent of Spawn replication."""
self.cfg = deepcopy(cfg)
self.uid = self.cfg.uid
self.device = device
self._entities: list[Any] = []
self._declared_num_instances: int | None = None
self._spawn_result: Scene | None = None
self._world = None
self._data = None
self._all_indices: list[int] = []
self._visual_material: list[VisualMaterialInst | None] = []
self.is_shared_visual_material = False
def _initialize_spawn_declaration(self, num_instances: int) -> None:
"""Initialize instance-dependent declaration state from ``SpawnScene``."""
if num_instances <= 0:
raise ValueError(
f"A declared {type(self).__name__} requires num_instances > 0."
)
if self._declared_num_instances is not None:
if self._declared_num_instances != num_instances:
raise RuntimeError(
f"{type(self).__name__} {self.uid!r} is already declared for "
f"{self._declared_num_instances} instances."
)
return
self._declared_num_instances = num_instances
self._all_indices = list(range(num_instances))
self._visual_material = [None] * num_instances
def _require_declared_num_instances(self) -> int:
"""Return the Spawn-provided instance count or raise a lifecycle error."""
if self._declared_num_instances is None:
raise RuntimeError(
f"{type(self).__name__} {self.uid!r} must be registered through "
"SpawnScene before it can be used."
)
return self._declared_num_instances
def _initialize_topology(self, entities: Sequence[SpawnedParticleSet]) -> None:
"""Capture per-instance render topology with a stable batch shape."""
vertex_counts = tuple(
np.asarray(entity.get_render_vertices(), dtype=np.float32)
.reshape(-1, 3)
.shape[0]
for entity in entities
)
if len(set(vertex_counts)) != 1:
raise RuntimeError(
"Replicated Newton deformable render meshes must share one "
"vertex count, but DexSim materialized counts "
f"{vertex_counts}. This indicates a render-clone topology "
"mismatch; use a compatible source mesh or one environment "
"until the DexSim clone path is corrected."
)
triangles = [
np.asarray(entity.get_render_triangles(), dtype=np.int32).reshape(-1, 3)
for entity in entities
]
triangle_counts = {len(item) for item in triangles}
if len(triangle_counts) != 1:
raise ValueError(
"All instances of one deformable asset must share render "
f"triangle count, got {sorted(triangle_counts)}."
)
for instance, (instance_triangles, vertex_count) in enumerate(
zip(triangles, vertex_counts, strict=True)
):
if instance_triangles.size and (
int(instance_triangles.min()) < 0
or int(instance_triangles.max()) >= vertex_count
):
raise ValueError(
"Deformable render topology contains an out-of-range "
f"vertex index for instance {instance}."
)
self._surface_triangles = torch.as_tensor(
np.stack(triangles),
dtype=torch.int32,
device=self.device,
).clone()
@staticmethod
def _resolve_arena_offsets(
scene: Scene,
entities: Sequence[SpawnedParticleSet],
) -> torch.Tensor:
if not scene.arenas:
offsets = np.zeros((len(entities), 3), dtype=np.float32)
else:
arena_indices = [
scene.arenas.index(entity.arena_name) for entity in entities
]
offsets = scene.arenas.root_offsets[arena_indices]
return torch.as_tensor(offsets, dtype=torch.float32)
def _configured_initial_pose(self) -> torch.Tensor:
if self.cfg.init_local_pose is not None:
pose = torch.as_tensor(
self.cfg.init_local_pose,
dtype=torch.float32,
device=self.device,
).reshape(4, 4)
return pose.clone()
pose = torch.eye(4, dtype=torch.float32, device=self.device)
pose[:3, 3] = torch.as_tensor(
self.cfg.init_pos,
dtype=torch.float32,
device=self.device,
)
rotation = (
torch.as_tensor(
self.cfg.init_rot,
dtype=torch.float32,
device=self.device,
)
* torch.pi
/ 180.0
)
pose[:3, :3] = matrix_from_euler(rotation.unsqueeze(0), "XYZ")[0]
return pose
def _capture_local_rest_positions(self) -> torch.Tensor:
self._require_data()
initial_pose = self._configured_initial_pose()
initial_positions = self.data.default_nodal_state_w[..., :3]
arena_offsets = self._arena_offsets.to(self.device).unsqueeze(1)
translated = initial_positions - initial_pose[:3, 3] - arena_offsets
return translated @ initial_pose[:3, :3]
@property
def is_spawn_bound(self) -> bool:
"""Whether this facade is bound to one finalized Spawn scene."""
return self._spawn_result is not None
@property
def is_declared(self) -> bool:
"""Whether this facade is waiting for its Spawn scene binding."""
return self._world is None
@property
def num_instances(self) -> int:
"""Return the materialized or declared instance count."""
return (
len(self._entities)
if self._entities
else self._require_declared_num_instances()
)
@property
def data(self) -> DeformableObjectData | None:
"""Return the common deformable data view after Spawn binding."""
return self._data
[docs]
def attach_spawn_handles(self, entities: Sequence[Any]) -> None:
"""Store materialized handles before final Spawn binding."""
handles = list(entities)
expected = self._require_declared_num_instances()
if len(handles) != expected:
raise ValueError(
f"{type(self).__name__} {self.uid!r} expected {expected} Spawn "
f"handles, got {len(handles)}."
)
self._entities = handles
def _initialize_spawn_bound(
self,
result: Scene,
entities: Sequence[Any],
) -> None:
"""Create result-dependent runtime state on this declared facade."""
if not isinstance(result, Scene):
raise TypeError(
"DeformableObject binding requires a finalized DexSim Scene; use "
"SimulationManager.prepare()."
)
if result.backend != "newton":
raise NotImplementedError(
"EmbodiChain deformable objects require the Newton backend; "
"the Default backend is no longer supported."
)
cfg = deepcopy(self.cfg)
self._spawn_result = result
self._world = result.world
self._all_indices = list(range(len(entities)))
self._arena_offsets = self._resolve_arena_offsets(result, entities)
self._data = DeformableObjectData(entities, result, self.device)
self._local_rest_positions = self._capture_local_rest_positions()
self._initialize_topology(entities)
self._visual_material = [None] * len(entities)
self.is_shared_visual_material = False
super().__init__(cfg=cfg, entities=list(entities), device=self.device)
self._initialize_existing_visual_material()
self.reset()
[docs]
def bind_spawn(self, result: Scene) -> None:
"""Bind a declared facade to finalized native handles in place."""
if self.is_spawn_bound:
raise RuntimeError(
f"{type(self).__name__} {self.uid!r} is already Spawn-bound."
)
if not self.is_declared:
raise RuntimeError(
f"{type(self).__name__} {self.uid!r} was not created as a Spawn declaration."
)
entities = list(self._entities)
expected = self._require_declared_num_instances()
if len(entities) != expected:
raise ValueError(
f"{type(self).__name__} {self.uid!r} expected {expected} Spawn "
f"handles, got {len(entities)}."
)
declared_state = self.__dict__.copy()
try:
if self.cfg.shape.compute_uv:
for entity in entities:
render_body = entity.get_render_body()
project_uv = getattr(render_body, "set_projective_uv", None)
if project_uv is None:
raise NotImplementedError(
"compute_uv requires a deformable render body with "
"set_projective_uv()."
)
project_uv(np.asarray(self.cfg.shape.project_direction))
self._initialize_spawn_bound(result, entities)
except Exception:
self.__dict__.clear()
self.__dict__.update(declared_state)
raise
def __str__(self) -> str:
if self.is_declared:
return (
f"{self.__class__}: declared {self.num_instances} Spawn "
f"{self.deformable_type} deformable objects | uid: {self.uid} | "
f"device: {self.device}"
)
return super().__str__()
def _initialize_existing_visual_material(self) -> None:
"""Capture and wrap materials parsed from the source asset."""
self._original_visual_material = [[] for _ in self._entities]
self._original_visual_material_inst = [None] * len(self._entities)
for env_idx, entity in enumerate(self._entities):
render_body = entity.get_render_body()
if render_body is None:
continue
original_materials = _capture_render_materials(render_body)
self._original_visual_material[env_idx] = original_materials
wrapped = _wrap_first_render_material(original_materials)
if wrapped is not None:
self._visual_material[env_idx] = wrapped
self._original_visual_material_inst[env_idx] = wrapped
[docs]
def set_visual_material(
self,
mat: VisualMaterial,
env_ids: Sequence[int] | None = None,
shared: bool = False,
) -> None:
"""Assign visual material instances to selected environments."""
local_env_ids = self._resolve_env_ids(env_ids)
if shared:
if len(local_env_ids) != self.num_instances:
logger.log_error("Cannot share material instance for partial env_ids.")
mat_inst = mat.create_instance(f"{mat.uid}_{self.uid}")
for env_idx in local_env_ids:
self._entities[env_idx].set_material(mat_inst.mat)
self._visual_material[env_idx] = mat_inst
self.is_shared_visual_material = True
return
for env_idx in local_env_ids:
mat_inst = mat.create_instance(f"{mat.uid}_{self.uid}_{env_idx}")
self._entities[env_idx].set_material(mat_inst.mat)
self._visual_material[env_idx] = mat_inst
self.is_shared_visual_material = False
[docs]
def restore_visual_material(self, env_ids: Sequence[int] | None = None) -> None:
"""Restore materials captured when the deformable was created."""
if not hasattr(self, "_original_visual_material"):
return
for env_idx in self._resolve_env_ids(env_ids):
render_body = self._entities[env_idx].get_render_body()
if render_body is None:
continue
_restore_render_materials(
render_body, self._original_visual_material[env_idx]
)
self._visual_material[env_idx] = self._original_visual_material_inst[
env_idx
]
self.is_shared_visual_material = False
[docs]
def get_visual_material_inst(
self, env_ids: Sequence[int] | None = None
) -> list[VisualMaterialInst | None]:
"""Return registered material wrappers for selected environments."""
return [self._visual_material[i] for i in self._resolve_env_ids(env_ids)]
def _resolve_env_ids(self, env_ids: Sequence[int] | None) -> list[int]:
if env_ids is None:
return list(self._all_indices)
if isinstance(env_ids, torch.Tensor):
ids = env_ids.detach().cpu().reshape(-1).tolist()
else:
ids = list(env_ids)
resolved = [int(env_id) for env_id in ids]
if any(env_id < 0 or env_id >= self.num_instances for env_id in resolved):
raise IndexError(
f"Environment IDs {resolved!r} are outside [0, {self.num_instances})."
)
return resolved
[docs]
def set_local_pose(
self, pose: torch.Tensor, env_ids: Sequence[int] | None = None
) -> None:
"""Set a deformable pose by transforming its captured rest particles."""
local_env_ids = self._resolve_env_ids(env_ids)
if len(local_env_ids) != len(pose):
raise ValueError(
f"Length of env_ids {len(local_env_ids)} does not match pose "
f"length {len(pose)}."
)
if pose.dim() == 2 and pose.shape[1] == 7:
pose4x4 = xyz_quat_to_4x4_matrix(pose)
elif pose.dim() == 3 and pose.shape[1:] == (4, 4):
pose4x4 = pose
else:
raise ValueError(
f"Invalid pose shape {tuple(pose.shape)}. Expected (N, 7) or "
"(N, 4, 4)."
)
self._apply_local_pose(
pose4x4.to(device=self.device, dtype=torch.float32),
local_env_ids,
)
def _apply_local_pose(
self,
pose: torch.Tensor,
env_ids: Sequence[int],
) -> None:
"""Apply rest-particle transforms through the Spawn particle batch."""
self._require_data()
if not env_ids:
return
index = torch.as_tensor(env_ids, dtype=torch.long, device=self.device)
local_positions = self._local_rest_positions.index_select(0, index)
rotations = pose[:, :3, :3]
translations = pose[:, :3, 3].unsqueeze(1)
arena_offsets = self._arena_offsets.to(self.device).index_select(0, index)
positions = (
torch.bmm(local_positions, rotations.transpose(1, 2))
+ translations
+ arena_offsets.unsqueeze(1)
)
self._data._apply_nodal_state(
positions,
torch.zeros_like(positions),
env_ids,
)
[docs]
def get_local_pose(self, to_matrix: bool = False) -> torch.Tensor:
"""Reject root-pose reads because deformables have no rigid root pose."""
del to_matrix
raise NotImplementedError(
f"Getting local pose for {type(self).__name__} is not supported."
)
def _require_data(self) -> None:
if self.data is None:
raise RuntimeError(
f"{type(self).__name__} data is unavailable before Spawn finalization."
)
[docs]
def get_surface_vertices(self) -> torch.Tensor:
"""Return live render-surface vertices in world frame."""
vertices_per_instance: list[torch.Tensor] = []
render_pose = self._configured_initial_pose()
render_rotation = render_pose[:3, :3]
render_translation = render_pose[:3, 3]
arena_offsets = self._arena_offsets.to(self.device)
for env_idx, entity in enumerate(self._entities):
vertices_warp = entity.get_render_vertices_warp()
if vertices_warp is None:
vertices = torch.as_tensor(
entity.get_render_vertices(),
dtype=torch.float32,
device=self.device,
).reshape(-1, 3)
else:
import warp as wp
vertices = wp.to_torch(vertices_warp).reshape(-1, 3).to(self.device)
vertices_per_instance.append(
vertices @ render_rotation.T
+ render_translation
+ arena_offsets[env_idx]
)
vertex_counts = {len(vertices) for vertices in vertices_per_instance}
if len(vertex_counts) != 1:
raise ValueError(
"All instances of one deformable asset must share render vertex count."
)
return torch.stack(vertices_per_instance).clone()
[docs]
def get_surface_triangles(
self, env_ids: Sequence[int] | None = None
) -> torch.Tensor:
"""Return render-surface triangle indices for selected environments."""
ids = self._resolve_env_ids(env_ids)
index = torch.as_tensor(ids, dtype=torch.long, device=self.device)
return self._surface_triangles.index_select(0, index).clone()
[docs]
def reset(self, env_ids: Sequence[int] | None = None) -> None:
"""Restore the configured pose, zero velocity, and source materials."""
local_env_ids = self._resolve_env_ids(env_ids)
self.restore_visual_material(env_ids=local_env_ids)
initial_pose = self._configured_initial_pose()
pose = initial_pose.unsqueeze(0).repeat(len(local_env_ids), 1, 1)
self.set_local_pose(pose, env_ids=local_env_ids)
[docs]
def destroy(self) -> None:
"""Leave particle lifetime ownership with the finalized Spawn scene."""