Spaces:
Running on Zero
Running on Zero
Download embodied_gen/utils/io_utils.py from HorizonRobotics/EmbodiedGen-Texture-Gen: direct link, hf CLI and curl.
- Browser
- Download file 24.5 kB
-
https://huggingface.co/spaces/HorizonRobotics/EmbodiedGen-Texture-Gen/resolve/main/embodied_gen/utils/io_utils.py
- Command line
-
hf download hf://spaces/HorizonRobotics/EmbodiedGen-Texture-Gen/embodied_gen/utils/io_utils.py
-
curl -L -o io_utils.py https://huggingface.co/spaces/HorizonRobotics/EmbodiedGen-Texture-Gen/resolve/main/embodied_gen/utils/io_utils.py
24.5 kB
| # Project EmbodiedGen | |
| # | |
| # Copyright (c) 2025 Horizon Robotics. All Rights Reserved. | |
| # | |
| # 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. | |
| import json | |
| import os | |
| import xml.etree.ElementTree as ET | |
| from collections.abc import Iterable, Mapping | |
| from typing import TYPE_CHECKING, Any, Literal | |
| import numpy as np | |
| import trimesh | |
| import trimesh.transformations as tra | |
| if TYPE_CHECKING: | |
| from embodied_gen.utils.geometry import MeshInfo | |
| __all__ = [ | |
| "URDFFile", | |
| "load_json", | |
| "load_mesh", | |
| "load_mesh_info", | |
| "save_mesh", | |
| "write_json", | |
| ] | |
| DEFAULT_URDF_ORIGIN_XYZ = (0.0, 0.0, 0.0) | |
| DEFAULT_URDF_ORIGIN_RPY = (1.5708, 0.0, 0.0) | |
| DEFAULT_URDF_SCALE = (1.0, 1.0, 1.0) | |
| def load_json(path: str | os.PathLike) -> dict: | |
| with open(path, "r", encoding="utf-8") as file: | |
| payload = json.load(file) | |
| return payload | |
| def write_json(payload: dict, path: str | os.PathLike) -> None: | |
| output_path = os.fspath(path) | |
| output_dir = os.path.dirname(output_path) | |
| if output_dir: | |
| os.makedirs(output_dir, exist_ok=True) | |
| with open(output_path, "w", encoding="utf-8") as file: | |
| json.dump(payload, file, indent=4) | |
| def _sapien_pose_from_mesh_transform(mesh_transform: Mapping[str, str]): | |
| import sapien.core as sapien | |
| from scipy.spatial.transform import Rotation as R | |
| origin_xyz = [ | |
| float(value) for value in mesh_transform["origin_xyz"].split() | |
| ] | |
| origin_rpy = [ | |
| float(value) for value in mesh_transform["origin_rpy"].split() | |
| ] | |
| rotation = R.from_euler("xyz", origin_rpy) | |
| quat_xyzw = rotation.as_quat() | |
| local_pose = sapien.Pose( | |
| p=origin_xyz, | |
| q=[ | |
| float(quat_xyzw[3]), | |
| float(quat_xyzw[0]), | |
| float(quat_xyzw[1]), | |
| float(quat_xyzw[2]), | |
| ], | |
| ) | |
| return local_pose | |
| def _mesh_scale_from_transform( | |
| mesh_transform: Mapping[str, str], | |
| ) -> np.ndarray: | |
| return np.array( | |
| [float(v) for v in mesh_transform["scale"].split()], | |
| dtype=np.float64, | |
| ) | |
| def load_mesh_info(urdf_path: str | os.PathLike) -> "MeshInfo": | |
| from embodied_gen.utils.geometry import MeshInfo | |
| urdf = URDFFile(urdf_path) | |
| collision_mesh_path = urdf.get_mesh_path("collision") | |
| collision_mesh_transform = urdf.get_mesh_transform("collision") | |
| collision_mesh_scale = _mesh_scale_from_transform(collision_mesh_transform) | |
| collision_local_pose = _sapien_pose_from_mesh_transform( | |
| collision_mesh_transform | |
| ) | |
| try: | |
| visual_mesh_path = urdf.get_mesh_path("visual") | |
| visual_mesh_transform = urdf.get_mesh_transform("visual") | |
| except ValueError: | |
| visual_mesh_path = collision_mesh_path | |
| visual_mesh_transform = collision_mesh_transform | |
| visual_mesh_scale = _mesh_scale_from_transform(visual_mesh_transform) | |
| visual_local_pose = _sapien_pose_from_mesh_transform(visual_mesh_transform) | |
| mesh = load_mesh( | |
| collision_mesh_path, | |
| apply_origin=True, | |
| **collision_mesh_transform, | |
| ) | |
| object_height = float(mesh.bounds[1, 2] - mesh.bounds[0, 2]) | |
| static_friction, dynamic_friction = urdf.get_collision_friction() | |
| return MeshInfo( | |
| actor_name=urdf.get_robot_name(), | |
| collision_mesh_path=collision_mesh_path, | |
| collision_mesh_scale=collision_mesh_scale, | |
| collision_local_pose=collision_local_pose, | |
| visual_mesh_path=visual_mesh_path, | |
| visual_mesh_scale=visual_mesh_scale, | |
| visual_local_pose=visual_local_pose, | |
| transformed_mesh=mesh, | |
| object_height=object_height, | |
| mass=urdf.get_mass(), | |
| static_friction=static_friction, | |
| dynamic_friction=dynamic_friction, | |
| ) | |
| class URDFFile: | |
| """Small XML helper for reading and writing fields inside one URDF file.""" | |
| def __init__(self, urdf_path: str | os.PathLike): | |
| self.urdf_path = os.fspath(urdf_path) | |
| self.urdf_dir = os.path.dirname(self.urdf_path) | |
| self.tree = ET.parse(self.urdf_path) | |
| self.root = self.tree.getroot() | |
| def reload(self) -> None: | |
| self.tree = ET.parse(self.urdf_path) | |
| self.root = self.tree.getroot() | |
| def save( | |
| self, | |
| urdf_path: str | os.PathLike | None = None, | |
| *, | |
| indent: bool = True, | |
| indent_space: str = " ", | |
| ) -> None: | |
| output_path = ( | |
| os.fspath(urdf_path) if urdf_path is not None else self.urdf_path | |
| ) | |
| if indent: | |
| ET.indent(self.tree, space=indent_space) | |
| self.tree.write(output_path, encoding="utf-8", xml_declaration=True) | |
| def read( | |
| self, | |
| path: str, | |
| *, | |
| attr: str | None = None, | |
| default: Any = None, | |
| required: bool = False, | |
| all_matches: bool = False, | |
| strip: bool = True, | |
| ) -> Any: | |
| nodes = ( | |
| self.root.findall(path) if all_matches else [self.root.find(path)] | |
| ) | |
| if not all_matches and nodes[0] is None: | |
| if required: | |
| raise ValueError( | |
| f"URDF path not found: {path} in {self.urdf_path}" | |
| ) | |
| return default | |
| values = [ | |
| self._read_node_value( | |
| node, attr=attr, default=default, strip=strip | |
| ) | |
| for node in nodes | |
| if node is not None | |
| ] | |
| if all_matches: | |
| if required and not values: | |
| raise ValueError( | |
| f"URDF path not found: {path} in {self.urdf_path}" | |
| ) | |
| return values | |
| return values[0] | |
| def get_mesh_path(self, mesh_type: str = "visual") -> str: | |
| mesh_path = self.read( | |
| f".//{mesh_type}/geometry/mesh", | |
| attr="filename", | |
| required=True, | |
| ) | |
| return self._resolve_path(mesh_path) | |
| def get_robot_name(self, default: str = "dropped_object") -> str: | |
| return self.read(".", attr="name", default=default) | |
| def get_category(self) -> str: | |
| category = self.read(".//extra_info/category", required=True) | |
| if not category: | |
| raise ValueError(f"Empty category in {self.urdf_path}") | |
| return category | |
| def get_mesh_part_seg_path(self) -> str: | |
| mesh_path = self.read( | |
| ".//custom_data/affordance/visual_seg/geometry/mesh", | |
| attr="filename", | |
| required=True, | |
| ) | |
| return self._resolve_path(mesh_path) | |
| def get_affordance_annot_path(self) -> str: | |
| annot_path = self.read( | |
| ".//custom_data/affordance/affordance_annot", | |
| required=True, | |
| ) | |
| return self._resolve_path(annot_path) | |
| def get_mesh_transform(self, mesh_type: str = "visual") -> dict: | |
| return { | |
| "origin_xyz": self.read( | |
| f".//{mesh_type}/origin", | |
| attr="xyz", | |
| default="0 0 0", | |
| ), | |
| "origin_rpy": self.read( | |
| f".//{mesh_type}/origin", | |
| attr="rpy", | |
| default="0 0 0", | |
| ), | |
| "scale": self.read( | |
| f".//{mesh_type}/geometry/mesh", | |
| attr="scale", | |
| default="1 1 1", | |
| ), | |
| } | |
| def get_mass(self) -> float | None: | |
| mass = self.read(".//inertial/mass", attr="value") | |
| return None if mass is None else float(mass) | |
| def get_collision_friction( | |
| self, | |
| *, | |
| default_static: float = 0.7, | |
| default_dynamic: float = 0.6, | |
| ) -> tuple[float, float]: | |
| static_friction = self.read( | |
| ".//collision/gazebo/mu1", | |
| default=str(default_static), | |
| ) | |
| dynamic_friction = self.read( | |
| ".//collision/gazebo/mu2", | |
| default=str(default_dynamic), | |
| ) | |
| return float(static_friction), float(dynamic_friction) | |
| def get_prismatic_joint_control_info(self) -> dict[str, float]: | |
| lower_limits = [] | |
| upper_limits = [] | |
| effort_limits = [] | |
| damping_values = [] | |
| for joint_node in self.root.findall(".//joint"): | |
| if joint_node.get("type") != "prismatic": | |
| continue | |
| limit_node = joint_node.find("limit") | |
| if limit_node is None: | |
| continue | |
| lower_limits.append(float(limit_node.get("lower", "0.0"))) | |
| upper_limits.append(float(limit_node.get("upper", "0.0"))) | |
| effort_limits.append(float(limit_node.get("effort", "0.0"))) | |
| dynamics_node = joint_node.find("dynamics") | |
| if dynamics_node is not None: | |
| damping_values.append( | |
| float(dynamics_node.get("damping", "0.0")) | |
| ) | |
| if not lower_limits or not upper_limits: | |
| raise ValueError( | |
| f"No prismatic joint limits found in {self.urdf_path}" | |
| ) | |
| valid_efforts = [effort for effort in effort_limits if effort > 0.0] | |
| valid_damping = [ | |
| damping for damping in damping_values if damping > 0.0 | |
| ] | |
| return { | |
| "open_qpos": min(upper_limits), | |
| "close_qpos": max(lower_limits), | |
| "drive_damping": max(valid_damping) if valid_damping else 0.0, | |
| "force_limit": min(valid_efforts) if valid_efforts else 0.0, | |
| } | |
| def get_link_names(self) -> list[str]: | |
| return [ | |
| link_name | |
| for link_name in ( | |
| link_node.get("name") | |
| for link_node in self.root.findall("link") | |
| ) | |
| if link_name | |
| ] | |
| def get_child_link_names( | |
| self, | |
| *, | |
| name_contains: str | None = None, | |
| ) -> list[str]: | |
| child_link_names = [] | |
| name_query = ( | |
| name_contains.lower() if name_contains is not None else None | |
| ) | |
| for joint_node in self.root.findall("joint"): | |
| child_node = joint_node.find("child") | |
| link_name = ( | |
| child_node.get("link") if child_node is not None else None | |
| ) | |
| if not link_name: | |
| continue | |
| if name_query is not None and name_query not in link_name.lower(): | |
| continue | |
| child_link_names.append(link_name) | |
| return list(dict.fromkeys(child_link_names)) | |
| def get_link_transforms(self) -> dict[str, np.ndarray]: | |
| child_to_joint = {} | |
| for joint_node in self.root.findall("joint"): | |
| child_node = joint_node.find("child") | |
| parent_node = joint_node.find("parent") | |
| child_name = ( | |
| child_node.get("link") if child_node is not None else None | |
| ) | |
| parent_name = ( | |
| parent_node.get("link") if parent_node is not None else None | |
| ) | |
| if child_name and parent_name: | |
| child_to_joint[child_name] = joint_node | |
| transforms: dict[str, np.ndarray] = {} | |
| def link_transform(link_name: str) -> np.ndarray: | |
| if link_name in transforms: | |
| return transforms[link_name] | |
| joint_node = child_to_joint.get(link_name) | |
| if joint_node is None: | |
| transforms[link_name] = np.eye(4, dtype=np.float64) | |
| return transforms[link_name] | |
| parent_name = joint_node.find("parent").get("link") | |
| transforms[link_name] = link_transform( | |
| parent_name | |
| ) @ self._joint_transform(joint_node) | |
| return transforms[link_name] | |
| for link_name in self.get_link_names(): | |
| link_transform(link_name) | |
| return transforms | |
| def load_link_geometry_mesh( | |
| self, | |
| link_name: str, | |
| geometry_type: Literal["collision", "visual"] = "collision", | |
| ) -> trimesh.Trimesh: | |
| link_node = next( | |
| ( | |
| node | |
| for node in self.root.findall("link") | |
| if node.get("name") == link_name | |
| ), | |
| None, | |
| ) | |
| if link_node is None: | |
| raise ValueError( | |
| f"URDF link not found: {link_name} in {self.urdf_path}" | |
| ) | |
| geom_node = link_node.find(geometry_type) | |
| if geom_node is None and geometry_type == "collision": | |
| geom_node = link_node.find("visual") | |
| if geom_node is None: | |
| raise ValueError( | |
| f"link {link_name} does not contain {geometry_type} geometry" | |
| ) | |
| mesh_node = geom_node.find("geometry/mesh") | |
| mesh_filename = ( | |
| mesh_node.get("filename") if mesh_node is not None else None | |
| ) | |
| if mesh_filename is None: | |
| raise ValueError( | |
| f"link {link_name} does not contain a mesh geometry" | |
| ) | |
| origin_node = geom_node.find("origin") | |
| origin_xyz = ( | |
| origin_node.get("xyz", "0 0 0") | |
| if origin_node is not None | |
| else None | |
| ) | |
| origin_rpy = ( | |
| origin_node.get("rpy", "0 0 0") | |
| if origin_node is not None | |
| else None | |
| ) | |
| return load_mesh( | |
| self._resolve_path(mesh_filename), | |
| origin_xyz=origin_xyz, | |
| origin_rpy=origin_rpy, | |
| scale=mesh_node.get("scale", "1.0 1.0 1.0"), | |
| apply_origin=True, | |
| apply_scale=True, | |
| ) | |
| def write( | |
| self, | |
| updates: Mapping[str, Any] | Iterable[Mapping[str, Any]], | |
| *, | |
| save: bool = True, | |
| urdf_path: str | os.PathLike | None = None, | |
| indent: bool = True, | |
| indent_space: str = " ", | |
| ) -> None: | |
| for update in self._normalize_updates(updates): | |
| node = self._find_or_create(update["path"]) | |
| if update.get("clear_attrs", False): | |
| node.attrib.clear() | |
| if update.get("clear_children", False): | |
| node.clear() | |
| if "text" in update: | |
| node.text = ( | |
| None if update["text"] is None else str(update["text"]) | |
| ) | |
| for key, value in update.get("attrs", {}).items(): | |
| node.set(key, str(value)) | |
| if save: | |
| self.save(urdf_path, indent=indent, indent_space=indent_space) | |
| def _read_node_value( | |
| node: ET.Element, | |
| *, | |
| attr: str | None, | |
| default: Any, | |
| strip: bool, | |
| ) -> Any: | |
| value = node.get(attr) if attr is not None else node.text | |
| if value is None: | |
| return default | |
| if strip and isinstance(value, str): | |
| return value.strip() | |
| return value | |
| def _resolve_path(self, path: str | os.PathLike) -> str: | |
| path = os.fspath(path) | |
| if not os.path.isabs(path): | |
| path = os.path.join(self.urdf_dir, path) | |
| return os.path.normpath(path) | |
| def _origin_transform(origin_node: ET.Element | None) -> np.ndarray: | |
| if origin_node is None: | |
| return np.eye(4, dtype=np.float64) | |
| xyz = _parse_xyz_rpy( | |
| origin_node.get("xyz", "0 0 0"), (0.0, 0.0, 0.0), "origin xyz" | |
| ) | |
| rpy = _parse_xyz_rpy( | |
| origin_node.get("rpy", "0 0 0"), (0.0, 0.0, 0.0), "origin rpy" | |
| ) | |
| transform = tra.euler_matrix(*rpy, axes="sxyz") | |
| transform[:3, 3] = xyz | |
| return transform.astype(np.float64, copy=False) | |
| def _joint_axis(joint_node: ET.Element) -> np.ndarray: | |
| axis_node = joint_node.find("axis") | |
| axis = np.asarray( | |
| _parse_xyz_rpy( | |
| axis_node.get("xyz", "1 0 0") | |
| if axis_node is not None | |
| else None, | |
| (1.0, 0.0, 0.0), | |
| "joint axis", | |
| ), | |
| dtype=np.float64, | |
| ) | |
| norm = np.linalg.norm(axis) | |
| if norm <= 1e-12: | |
| raise ValueError( | |
| f"joint axis must be non-zero, got {axis.tolist()}" | |
| ) | |
| return axis / norm | |
| def _joint_default_position(joint_node: ET.Element) -> float: | |
| if joint_node.get("type") != "prismatic": | |
| return 0.0 | |
| limit_node = joint_node.find("limit") | |
| if limit_node is None: | |
| return 0.0 | |
| return float(limit_node.get("upper", limit_node.get("lower", "0.0"))) | |
| def _joint_transform(cls, joint_node: ET.Element) -> np.ndarray: | |
| transform = cls._origin_transform(joint_node.find("origin")) | |
| joint_position = cls._joint_default_position(joint_node) | |
| if joint_position == 0.0: | |
| return transform | |
| motion = np.eye(4, dtype=np.float64) | |
| motion[:3, 3] = cls._joint_axis(joint_node) * joint_position | |
| return transform @ motion | |
| def _normalize_updates( | |
| updates: Mapping[str, Any] | Iterable[Mapping[str, Any]], | |
| ) -> list[dict[str, Any]]: | |
| if isinstance(updates, Mapping): | |
| normalized = [] | |
| for path, value in updates.items(): | |
| if isinstance(value, Mapping) and any( | |
| key in value | |
| for key in ( | |
| "text", | |
| "attrs", | |
| "clear_attrs", | |
| "clear_children", | |
| ) | |
| ): | |
| item = {"path": path, **dict(value)} | |
| else: | |
| item = {"path": path, "text": value} | |
| normalized.append(item) | |
| return normalized | |
| normalized = [] | |
| for update in updates: | |
| if "path" not in update: | |
| raise ValueError(f"URDF write update missing path: {update}") | |
| normalized.append(dict(update)) | |
| return normalized | |
| def _find_or_create(self, path: str) -> ET.Element: | |
| existing = self.root.find(path) | |
| if existing is not None: | |
| return existing | |
| node = self.root | |
| for tag in self._creation_path_parts(path): | |
| child = node.find(tag) | |
| if child is None: | |
| child = ET.SubElement(node, tag) | |
| node = child | |
| return node | |
| def _creation_path_parts(self, path: str) -> list[str]: | |
| clean_path = path.strip() | |
| if clean_path in ("", "."): | |
| return [] | |
| if clean_path.startswith(".//"): | |
| clean_path = clean_path[3:] | |
| elif clean_path.startswith("./"): | |
| clean_path = clean_path[2:] | |
| if clean_path.startswith(f"{self.root.tag}/"): | |
| clean_path = clean_path[len(self.root.tag) + 1 :] | |
| parts = [part for part in clean_path.split("/") if part] | |
| if any( | |
| any(token in part for token in ("[", "]", "@", "*")) | |
| for part in parts | |
| ): | |
| raise ValueError( | |
| f"URDF write can only create simple element paths, got: {path}" | |
| ) | |
| return parts | |
| def _normalize_scale(scale) -> tuple[float, float, float]: | |
| if isinstance(scale, str): | |
| values = [float(value) for value in scale.split()] | |
| elif isinstance(scale, (int, float, np.number)): | |
| values = [float(scale)] | |
| else: | |
| values = [float(value) for value in scale] | |
| if len(values) == 1: | |
| values = values * 3 | |
| if len(values) != 3: | |
| raise ValueError(f"scale must have 1 or 3 values, got {scale}") | |
| return tuple(values) | |
| def _parse_xyz_rpy( | |
| values: Iterable[float] | str | None, | |
| default: tuple[float, float, float], | |
| name: str, | |
| ) -> list[float]: | |
| if values is None: | |
| parsed = list(default) | |
| elif isinstance(values, str): | |
| parsed = [float(value) for value in values.split()] | |
| else: | |
| parsed = [float(value) for value in values] | |
| if len(parsed) != 3: | |
| raise ValueError(f"{name} must have 3 values, got {parsed}") | |
| return parsed | |
| def _apply_mesh_scale( | |
| mesh: trimesh.Trimesh, | |
| scale: tuple[float, float, float] | str | Iterable[float] | float, | |
| ) -> None: | |
| scale_array = np.asarray(_normalize_scale(scale), dtype=np.float64) | |
| if np.allclose(scale_array, scale_array[0]): | |
| mesh.apply_scale(float(scale_array[0])) | |
| return | |
| transform = np.eye(4, dtype=np.float64) | |
| transform[:3, :3] = np.diag(scale_array) | |
| mesh.apply_transform(transform) | |
| def _apply_inverse_mesh_scale( | |
| mesh: trimesh.Trimesh, | |
| scale: tuple[float, float, float] | str | Iterable[float] | float, | |
| ) -> None: | |
| scale_array = np.asarray(_normalize_scale(scale), dtype=np.float64) | |
| if np.any(np.isclose(scale_array, 0.0)): | |
| raise ValueError(f"scale must be non-zero to invert, got {scale}") | |
| inverse_scale = 1.0 / scale_array | |
| if np.allclose(inverse_scale, inverse_scale[0]): | |
| mesh.apply_scale(float(inverse_scale[0])) | |
| return | |
| transform = np.eye(4, dtype=np.float64) | |
| transform[:3, :3] = np.diag(inverse_scale) | |
| mesh.apply_transform(transform) | |
| def _apply_origin_transform( | |
| mesh: trimesh.Trimesh, | |
| origin_xyz: Iterable[float] | str | None, | |
| origin_rpy: Iterable[float] | str | None, | |
| ) -> None: | |
| xyz = _parse_xyz_rpy(origin_xyz, (0.0, 0.0, 0.0), "origin_xyz") | |
| rpy = _parse_xyz_rpy(origin_rpy, (0.0, 0.0, 0.0), "origin_rpy") | |
| transform = tra.euler_matrix(*rpy, axes="sxyz") | |
| transform[:3, 3] = xyz | |
| mesh.apply_transform(transform.astype(np.float64, copy=False)) | |
| def _apply_inverse_origin_transform( | |
| mesh: trimesh.Trimesh, | |
| origin_xyz: Iterable[float] | str | None, | |
| origin_rpy: Iterable[float] | str | None, | |
| ) -> None: | |
| xyz = _parse_xyz_rpy(origin_xyz, (0.0, 0.0, 0.0), "origin_xyz") | |
| rpy = _parse_xyz_rpy(origin_rpy, (0.0, 0.0, 0.0), "origin_rpy") | |
| transform = tra.euler_matrix(*rpy, axes="sxyz") | |
| transform[:3, 3] = xyz | |
| mesh.apply_transform( | |
| np.linalg.inv(transform).astype(np.float64, copy=False) | |
| ) | |
| def load_mesh( | |
| mesh_path: str | os.PathLike, | |
| *, | |
| origin_xyz: Iterable[float] | str | None = None, | |
| origin_rpy: Iterable[float] | str | None = None, | |
| scale: tuple[float, float, float] | |
| | str | |
| | Iterable[float] | |
| | float | |
| | None = None, | |
| apply_origin: bool = True, | |
| apply_scale: bool = True, | |
| ) -> trimesh.Trimesh: | |
| """Load a mesh and optionally apply URDF mesh scale and origin transform.""" | |
| mesh = trimesh.load(os.fspath(mesh_path), force="mesh", process=False) | |
| if isinstance(mesh, trimesh.Scene): | |
| mesh = mesh.dump(concatenate=True) | |
| if not isinstance(mesh, trimesh.Trimesh): | |
| raise ValueError(f"mesh is not a triangle mesh: {mesh_path}") | |
| if apply_scale and scale is not None: | |
| _apply_mesh_scale(mesh, scale) | |
| if apply_origin: | |
| _apply_origin_transform(mesh, origin_xyz, origin_rpy) | |
| if "face_ids" in mesh.metadata: | |
| return mesh, np.asarray(mesh.metadata["face_ids"], dtype=np.int64) | |
| return mesh | |
| def save_mesh( | |
| mesh: trimesh.Trimesh, | |
| output_path: str | os.PathLike, | |
| *, | |
| origin_xyz: Iterable[float] | str | None = DEFAULT_URDF_ORIGIN_XYZ, | |
| origin_rpy: Iterable[float] | str | None = DEFAULT_URDF_ORIGIN_RPY, | |
| scale: tuple[float, float, float] | |
| | str | |
| | Iterable[float] | |
| | float | |
| | None = DEFAULT_URDF_SCALE, | |
| apply_origin: bool = True, | |
| apply_scale: bool = True, | |
| copy: bool = True, | |
| ) -> str: | |
| """Save a mesh by optionally undoing the default URDF scale and origin.""" | |
| if not isinstance(mesh, trimesh.Trimesh): | |
| raise ValueError( | |
| f"mesh must be a trimesh.Trimesh, got {type(mesh).__name__}" | |
| ) | |
| mesh_to_save = mesh.copy() if copy else mesh | |
| if apply_origin: | |
| _apply_inverse_origin_transform(mesh_to_save, origin_xyz, origin_rpy) | |
| if apply_scale and scale is not None: | |
| _apply_inverse_mesh_scale(mesh_to_save, scale) | |
| output_path = os.fspath(output_path) | |
| output_dir = os.path.dirname(output_path) | |
| if output_dir: | |
| os.makedirs(output_dir, exist_ok=True) | |
| mesh_to_save.export(output_path) | |
| return output_path | |