diff --git a/cg/freecad/utils/__init__.py b/cg/freecad/utils/__init__.py new file mode 100644 index 0000000..74a52df --- /dev/null +++ b/cg/freecad/utils/__init__.py @@ -0,0 +1,31 @@ +# ***** BEGIN GPL LICENSE BLOCK ***** +# +# Copyright (C) 2021-2024 Robossembler LLC +# +# Created by Ilia Kurochkin (brothermechanic) +# contact: brothermechanic@yandex.com +# +# This file is part of Robossembler Framework +# project repo: https://gitlab.com/robossembler/framework +# +# Robossembler Framework 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 . +# +# ***** END GPL LICENSE BLOCK ***** +# +# coding: utf-8 +''' +DESCRIPTION. +FreeCAD modules for Robosembler project pipeline. +''' diff --git a/cg/freecad/utils/freecad_exporters.py b/cg/freecad/utils/freecad_exporters.py new file mode 100644 index 0000000..926bd8a --- /dev/null +++ b/cg/freecad/utils/freecad_exporters.py @@ -0,0 +1,227 @@ +# ***** BEGIN GPL LICENSE BLOCK ***** +# +# Copyright (C) 2021-2024 Robossembler LLC +# +# Created by Ilia Kurochkin (brothermechanic) +# contact: brothermechanic@yandex.com +# +# This file is part of Robossembler Framework +# project repo: https://gitlab.com/robossembler/framework +# +# Robossembler Framework 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 . +# +# ***** END GPL LICENSE BLOCK ***** +# +# coding: utf-8 +''' +DESCRIPTION. +FreeCAD's tools for publishing .FCStd scene. +''' + +__version__ = '0.3' + +import logging +import json +import os +import shutil + +import FreeCAD + +import freecad_tools + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +def export_assembly_trees(doc, clones_dic=None) -> list: + ''' Read FreeCAD .FCStd hierarchy and store it to assembly JSON config files. ''' + # determine root locators + lcs_root_points = [] + for obj in doc.Objects: + if obj.isDerivedFrom('PartDesign::CoordinateSystem'): + if (hasattr(obj, 'Robossembler_DefaultOrigin') + and getattr(obj, 'Robossembler_DefaultOrigin')): + lcs_root_points.append(obj) + if len(lcs_root_points) > 1: + root_locators = [] + for lcs in lcs_root_points: + if hasattr(lcs, 'Robossembler_RootLocator'): + root_locators.append(getattr(lcs, 'Robossembler_DefaultOrigin')) + else: + logger.warning('RootLocator attribute not found for %s LCS! ' + 'So %s used as Root Locator!', + lcs.Label, lcs.InList[0].Label) + root_locators.append(lcs.InList[0].Label) + else: + root_locators = [ + root for root in doc.Objects + if not root.InList + if root .isDerivedFrom('App::Part')] + + config_files = [] + for root_locator in root_locators: + dict_tree = {} + freecad_tools.hierarchy_tree(root_locator, dict_tree, clones_dic) + + # write file + main_file_dir = os.path.dirname(doc.FileName) + assembly_tree_path = os.path.join(main_file_dir, f'{root_locator.Label}.json') + with open(assembly_tree_path, 'w', encoding='utf-8') as json_file: + json.dump(dict_tree, json_file, ensure_ascii=False, indent=4) + logger.info('Assembly tree saved successfully to %s!', assembly_tree_path) + config_files.append(assembly_tree_path) + logger.info('Saved %s assembly trees!', len(config_files)) + + return config_files + + +def export_parts_database( + doc=FreeCAD.getDocument(FreeCAD.ActiveDocument.Label), + clones_dic=None, **tesselation_params): + ''' + Collect parts database and export as JSON config file + [ + { + 'type': '', + 'name': '', + 'attributes': [], + 'part_path': '', + 'material_path': '' + }, + {...}, + ] + ''' + # path directory + main_file_dir = os.path.dirname(doc.FileName) + parts_dir = os.path.join(main_file_dir, 'parts') + if os.path.exists(parts_dir): + shutil.rmtree(parts_dir) + os.makedirs(parts_dir) + else: + os.makedirs(parts_dir) + + # collect all materials + fem_mats = [fem_mat for fem_mat in doc.Objects + if fem_mat.isDerivedFrom('App::MaterialObjectPython')] + material_paths = freecad_tools.get_material_paths() + + parts_db = [] + for obj in doc.Objects: + + # skip hidden objects + if not obj.Visibility: + continue + + # skip non part objects + if not obj.isDerivedFrom('Part::Feature'): + continue + + # skip lcs objects + if obj.isDerivedFrom('PartDesign::CoordinateSystem'): + continue + + # skip non solid part objects + forsed_nonsolid = ( + hasattr(obj, 'Robossembler_NonSolid') + and getattr(obj, 'Robossembler_NonSolid')) + if not freecad_tools.is_object_solid(obj) or forsed_nonsolid: + logger.warning('Part has non solid shape! Please check %s', obj.Label) + continue + + db_obj = {'type': 'PART'} + + db_obj['name'] = obj.Label + if clones_dic: + for k, v in clones_dic.items(): + if obj.Label in v: + db_obj['name'] = k + + robossembler_attrs = [attr for attr in dir(obj) if 'Robossembler' in attr] + if robossembler_attrs: + db_obj['attributes'] = [] + for attr in robossembler_attrs: + db_obj['attributes'].append({attr: getattr(obj, attr)}) + + part_path = os.path.join(parts_dir, db_obj['name'] + '.stl') + if os.path.exists(part_path): + # this is clone + continue + mesh_from_shape = freecad_tools.tesselation( + obj, doc, + # TODO adaptive=(not forsed_nonsolid), + adaptive=False, + **tesselation_params + ) + # export to stl files + #Mesh.export([mesh_from_shape], part_path, tolerance=linear_deflection) + mesh_from_shape.Mesh.write(part_path) + db_obj['part_path'] = os.path.relpath(part_path, main_file_dir) + logger.info('Part %s exported to stl file %s.', obj.Label, part_path) + + # find linked material path + fem_mat_name = [fem_mat.Material['CardName'] + for fem_mat in fem_mats + for ref in fem_mat.References + if ref[0].Label == obj.Label] + if fem_mat_name: + for material_path in material_paths: + if fem_mat_name[0] in material_path: + db_obj['material_path'] = material_path + + # append to database + parts_db.append(db_obj) + + logger.info('Passed %s parts without errors', len(parts_db)) + parts_db_path = os.path.join(main_file_dir, f'{FreeCAD.ActiveDocument.Label}.FCStd.json') + with open(parts_db_path, 'w', encoding='utf-8') as json_file: + json.dump(parts_db, json_file, ensure_ascii=False, indent=4) + logger.info('Parts Database exported successfully to %s!', parts_db_path) + + return parts_db_path + + +def publish_project_database(doc=FreeCAD.getDocument(FreeCAD.ActiveDocument.Label)): + ''' + Publish FCStd document to CG pipeline: + - Exetute openned FreeCAD document. + - Save FCStd document to database root path. + - Export all assembly trees with LCS DefaultOrigin, + or single one as JSON hierarchy tree. + - Set tesselate solid parts to mesh. + - Export all tesselated parts to STL files. + - Return document as JSON dictionary. + ''' + tesselation_params = { + # Select tesselation method: Standard or FEM. + 'tesselation_method': 'Standard', + # Max linear distance error + 'linear_deflection': 0.1, + # Max angular distance error + 'angular_deflection': 20.0, + # For FEM method only! Finite element size in mm + 'fem_size': 10.0 + } + + # TODO Save FCStd project file + #doc.saveAs(u"//") + + clones_dic = freecad_tools.collect_clones(doc) + + export_assembly_trees(doc, clones_dic) + export_parts_database(doc, clones_dic, **tesselation_params) + + logger.info('FreeCAD document %s published!', doc.Label) + + return doc.Label diff --git a/cg/freecad/utils/freecad_to_json.py b/cg/freecad/utils/freecad_to_json.py deleted file mode 100644 index ba18f1e..0000000 --- a/cg/freecad/utils/freecad_to_json.py +++ /dev/null @@ -1,281 +0,0 @@ -# coding: utf-8 -# Copyright (C) 2023 Ilia Kurochkin -# -# 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. -''' -DESCRIPTION. -- Reads a FreeCAD .FCStd file. -- Set tesselation parts to mesh. -- Return scene as JSON dictionary. -''' - -{ - ".FCStd": { - "name": "", - "type": "LOCATOR", - "loc_xyz": ["x", "y", "z"], - "rot_xyzw": ["x", "y", "z", "w"], - "attributes": [], - "clones": {}, - "mesh_path": "", - "material": "", - "children": [ - { - "name": "", - "type": "PART", - "loc_xyz": ["x", "y", "z"], - "rot_xyzw": ["x", "y", "z", "w"], - "attributes": [ - "Robossembler_NonSolid": True - ], - "clones": { - "": [ - "", - ] - }, - "mesh_path": "/path/to/robossembler/database/mesh/..stl", - "material": "/path/to/robossembler/materials/Robossembler_ABS-Dark-Rough.FCMat", - "children": [] - }, - { - "name": "", - "type": "LCS", - "loc_xyz": ["x", "y", "z"], - "rot_xyzw": ["x", "y", "z", "w"], - "attributes": [ - "Robossembler_DefaultOrigin": True, - "Robossembler_SocketFlow": "inlet", - "Robossembler_SocketType": "planar", - "Robossembler_WorldStable": True - ], - "clones": {}, - "mesh_path": "", - "material": "", - "children": [] - } - ] - } -} - -__version__ = '0.1' - -import collections -import logging -import math -import json -import shutil -import xml.etree.ElementTree as ET - -import FreeCAD -import Part -import Mesh -import MeshPart - - -from freecad.utils.solid_tools import (is_object_solid, - collect_clones, - tesselation, - config_parser, - get_material_paths) - -from utils.custom_parser import CustomArgumentParser - -logger = logging.getLogger(__name__) - - -def freecad_to_json(**kwargs): - ''' Reads a FreeCAD .FCStd file and return json assembly. ''' - - scene = {} - js_objs = {} - - doc = FreeCAD.open(kwargs['fcstd_path']) - docname = doc.Name - - # collect equal cad objects - clones = collect_clones(doc): - - # collect all materials - fem_mats = [fem_mat for fem_mat in doc.Objects - if fem_mat.isDerivedFrom('App::MaterialObjectPython') - ] - material_paths = get_material_paths - - for obj in doc.Objects: - - # skip hidden objects - if not obj.Visibility: - continue - - # skip non part or lcs objects - if not (obj.isDerivedFrom('PartDesign::CoordinateSystem') - or obj.isDerivedFrom('PartDesign::CoordinateSystem')): - continue - - js_obj = { - 'name': 'NAME', - 'type': 'TYPE', - 'loc_xyz': ['X', 'Y', 'Z'], - 'rot_xyzw': ['X', 'Y', 'Z', 'W'], - 'attributes': [], - 'clones': {}, - 'mesh_path': '', - 'material': '', - 'children': []} - - js_obj['name'] = obj.Label - - if obj.isDerivedFrom('PartDesign::CoordinateSystem'): - js_obj['type'] = 'LCS' - elif obj.isDerivedFrom('Part::Feature'): - js_obj['type'] = 'PART' - - js_obj['loc_xyz'] = list(obj.Placement.Base) - js_obj['rot_xyzw'] = list(obj.Placement.Rotation.Q) - - for attr in dir(obj): - if 'Robossembler' not in attr: - continue - js_obj['attributes'].append({attr: getattr(obj, attr)}) - - base_names = [k for k, v in mdct.items() if name in v] - if base_names: - js_obj['clones'] = {base_names[0]: clones[base_names[0]]} - else: - js_obj['clones'] = {} - - if obj.isDerivedFrom('Part::Feature'): - # filter for nonsolids - forsed_nonsolid = (hasattr(obj, 'Robossembler_NonSolid') - and getattr(obj, 'Robossembler_NonSolid')) - if is_object_solid(obj) or forsed_nonsolid: - # create mesh from shape - mesh_from_shape = tesselation( - doc, obj, - kwargs['linear_deflection'], kwargs['angular_deflection'], - kwargs['tesselation_method'], kwargs['fem_size'], - adaptive=not forsed_nonsolid - ) - # export to stl files - main_file_path = doc.FileName - main_file_dir = os.path.dirname(main_file_path) - mesh_dir = os.path.join(main_file_dir, 'mesh') - if os.path.exists(mesh_dir): - shutil.rmtree(mesh_dir) - os.makedirs(mesh_dir) - else: - os.makedirs(mesh_dir) - mesh_path = os.path.join(mesh_dir, obj.Label + '.stl') - mesh_from_shape.Mesh.write(mesh_path) - js_obj['mesh_path'] = mesh_path - logger.info('Part %s exported to stl mesh file %s.', obj.Label, mesh_path) - - # find linked material config - fem_mat_name = [fem_mat.Material['CardName'] - for fem_mat in fem_mats - for ref in fem_mat.References - if ref[0].Label == obj.Label - ] - if fem_mat_name: - for material_path in material_paths: - if fem_mat_name[0] in material_path: - js_obj['material'] = material_path - - - # construct assembly hierarchy - obj_parent = obj.getParentGeoFeatureGroup() - obj_child_name = None - parents = {} - deep_index = 0 - while obj_parent: - parent = {} - parent['fc_location'] = tuple(obj_parent.Placement.Base) - parent['fc_rotation'] = obj_parent.Placement.Rotation.Q - obj_child_name = obj_parent.Label - obj_parent = obj_parent.getParentGeoFeatureGroup() - if obj_parent: - parent['parent'] = obj_parent.Label - else: - parent['parent'] = None - parents[obj_child_name] = parent - parent['deep_index'] = deep_index - deep_index += 1 - js_obj['hierarchy'] = parents - - js_objs[obj.Label] = js_obj - - FreeCAD.closeDocument(docname) - - scene[kwargs['fcstd_path']] = js_objs - - logger.info('Passed %s objects without errors', len(js_objs)) - - print(json.dumps(scene)) - - -# to run script via FreeCADCmd -parser = CustomArgumentParser() -parser.add_argument( - '--fcstd_path', - type=str, - help='Path to source FreeCAD scene', - required=True -) -parser.add_argument( - '--tesselation_method', - type=str, - help='Select tesselation method: Standard or FEM.', - default='Standard', - required=False -) -parser.add_argument( - '--linear_deflection', - type=float, - help='Max linear distance error', - default=0.1, - required=False -) -parser.add_argument( - '--angular_deflection', - type=float, - help='Max angular distance error', - default=20.0, - required=False -) -parser.add_argument( - '--fem_size', - type=float, - help='For FEM method only! Finite element size in mm', - default=50.0, - required=False -) -parser.add_argument( - '--skiphidden', - type=bool, - help='Skip processing for hidden FreeCAD objects', - default=True, - required=False -) -""" -parser.add_argument( - '--property_forse_nonsolid', - type=str, - help='FreeCAD property to enable processing for nonsolid objects', - default='Robossembler_NonSolid', - required=False -) -""" - -fc_kwargs = vars(parser.parse_known_args()[0]) - -freecad_to_json(**fc_kwargs) - -logger.info('FreeCAD scene passed!') diff --git a/cg/freecad/utils/solid_tools.py b/cg/freecad/utils/freecad_tools.py similarity index 64% rename from cg/freecad/utils/solid_tools.py rename to cg/freecad/utils/freecad_tools.py index a8d5516..ccdc5c9 100644 --- a/cg/freecad/utils/solid_tools.py +++ b/cg/freecad/utils/freecad_tools.py @@ -27,14 +27,15 @@ # coding: utf-8 ''' DESCRIPTION. -Solid tools for FreeCAD's .FCStd scene. +Various FreeCAD's analise, tesselation and parsing tools. ''' -__version__ = '0.2' +__version__ = '0.5' import difflib import glob import logging import math +import os import xml.dom.minidom import FreeCAD @@ -55,6 +56,12 @@ def is_object_solid(obj) -> bool: if not isinstance(obj, FreeCAD.DocumentObject): return False + if not obj.isDerivedFrom('Part::Feature'): + return False + + if obj.isDerivedFrom('PartDesign::CoordinateSystem'): + return False + if not hasattr(obj, 'Shape'): return False @@ -65,26 +72,27 @@ def collect_clones(doc=FreeCAD.getDocument(FreeCAD.ActiveDocument.Label)) -> lis ''' This script find equal cad parts in a FreeCAD .FCStd scene. - :param doc: document, freecad opened scene (or current scene) - :returns: list with clones sublists [['c0'], ['c1', 'c2', 'c4'],] + :param doc: freecad document by name or current document + :returns: {'basename': ['c1', 'c2', 'c4']} ''' doc_clones = {} - for item in doc.Objects: - # for solid objects only - if not is_object_solid(item): - continue + parts = [item for item in doc.Objects if is_object_solid(item) if item.Visibility] + for part in parts: # collect clones lists item_clones = [] - for other in doc.Objects: - if other == item: + for other in parts: + if other == part: continue - if other.Shape.isPartner(item.Shape): - logger.info('%s and %s objects has equal Shapes', - item.Label, other.Label) - item_clones.append(other.Label) - # if item has clones + if (len(other.Shape.Vertexes) == len(part.Shape.Vertexes) + and len(other.Shape.Edges) == len(part.Shape.Edges) + and len(other.Shape.Faces) == len(part.Shape.Faces)): + if round(other.Shape.Volume, 7) == round(part.Shape.Volume, 7): + logger.info('Objects has equal Shapes: %s and %s ', + part.Label, other.Label) + item_clones.append(other.Label) + # if part has clones if item_clones: - item_clones.append(item.Label) + item_clones.append(part.Label) # find common basename for all clones idx = 0 common_name = item_clones[0].lower() @@ -95,7 +103,7 @@ def collect_clones(doc=FreeCAD.getDocument(FreeCAD.ActiveDocument.Label)) -> lis common_name = common_name[match_data.a:match_data.a + match_data.size] idx += 1 # if names has or hasn't common patterns - item_base_name = common_name.strip(' .,_') or item_clones[0] + item_base_name = common_name.strip(' .,_-') or item_clones[0] # sort list names item_clones.sort() # add only unical list of clones @@ -106,11 +114,13 @@ def collect_clones(doc=FreeCAD.getDocument(FreeCAD.ActiveDocument.Label)) -> lis def tesselation(obj, doc=FreeCAD.getDocument(FreeCAD.ActiveDocument.Label), - linear_deflection=0.1, angular_deflection=20, - tesselation_method='Standard', fem_size=50.0, - adaptive=True) -> list:: + adaptive=False, + linear_deflection=0.1, + angular_deflection=20, + tesselation_method='Standard', + fem_size=50.0) -> list: ''' - Perform shape tesselation. + Perform part's shape tesselation. ''' shape = obj.Shape.copy() shape.Placement = obj.Placement.inverse().multiply(shape.Placement) @@ -145,9 +155,9 @@ def tesselation(obj, linear_deflection = linear_deflection / 2 angular_deflection = angular_deflection / 2 doc.removeObject('mesh_from_shape') - #doc.recompute() + doc.recompute() tesselation( - obj, doc, linear_deflection, angular_deflection, + obj, doc, adaptive, linear_deflection, angular_deflection, tesselation_method, fem_size) return mesh_from_shape @@ -225,3 +235,69 @@ def get_material_paths() -> list: if item.endswith('.FCMat')] ) return material_paths + + +def hierarchy_tree(obj, dict_tree, clones_dic=None) -> dict: + ''' + Collect hierarchy tree as dict dict_tree + { + 'type': '', + 'name': '', + 'base_name': '', + 'loc_xyz': [], + 'rot_xyzw': [], + 'attributes': [], + 'children': [ + { + 'type': '', + 'name': '', + 'base_name': '', + 'loc_xyz': [], + 'rot_xyzw': [], + 'attributes': [], + 'children': [...] + }, + {...}, + ] + } + ''' + # collect type + if obj.isDerivedFrom('Part::Feature'): + if obj.isDerivedFrom('PartDesign::CoordinateSystem'): + dict_tree['type'] = 'LCS' + else: + dict_tree['type'] = 'PART' + elif obj.isDerivedFrom('App::Part'): + dict_tree['type'] = 'LOCATOR' + else: + return False + # collect name + dict_tree['name'] = obj.Label + # collect base_name + if clones_dic: + if obj.isDerivedFrom('Part::Feature'): + for k, v in clones_dic.items(): + if obj.Label in v: + dict_tree['base_name'] = k + # collect transforms + dict_tree['loc_xyz'] = list(obj.Placement.Base) + dict_tree['rot_xyzw'] = list(obj.Placement.Rotation.Q) + # collect attributes + robossembler_attrs = [attr for attr in dir(obj) if 'Robossembler' in attr] + if robossembler_attrs: + dict_tree['attributes'] = [] + for attr in robossembler_attrs: + dict_tree['attributes'].append({attr: getattr(obj, attr)}) + # collect children + if obj.OutList: + dict_tree['children'] = [] + for child in obj.OutList: + # skip hidden objects + if not child.Visibility: + continue + # skip helper objects + if (child.isDerivedFrom('Part::Feature') + or child.isDerivedFrom('App::Part')): + dict_tree['children'].append({}) + hierarchy_tree(child, dict_tree['children'][-1], clones_dic) + return True