import numpy as np import os from .config import Config from .robot import Robot, Link, Part from .processor import Processor from .geometry import Mesh from .message import bright, info, error from stl import mesh, Mode class ProcessorMergeParts(Processor): """ This processor merge all parts into a single one, combining the STL """ def __init__(self, config: Config): super().__init__(config) self.merge_stls = config.get("merge_stls", False) def process(self, robot: Robot): if self.merge_stls: os.makedirs(self.config.asset_path("merged"), exist_ok=True) getcwd = os.getcwd() os.chdir(self.config.output_directory) for link in robot.links: self.merge_parts(link) os.chdir(getcwd) def load_mesh(self, stl_file: str) -> mesh.Mesh: return mesh.Mesh.from_file(stl_file) def save_mesh(self, mesh: mesh.Mesh, stl_file: str): # Tweaking STL header to avoid timestamp # This ensures that same process will result in same STL file def get_header(name): header = "onshape-to-robot" return header[:80].ljust(80, " ") mesh.get_header = get_header mesh.save(stl_file, mode=Mode.BINARY) def transform_mesh(self, mesh: mesh.Mesh, matrix: np.ndarray): rotation = matrix[:3, :3] translation = matrix[:3, 3] def transform(points): return (rotation @ points.T).T + translation mesh.v0 = transform(mesh.v0) mesh.v1 = transform(mesh.v1) mesh.v2 = transform(mesh.v2) mesh.normals = transform(mesh.normals) def combine_meshes(self, m1: mesh.Mesh, m2: mesh.Mesh): return mesh.Mesh(np.concatenate([m1.data, m2.data])) def merge_parts(self, link: Link): print(info(f"+ Merging parts for {link.name}")) merge_everything = ( self.merge_stls != "collision" and self.merge_stls != "visual" ) # Computing the frame where the new part will be located at _, com, __ = link.get_dynamics() T_world_com = np.eye(4) T_world_com[:3, 3] = com # Computing a new color, weighting by masses color = np.zeros(4) total_mass = 0 for part in link.parts: if len(part.meshes): meshes_color = np.mean([mesh.color for mesh in part.meshes], axis=0) color += meshes_color * part.mass total_mass += part.mass color /= total_mass # Changing shapes frame merged_shapes = [] for part in link.parts: if part.shapes is not None: for shape in part.shapes: if merge_everything or shape.is_type(self.merge_stls): # Changing the shape frame T_world_shape = part.T_world_part @ shape.T_part_shape shape.T_part_shape = np.linalg.inv(T_world_com) @ T_world_shape merged_shapes.append(shape) # Merging STL files def accumulate_meshes(which: str): mesh = None for part in link.parts: for part_mesh in part.meshes: if part_mesh.is_type(which): if which == "visual": part_mesh.visual = False else: part_mesh.collision = False # Retrieving meshes part_mesh = self.load_mesh(part_mesh.filename) # Expressing meshes in the merged frame T_com_part = np.linalg.inv(T_world_com) @ part.T_world_part self.transform_mesh(part_mesh, T_com_part) if mesh is None: mesh = part_mesh else: mesh = self.combine_meshes(mesh, part_mesh) return mesh merged_meshes = [] if self.merge_stls != "collision": visual_mesh = accumulate_meshes("visual") if visual_mesh is not None: filename = self.config.asset_path( "merged/" + "/" + link.name + "_visual.stl" ) self.save_mesh(visual_mesh, filename) merged_meshes.append( Mesh(os.path.relpath(filename, self.config.output_directory), color, visual=True, collision=False) ) if self.merge_stls != "visual": collision_mesh = accumulate_meshes("collision") if collision_mesh is not None: filename = self.config.asset_path( "merged/" + "/" + link.name + "_collision.stl" ) self.save_mesh(collision_mesh, filename) merged_meshes.append( Mesh(os.path.relpath(filename, self.config.output_directory), color, visual=False, collision=True) ) mass, com, inertia = link.get_dynamics(T_world_com) if merge_everything: # Remove all parts link.parts = [] else: # We keep the existing parts and add a massless part with merged meshes mass = 0 inertia *= 0 # Replacing parts with a single one link.parts.append( Part( f"{link.name}_parts", T_world_com, mass, com, inertia, merged_meshes, merged_shapes, ) )