From c64bbf4a707dd1cc609e459a3ca2a3abf8d16843 Mon Sep 17 00:00:00 2001 From: brothermechanic Date: Thu, 21 Dec 2023 08:45:25 +0000 Subject: [PATCH] Blender Scene Compose & Export addon --- README.md | 6 + .../scripts/addons/Robossembler/__init__.py | 139 +++++++++++++ .../scripts/addons/Robossembler/model_md5.py | 30 +++ .../scripts/addons/Robossembler/model_name.py | 18 ++ .../addons/Robossembler/model_paths.py | 12 ++ .../addons/Robossembler/scene_to_json.py | 189 ++++++++++++++++++ docs/scene_generator.md | 61 ++++++ 7 files changed, 455 insertions(+) create mode 100644 cg/blender/scripts/addons/Robossembler/__init__.py create mode 100644 cg/blender/scripts/addons/Robossembler/model_md5.py create mode 100644 cg/blender/scripts/addons/Robossembler/model_name.py create mode 100644 cg/blender/scripts/addons/Robossembler/model_paths.py create mode 100644 cg/blender/scripts/addons/Robossembler/scene_to_json.py create mode 100644 docs/scene_generator.md diff --git a/README.md b/README.md index 233f5b5..cb12262 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,9 @@ - __Предикат стабильной осуществимости__. Верен для последовательности сборки, когда сборка на каждом из этапов приходит к стабильному состоянию. - __Предикат степеней свободы__. Формируется на основе уже сгенерированных графов/графа сборки. В каких степенях свободы возможно перемещать деталь. +# Генерация сцен + +TODO: составить описание + +[пример файла описания сцены](docs/scene_generator) + diff --git a/cg/blender/scripts/addons/Robossembler/__init__.py b/cg/blender/scripts/addons/Robossembler/__init__.py new file mode 100644 index 0000000..2b52d89 --- /dev/null +++ b/cg/blender/scripts/addons/Robossembler/__init__.py @@ -0,0 +1,139 @@ +# ***** BEGIN GPL LICENSE BLOCK ***** +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# and write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301, USA.. +# +# The Original Code is Copyright (C) 2023 by Kurochkin Ilia ### +# All rights reserved. +# +# Contact: brothermechanic@yandex.com ### +# Information: https://gitlab.com/robossembler ### +# +# The Original Code is: all of this file. +# +# ***** END GPL LICENSE BLOCK ***** +# +# -*- coding: utf-8 -*- + +import os +import bpy + +from bpy.types import ( + Panel, + Operator, + PropertyGroup) + +from bpy.props import ( + StringProperty, + EnumProperty, + PointerProperty) + +from .scene_to_json import export_scene_conf + +bl_info = { + 'name': 'Robossembler Tools', + 'author': 'brothermechanic@gmail.com', + 'version': (0, 1), + 'blender': (3, 6, 1), + 'location': '3D View > Toolbox', + 'description': 'Robossembler pipeline tools', + 'warning': '', + 'wiki_url': '', + 'tracker_url': 'https://gitlab.com/robossembler', + 'category': 'Robossembler', +} + + +class addon_Properties(PropertyGroup): + + engine: EnumProperty( + name='Physics Engine', + description='Selest Target Engine', + items=[('BULLET', 'Bullet', ''), + ('ODE', 'O D E', ''), + ('SIMBODY', 'Simbody', ''), + ('OPENSIM', 'OpenSim', '') + ] + ) + + conf_file_path: StringProperty( + name='Config File Path', + description='Input/output config file path', + default='', + maxlen=1023, + subtype='FILE_PATH' + ) + + +class RobossemblerPanel(Panel): + '''Doc''' + bl_label = 'Robossembler Tools' + bl_idname = 'ROBOSSEMBLER_PT_PANEL' + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = 'Robossembler' + + def draw(self, context): + prop = context.scene.robossembler_properties + + layout = self.layout + layout.prop(prop, 'conf_file_path') + layout.prop(prop, 'engine') + + col = layout.column() + col.alert = True + col.scale_y = 2.0 + col.operator('export.scene_config', + icon='WORLD_DATA', + text='Export Scene Config') + + +class RobossemblerOperator(Operator): + '''Tooltip''' + bl_idname = 'export.scene_config' + bl_label = '' + bl_description = 'Export scene config' + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + prop = context.scene.robossembler_properties + + conf_file_path = os.path.realpath(bpy.path.abspath((prop.conf_file_path))) + physics_engine = prop.engine + export_scene_conf(context, conf_file_path, physics_engine) + + return {'FINISHED'} + + +classes = ( + RobossemblerPanel, + RobossemblerOperator, + addon_Properties) + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.Scene.robossembler_properties = PointerProperty(type=addon_Properties) + + +def unregister(): + for cls in classes: + bpy.utils.unregister_class(cls) + del bpy.types.Scene.robossembler_properties + + +if __name__ == '__main__': + register() diff --git a/cg/blender/scripts/addons/Robossembler/model_md5.py b/cg/blender/scripts/addons/Robossembler/model_md5.py new file mode 100644 index 0000000..e76d6fd --- /dev/null +++ b/cg/blender/scripts/addons/Robossembler/model_md5.py @@ -0,0 +1,30 @@ +# coding: utf-8 + +import hashlib +import os + + +def md5(file_path): + ''' Generate md5 hash. ''' + hash_md5 = hashlib.md5() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_md5.update(chunk) + return hash_md5.hexdigest() + + +def create_file_md5(file_path): + ''' Get md5_file_hash of file and store to md5 file. ''' + md5_file_path = f'{file_path}.md5' + md5_file_hash = md5(file_path) + # create md5_file + if not os.path.isfile(md5_file_path): + with open(md5_file_path, 'w', encoding='utf-8') as md5_file: + md5_file.write(md5_file_hash) + else: + with open(md5_file_path, 'r', encoding='utf-8') as md5_file: + md5_file_stored = md5_file.read() + # check md5_file + assert md5_file_hash == md5_file_stored, ( + "Mosel's md5 don't matched with stored %s.md5 file", file_path) + return md5_file_hash diff --git a/cg/blender/scripts/addons/Robossembler/model_name.py b/cg/blender/scripts/addons/Robossembler/model_name.py new file mode 100644 index 0000000..4fb0a50 --- /dev/null +++ b/cg/blender/scripts/addons/Robossembler/model_name.py @@ -0,0 +1,18 @@ +# coding: utf-8 + +import logging +import xml.etree.ElementTree as ET + +logger = logging.getLogger(__name__) + + +def get_sdf_urdf_model_name(model_path): + mytree = ET.parse(model_path) + myroot = mytree.getroot() + for elem in myroot.iter(): + if elem.tag in ('link', 'joint', 'material', 'visual', 'collision'): + continue + if not elem.attrib.get('name'): + continue + return elem.attrib.get('name') + return logger.warning('Model %s do not have a name!', model_path) diff --git a/cg/blender/scripts/addons/Robossembler/model_paths.py b/cg/blender/scripts/addons/Robossembler/model_paths.py new file mode 100644 index 0000000..5f0c0fa --- /dev/null +++ b/cg/blender/scripts/addons/Robossembler/model_paths.py @@ -0,0 +1,12 @@ +# coding: utf-8 + +import glob + + +def get_urdf_sdf_model_paths(models_dir): + ''' Collect models paths. ''' + model_paths = [] + for file_name in glob.glob(f'{models_dir}/**', recursive=True): + if file_name.endswith('.urdf') or file_name.endswith('.sdf'): + model_paths.append(file_name.replace('\\', '/')) + return model_paths diff --git a/cg/blender/scripts/addons/Robossembler/scene_to_json.py b/cg/blender/scripts/addons/Robossembler/scene_to_json.py new file mode 100644 index 0000000..8bc242d --- /dev/null +++ b/cg/blender/scripts/addons/Robossembler/scene_to_json.py @@ -0,0 +1,189 @@ +# ***** BEGIN GPL LICENSE BLOCK ***** +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# and write to the Free Software Foundation, Inc., 51 Franklin Street, +# Fifth Floor, Boston, MA 02110-1301, USA.. +# +# The Original Code is Copyright (C) 2023 by Kurochkin Ilia ### +# All rights reserved. +# +# Contact: brothermechanic@yandex.com ### +# Information: https://gitlab.com/robossembler ### +# +# The Original Code is: all of this file. +# +# ***** END GPL LICENSE BLOCK ***** +# +# -*- coding: utf-8 -*- +''' +DESCRIPTION. +Collect all root objects in blender scene and export as json scene configuration. +''' +__version__ = '0.1' + +import collections +import json +import logging +import math +import os + +from .model_paths import get_urdf_sdf_model_paths +from .model_name import get_sdf_urdf_model_name +from .model_md5 import create_file_md5 + + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +def export_scene_conf(context, scene_config_path, physics_engine): + ''' Export scene config to json. ''' + # open conf file + if os.path.isfile(scene_config_path): + with open(scene_config_path, 'r', encoding='utf-8') as conf_file: + world_dic = json.load(conf_file) + else: + world_dic = collections.defaultdict(list) + models_dir = os.path.join(os.path.dirname(scene_config_path), 'models') + model_paths = get_urdf_sdf_model_paths(models_dir) + for model_path in model_paths: + model_dic = {} + # 1 + model_dic['name'] = get_sdf_urdf_model_name(model_path) + # 2 + model_dic['id'] = create_file_md5(model_path) + # 3 + relative_model_path = model_path.split( + os.path.dirname(scene_config_path))[1][1:] + model_dic['path'] = relative_model_path + world_dic['models'].append(model_dic) + + # collect all non parented objs + roots = [obj for obj in context.scene.objects if not obj.parent] + + # collect instances + for instance in roots: + instance_dic = {} + # 1 + instance_dic['model_name'] = '' + if instance.get('model/name'): + instance_dic['model_name'] = instance['model/name'] + # 2 + instance_dic['model_id'] = '' + for item in world_dic['models']: + if item['name'] != instance_dic['model_name']: + continue + instance_dic['model_id'] = item['id'] + # 2 + instance_dic['id'] = '' + # 3 + instance_dic['pose'] = {} + instance_dic['pose']['x'] = round(instance.location[0], 3) + instance_dic['pose']['y'] = round(instance.location[1], 3) + instance_dic['pose']['z'] = round(instance.location[2], 3) + instance_dic['pose']['roll'] = round(math.degrees(instance.rotation_euler[0]), 3) + instance_dic['pose']['pitch'] = round(math.degrees(instance.rotation_euler[1]), 3) + instance_dic['pose']['yaw'] = round(math.degrees(instance.rotation_euler[2]), 3) + # 4 + if instance.scale[0] != sum(instance.scale[:]) / 3: + logger.warning('Asset instance {} is not uniformly scaled!'.format( + instance.name)) + instance_dic['scale'] = round(instance.scale[0], 3) + # 5 + # if assembly + if instance_dic['model_name']: + instance_dic['type'] = 'asset' + links = instance.children_recursive + links.insert(0, instance) + instance_dic['link_names'] = [] + instance_dic['links'] = {} + transforms = 0.0 + for link in links: + child = {} + if link.type != 'ARMATURE': + continue + bone = link.pose.bones[0] + rotation_mode = bone.rotation_mode + bone.rotation_mode = 'XYZ' + child['pose'] = {} + child['pose']['x'] = round(bone.location[0], 6) + child['pose']['y'] = round(bone.location[1], 6) + child['pose']['z'] = round(bone.location[2], 6) + child['pose']['roll'] = round(math.degrees(bone.rotation_euler[0]), 6) + child['pose']['pitch'] = round(math.degrees(bone.rotation_euler[1]), 6) + child['pose']['yaw'] = round(math.degrees(bone.rotation_euler[2]), 6) + bone.rotation_mode = rotation_mode + if bone.scale[0] != sum(bone.scale[:]) / 3: + logger.warning( + 'Link {} of asset {} is not uniformly scaled!'.format( + link.name, asset_dic['name'])) + child['scale'] = round(bone.scale[0], 3) + transforms += round(sum( + [child['pose']['x'], child['pose']['y'], child['pose']['z'], + child['pose']['roll'], child['pose']['pitch'], child['pose']['yaw']], + (child['scale'] - 1.0)), + 6) + if '.' in link.name: + link_name = link.name.rpartition('.')[0] + else: + link_name = link.name + + instance_dic['link_names'].append(link_name) + instance_dic['links'][link_name] = child + + if transforms == 0.0: + instance_dic.pop('link_names') + instance_dic.pop('links') + + # 6 + instance_dic['parent'] = 'world' + # if light + if instance.type == 'LIGHT': + instance_dic['type'] = instance.type.lower() + if instance.data.type == 'POINT': + instance_dic['light_type'] = 'point' + elif instance.data.type == 'SUN': + instance_dic['light_type'] = 'directional' + elif instance.data.type == 'SPOT': + instance_dic['light_type'] = 'spot' + instance_dic['spot_angle'] = round( + math.degrees(instance.data.spot_size), 1) + else: + logger.warning( + 'Unsupported light type {} on instance {}!'.format( + instance.data.type, instance.name)) + instance_dic['intencity'] = instance.data.energy + instance_dic['diffuse'] = list(instance.data.color) + # if camera + if instance.type == 'CAMERA': + instance_dic['type'] = instance.type.lower() + instance_dic['focal_length'] = instance.data.lens + instance_dic['sensor_size'] = [instance.data.sensor_width, + instance.data.sensor_height] + + world_dic['instances'].append(instance_dic) + + # select physics engine + world_dic['physics'] = {} + world_dic['physics']['engine_name'] = physics_engine + world_dic['physics']['gravity'] = {} + world_dic['physics']['gravity']['x'] = round(context.scene.gravity[0], 3) + world_dic['physics']['gravity']['y'] = round(context.scene.gravity[1], 3) + world_dic['physics']['gravity']['z'] = round(context.scene.gravity[2], 3) + + # write conf file + with open(scene_config_path, 'w', encoding='utf-8') as world_conf_file: + json.dump(world_dic, world_conf_file, ensure_ascii=False, indent=4) + logger.info('Scene configuration in json file was completed successfully!') + return scene_config_path diff --git a/docs/scene_generator.md b/docs/scene_generator.md new file mode 100644 index 0000000..bf29982 --- /dev/null +++ b/docs/scene_generator.md @@ -0,0 +1,61 @@ +## Пример файла описания сцены из Blender + +TODO: рассписать потребность в основных элементах, что и для чего + +```json +{ + "assets": [ + { + "name": "robo-arm", + "id": "629b29d7-fe15-428b-9014-6c3dde045af8", + "model_path": "../model.urdf" + } + ], + "instances": [ + // измненная URDF модель + { + "id": "0e29084f-1190-45d0-bd59-8f4ce2591gb1", + "name": "robo-arm-1", + //assetId указывает на родительский ассет + "assetId": "629b29d7-fe15-428b-9014-6c3dde045af8", + "pose": { + "x": 12.0, + "y": 1.0, + "z": 4.0, + "roll": 1.0, + "pitch": 4.0, + "yaw": 5.0 + }, + //если изменено внутренее состояние URDF модели + "tags": [ + "" + ] + }, + { + "id": "0e27984f-8890-4d90-bd59-8f4ce29920f9", + "name": "robo-arm-2", + //assetId указывает на родительский ассет + "assetId": "629b29d7-fe15-428b-9014-6c3dde045af8", + //если дефолтные позиции модели + "pose": null, + //если не изменено внутренее состояние URDF модели + "tags": null + }, + { + "type": "LIGHT", + "light_type": "SPOT", + "power": 10.0, + "spot_angle": 45.0, + "name": null, + "pose": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "roll": 0.0, + "pitch": 0.0, + "yaw": 0.0 + } + } + ] +} +```