Browse Source

- a working implementation of fbx exporting.
- updated from Blender 3.3 to 3.4 as there are some bugs during fbx exporting.
- added a workaround to disable prefab for AP builds to avoid issues with duplicate subIds.

Signed-off-by: Jason Dela Cruz <[email protected]>

Jason Dela Cruz 2 years ago
parent
commit
556b6e91d8

+ 111 - 12
Gems/O3DE/GeomNodes/Code/Source/Editor/Components/EditorGeomNodesComponent.cpp

@@ -7,7 +7,10 @@
 
 #include <AzToolsFramework/API/ToolsApplicationAPI.h>
 #include <AzToolsFramework/API/EntityCompositionRequestBus.h>
+#include <AzToolsFramework/ToolsComponents/TransformComponent.h>
+#include <AtomLyIntegration/CommonFeatures/Mesh/MeshComponentConstants.h>
 #include <AtomLyIntegration/CommonFeatures/Material/MaterialComponentConstants.h>
+#include <AtomLyIntegration/CommonFeatures/Mesh/MeshComponentBus.h>
 #include <AzCore/Utils/Utils.h>
 #include <Editor/Systems/GNProperty.h>
 #include <AzCore/JSON/prettywriter.h>
@@ -15,6 +18,7 @@
 #include <Atom/Feature/Mesh/MeshFeatureProcessorInterface.h>
 #include <Atom/RPI.Public/Scene.h>
 #include <AzCore/Component/NonUniformScaleBus.h>
+#include <AzCore/std/string/regex.h>
 
 namespace GeomNodes
 {
@@ -61,9 +65,13 @@ namespace GeomNodes
                     ->Attribute(Attributes::FuncValidator, ConvertFunctorToVoid(&Validators::ValidBlenderOrEmpty))
                     ->Attribute(Attributes::SelectFunction, blendFunctor)
                     ->Attribute(Attributes::ValidationChange, &EditorGeomNodesComponent::OnPathChange)
-                    ->DataElement(nullptr, &EditorGeomNodesComponent::m_paramContext, "Geom Nodes Parameters", "Parameter template")
+					->DataElement(nullptr, &EditorGeomNodesComponent::m_paramContext, "Geom Nodes Parameters", "Parameter template")
                     ->SetDynamicEditDataProvider(&EditorGeomNodesComponent::GetParamsEditData)
                         ->Attribute(AZ::Edit::Attributes::Visibility, AZ::Edit::PropertyVisibility::ShowChildrenOnly)
+					->UIElement(AZ::Edit::UIHandlers::Button, "", "Export to static mesh")
+					->Attribute(AZ::Edit::Attributes::ChangeNotify, &EditorGeomNodesComponent::ExportToStaticMesh)
+					->Attribute(AZ::Edit::Attributes::ButtonText, "Export")
+                    ->Attribute(AZ::Edit::Attributes::Visibility, &EditorGeomNodesComponent::IsBlenderFileLoaded)
                     ;
 
                 ec->Class<GNParamContext>("Geom Nodes Parameter Context", "Adding exposed Geometry Nodes parameters to the entity!")
@@ -148,6 +156,9 @@ namespace GeomNodes
                 }
 
                 AzFramework::StringFunc::Path::GetFileName(path.c_str(), m_currentBlenderFileName);
+
+                AZStd::regex reg("[^\\w\\s]+");
+                m_currentBlenderFileName = AZStd::regex_replace(m_currentBlenderFileName, reg, "_");
             }
         }
     }
@@ -255,6 +266,18 @@ namespace GeomNodes
 
                 m_manageChildEntities = true; // tell OnTick that we want to manage the child entities
             }
+            else if (jsonDocument.HasMember(Field::Export) && jsonDocument.HasMember(Field::Error))
+            {
+                AZStd::string errorMsg = jsonDocument[Field::Error].GetString();
+                if (errorMsg.empty())
+                {
+                    
+                }
+                else {
+                    // TODO: error message
+                    m_exportRequested = false;
+                }
+            }
         }
         else
         {
@@ -262,6 +285,33 @@ namespace GeomNodes
         }
     }
 
+    void EditorGeomNodesComponent::ExportToStaticMesh()
+    {
+        if (!m_exportRequested)
+        {
+            auto msg = AZStd::string::format(
+				R"JSON(
+                    {
+                        "%s": true,
+                        "%s": "%s",
+                        "%s": "%s"
+                    }
+                    )JSON",
+				Field::Export,
+				Field::Object,
+                m_currentObject.c_str(),
+                Field::FBXPath,
+                GenerateFBXPath().c_str());
+			m_instance->SendIPCMsg(msg);
+            m_exportRequested = true;
+        }
+    }
+
+    bool EditorGeomNodesComponent::IsBlenderFileLoaded()
+    {
+        return m_initialized;
+    }
+
     void EditorGeomNodesComponent::LoadObjects(const rapidjson::Value& objectNameArray, const rapidjson::Value& objectArray)
     {
         
@@ -448,7 +498,7 @@ namespace GeomNodes
             AZStd::string materialContent = matItr->value.GetString();
 			
             AZStd::string fullFilePath = GetProjectRoot() + "/";
-            AZStd::string materialFilePath = AZStd::string::format(MaterialFilePathFormat.data(), m_currentBlenderFileName.c_str());
+            AZStd::string materialFilePath = AZStd::string(AssetsFolderPath) + m_currentBlenderFileName + "/" + MaterialsFolder.data() + "/";
             
             fullFilePath += materialFilePath + materialName + MaterialExtension.data();
 
@@ -531,18 +581,49 @@ namespace GeomNodes
 		// otherwise, you look up assetId (and its a legacy assetId) and the actual asset will be different.
         if ((assetInfo.m_assetId.IsValid()) && (assetInfo.m_assetId == assetId))
         {
-			for (auto entityId : m_entityIdList)
+			AZStd::string assetName;
+			AzFramework::StringFunc::Path::GetFileName(assetInfo.m_relativePath.c_str(), assetName);
+
+            if (m_exportRequested && (assetName == GenerateModelAssetName()))
             {
-				AZStd::string materialName;
-				AzFramework::StringFunc::Path::GetFileName(assetInfo.m_relativePath.c_str(), materialName);
+                auto transformComponent = GetEntity()->FindComponent<AzToolsFramework::Components::TransformComponent>();
+				AZ::EntityId parentId = transformComponent->GetParentId();
+                auto worldTransform = transformComponent->GetWorldTM();
 
-                auto meshData = m_modelData.GetMeshData((AZ::u64)entityId);
-                if (meshData.GetMaterial() == materialName)
-                {
-                    AZ_Printf("GeomNodes", "added %s", materialName.c_str());
-					EditorGeomNodesMeshComponentEventBus::Event(entityId, &EditorGeomNodesMeshComponentEvents::OnMeshDataAssigned, meshData);
-                    break;
-                }
+				AZ::EntityId entityId;
+				EBUS_EVENT_RESULT(entityId, AzToolsFramework::EditorRequests::Bus, CreateNewEntity, parentId);
+
+                AzToolsFramework::EntityIdList entityIdList = {entityId};
+
+				AzToolsFramework::EntityCompositionRequests::AddComponentsOutcome addedComponentsResult = AZ::Failure(AZStd::string("Failed to call AddComponentsToEntities on EntityCompositionRequestBus"));
+				AzToolsFramework::EntityCompositionRequestBus::BroadcastResult(addedComponentsResult, &AzToolsFramework::EntityCompositionRequests::AddComponentsToEntities, entityIdList, AZ::ComponentTypeList{ AZ::Render::EditorMeshComponentTypeId });
+
+				if (addedComponentsResult.IsSuccess())
+				{
+					AZ::TransformBus::Event(
+                        entityId, &AZ::TransformBus::Events::SetWorldTM, worldTransform);
+
+                    AZ::Render::MeshComponentRequestBus::Event(
+                        entityId, &AZ::Render::MeshComponentRequestBus::Events::SetModelAssetPath, assetInfo.m_relativePath);
+
+					
+                    //TODO: delete this entity
+				}
+
+                m_exportRequested = false;
+            }
+            else
+            {
+				for (auto entityId : m_entityIdList)
+				{
+					auto meshData = m_modelData.GetMeshData((AZ::u64)entityId);
+					if (meshData.GetMaterial() == assetName)
+					{
+						AZ_Printf("GeomNodes", "added %s", assetName.c_str());
+						EditorGeomNodesMeshComponentEventBus::Event(entityId, &EditorGeomNodesMeshComponentEvents::OnMeshDataAssigned, meshData);
+						break;
+					}
+				}
             }
         }
     }
@@ -590,6 +671,24 @@ namespace GeomNodes
         return m_cachedStrings.insert(AZStd::make_pair(str, AZStd::string(str))).first->second.c_str();
     }
 
+    AZStd::string EditorGeomNodesComponent::GenerateFBXPath()
+    {
+		AZStd::string fullFilePath = GetProjectRoot() + "/";
+        AZStd::string filePath = AZStd::string(AssetsFolderPath) + m_currentBlenderFileName + "/";
+        fullFilePath += filePath + GenerateModelAssetName() + FbxExtension.data();
+        return fullFilePath;
+    }
+
+    AZStd::string EditorGeomNodesComponent::GenerateModelAssetName()
+    {
+        return m_currentBlenderFileName + "_" + AZStd::string::format("%llu", (AZ::u64)GetEntityId());
+    }
+
+    AZStd::string EditorGeomNodesComponent::GenerateAZModelFilename()
+    {
+		return GenerateModelAssetName() + AzModelExtension.data();
+    }
+
     void EditorGeomNodesComponent::ManageChildEntities()
     {
 		AzToolsFramework::EntityIdList entityIdList;

+ 11 - 2
Gems/O3DE/GeomNodes/Code/Source/Editor/Components/EditorGeomNodesComponent.h

@@ -61,9 +61,12 @@ namespace GeomNodes
         };
         
 		//! constants
-		static constexpr AZStd::string_view MaterialFilePathFormat = "assets/geomNodes/%s/materials/";
+		static constexpr AZStd::string_view AssetsFolderPath = "assets/geomNodes/";
+        static constexpr AZStd::string_view MaterialsFolder = "materials";
 		static constexpr AZStd::string_view MaterialExtension = ".material";
 		static constexpr AZStd::string_view AzMaterialExtension = ".azmaterial";
+        static constexpr AZStd::string_view FbxExtension = ".fbx";
+        static constexpr AZStd::string_view AzModelExtension = ".azmodel";
 
         typedef AZStd::vector<AZStd::string> StringVector;
         
@@ -72,6 +75,9 @@ namespace GeomNodes
         void OnParamChange();
         void OnMessageReceived(const AZ::u8* content, const AZ::u64 length) override;
 
+        void ExportToStaticMesh();
+        bool IsBlenderFileLoaded();
+
         void LoadObjects(const rapidjson::Value& objectNameArray, const rapidjson::Value& objectArray);
         void LoadObjectNames(const rapidjson::Value& objectNames);
         void LoadParams(const rapidjson::Value& objectArray);
@@ -92,7 +98,9 @@ namespace GeomNodes
         void AddDataElement(GNProperty* gnParam, ElementInfo& ei);
 
         const char* CacheString(const char* str);
-
+        AZStd::string GenerateFBXPath();
+        AZStd::string GenerateModelAssetName();
+        AZStd::string GenerateAZModelFilename();
         void ManageChildEntities();
         AZStd::atomic_bool m_manageChildEntities{ false };
 
@@ -113,5 +121,6 @@ namespace GeomNodes
         AzToolsFramework::EntityIdList m_entityIdList;
 
         bool m_initialized = false;
+        bool m_exportRequested = false;
     };
 }

+ 1 - 1
Gems/O3DE/GeomNodes/Code/Source/Editor/Configuration/GNConfiguration.h

@@ -19,7 +19,7 @@ namespace GeomNodes
 
         static GNConfiguration CreateDefault();
 
-        AZStd::string m_blenderPath = "C:/Program Files/Blender Foundation/Blender 3.3/blender.exe"; //!< Currently set blender path in user's machine.
+        AZStd::string m_blenderPath = "C:/Program Files/Blender Foundation/Blender 3.4/blender.exe"; //!< Currently set blender path in user's machine.
 
         AZStd::string m_lastFilePath; //!< Last file path used when selecting a blender file.
 

+ 4 - 0
Gems/O3DE/GeomNodes/Code/Source/Editor/Systems/GNParamContext.h

@@ -20,6 +20,8 @@ namespace GeomNodes
         static constexpr char SHMOpen[] = "SHMOpen";
         static constexpr char SHMClose[] = "SHMClose";
         static constexpr char MapId[] = "MapId";
+        static constexpr char Export[] = "Export";
+        static constexpr char Error[] = "Error";
 
         static constexpr char Params[] = "Params";
         static constexpr char Materials[] = "Materials";
@@ -30,6 +32,8 @@ namespace GeomNodes
         static constexpr char Value[] = "Value";
         static constexpr char MinValue[] = "MinValue";
         static constexpr char MaxValue[] = "MaxValue";
+
+        static constexpr char FBXPath[] = "FBXPath";
     }
 
     enum class ParamType : AZ::u8

+ 3 - 1
Gems/O3DE/GeomNodes/Code/Source/Editor/Systems/GeomNodesSystem.cpp

@@ -9,7 +9,9 @@ namespace GeomNodes
 {
     long IpcHandlerCB(AZ::u64 id, const char* data, AZ::u64 length)
     {
-        AZ_Printf("GeomNodesSystem", "id: %llu data: %s length: %llu", id, data, length);
+        AZStd::string msg;
+        msg.assign(data, length);
+        AZ_Printf("GeomNodesSystem", "id: %llu data: %s length: %llu", id, msg.c_str(), length);
         Ipc::IpcHandlerNotificationBus::Event(
             AZ::EntityId(id), &Ipc::IpcHandlerNotificationBus::Events::OnMessageReceived, reinterpret_cast<const AZ::u8*>(data), length);
 

+ 56 - 6
Gems/O3DE/GeomNodes/External/Scripts/GeomNodes.py

@@ -4,8 +4,9 @@ import os
 import time
 import datetime
 import json
-from messages import poll_for_messages, PollReturn
+from messages import poll_for_messages, PollReturn, update_gn_in_blender
 #from materials.blender_materials import print_materials_in_scene, get_o3de_materials
+from exporter import fbx_exporter
 
 dir = os.path.dirname(bpy.data.filepath)
 if not dir in sys.path:
@@ -27,10 +28,8 @@ def run():
     # send message to the gem that script is initialized
     MessageWriter().from_buffer(bytes(json.dumps({'Initialized' : True}), "UTF-8"))
     
-    # TEST
-    #print(json.dumps(get_o3de_materials(), indent=4))
-    #print_materials_in_scene()
-    
+    #run_tests()
+
     heartbeat_sent = False
     heartbeat_wait_time = 0
     heartbeat_time = 0
@@ -67,4 +66,55 @@ def run():
     if bpy.app.background:
         GNLibs.Uninitialize()
     else:
-        return update_rate
+        return update_rate
+    
+
+def run_tests():
+    msg_dict = {
+                    "Params": [
+                    {
+                        "Id": "Input_5",
+                        "Value": True,
+                        "Type": "BOOLEAN"
+                    }
+                ,
+                    {
+                        "Id": "Input_8",
+                        "Value": True,
+                        "Type": "BOOLEAN"
+                    }
+                ,
+                    {
+                        "Id": "Input_6",
+                        "Value": 3,
+                        "Type": "INT"
+                    }
+                ,
+                    {
+                        "Id": "Input_7",
+                        "Value": 12,
+                        "Type": "INT"
+                    }
+                ,
+                    {
+                        "Id": "Input_3",
+                        "Value": 3.00000000000000000,
+                        "Type": "VALUE"
+                    }
+                ,
+                    {
+                        "Id": "Input_9",
+                        "Value": 3.00000000000000000,
+                        "Type": "VALUE"
+                    }
+                ],
+                    "Object": "building_base",
+                    "Frame": 0,
+                    "ParamUpdate": True
+                }
+    obj_name = update_gn_in_blender(msg_dict)
+    fbx_exporter.fbx_file_exporter(obj_name, "F:/o3de_proj/TestProject/assets/geomNodes/buildify_1_0/buildify_1_0.fbx", False)
+    
+    # TEST
+    #print(json.dumps(get_o3de_materials(), indent=4))
+    #print_materials_in_scene()

+ 1 - 1
Gems/O3DE/GeomNodes/External/Scripts/__init__.py

@@ -28,7 +28,7 @@ _LOGGER.debug('uuid: ' + params[1])
 _LOGGER.debug('pid: ' + str(os.getpid()))
 
 if __name__ == "__main__":
-    from GeomNodes import init, run
+    from geomnodes import init, run
     init(params[0], params[1])
 
     # run our loop to watch for updates

+ 5 - 0
Gems/O3DE/GeomNodes/External/Scripts/constants.py

@@ -0,0 +1,5 @@
+# -------------------------------------------------------------------------
+from pathlib import Path
+# -------------------------------------------------------------------------
+
+IMAGE_EXT = ('', '.jpg', '.png', '.JPG', '.PNG')

+ 110 - 0
Gems/O3DE/GeomNodes/External/Scripts/exporter/fbx_exporter.py

@@ -0,0 +1,110 @@
+import bpy
+from pathlib import Path
+import utils
+from materials import mat_helper
+# import logging as _logging
+# _LOGGER = _logging.getLogger('GeomNodes.External.Scripts.exporter.fbx_exporter')
+
+def fbx_file_exporter(obj_name, fbx_file_path, mesh_to_triangles):
+    """!
+    This function will send to selected .FBX to an O3DE Project Path
+    @param fbx_file_path this is the o3de project path where the meshe(s)
+    will be exported as an .fbx
+    @param file_name A custom file name string
+    """
+
+    print(fbx_file_path)
+
+    # Lets create a dictionary to store all the source paths to place back after export
+    stored_image_source_paths = {}
+    source_file_path = Path(fbx_file_path) # Covert string to path
+    mat_helper.clone_repath_images(source_file_path, stored_image_source_paths)
+
+    # Deselect all objects
+    bpy.ops.object.select_all(action='DESELECT')
+
+    geomnodes_obj = bpy.data.objects.get(obj_name)
+    if geomnodes_obj == None:
+        geomnodes_obj = utils.get_geomnodes_obj()
+    
+    if geomnodes_obj.hide_get():
+        geomnodes_obj.hide_set(False, view_layer=bpy.context.view_layer)
+
+    depsgraph = bpy.context.evaluated_depsgraph_get()        
+    eval_geomnodes_data = geomnodes_obj.evaluated_get(depsgraph).data
+
+    # Create a new collection to hold the duplicated objects
+    collection = bpy.data.collections.new(name="Export Collection")
+    bpy.context.scene.collection.children.link(collection)
+
+    scene_scale_len = bpy.context.scene.unit_settings.scale_length
+
+    if geomnodes_obj.type == 'MESH':
+        new_obj = bpy.data.objects.new(name=utils.remove_special_chars(eval_geomnodes_data.name), object_data=eval_geomnodes_data.copy())
+        new_obj.matrix_world = geomnodes_obj.matrix_world.copy() * scene_scale_len
+        new_obj.instance_type = geomnodes_obj.instance_type
+        collection.objects.link(new_obj)
+
+    for instance in depsgraph.object_instances:
+        if instance.instance_object and instance.parent and instance.parent.original == geomnodes_obj:
+            if instance.object.type == 'MESH':
+                new_obj = bpy.data.objects.new(name=utils.remove_special_chars(instance.object.name), object_data=instance.object.data.copy())
+                new_obj.matrix_world = instance.matrix_world.copy() * scene_scale_len
+                new_obj.instance_type = instance.object.instance_type
+                collection.objects.link(new_obj)
+    
+    for obj in collection.objects:
+        obj.select_set(True)
+    
+    if mesh_to_triangles:
+        utils.add_remove_modifier("TRIANGULATE", True)
+    # Main Blender FBX API exporter
+    bpy.ops.export_scene.fbx( 
+        filepath=str(fbx_file_path),
+        check_existing=False,
+        filter_glob='*.fbx',
+        use_selection=True,
+        use_active_collection=False,
+        global_scale=1.0,
+        apply_unit_scale=True,
+        apply_scale_options='FBX_SCALE_UNITS',
+        use_space_transform=True,
+        bake_space_transform=False,
+        object_types={'ARMATURE', 'CAMERA', 'EMPTY', 'LIGHT', 'MESH', 'OTHER'},
+        use_mesh_modifiers=True,
+        use_mesh_modifiers_render=True,
+        mesh_smooth_type='OFF',
+        use_subsurf=False,
+        use_mesh_edges=False,
+        use_tspace=False,
+        use_custom_props=False,
+        add_leaf_bones=False,
+        primary_bone_axis='Y',
+        secondary_bone_axis='X',
+        use_armature_deform_only=False,
+        armature_nodetype='NULL',
+        bake_anim=False,
+        bake_anim_use_all_bones=False,
+        bake_anim_use_nla_strips=False,
+        bake_anim_use_all_actions=False,
+        bake_anim_force_startend_keying=False,
+        bake_anim_step=1.0,
+        bake_anim_simplify_factor=1.0,
+        path_mode='AUTO',
+        embed_textures=True,
+        batch_mode='OFF',
+        use_batch_own_dir=False,
+        use_metadata=True,
+        axis_forward='-Z',
+        axis_up='Y')
+    
+    if mesh_to_triangles:
+        utils.add_remove_modifier("TRIANGULATE", False)
+    
+    mat_helper.replace_stored_paths(stored_image_source_paths)
+
+    # Delete the duplicated objects and the collection
+    for obj in collection.objects:
+        bpy.data.objects.remove(obj, do_unlink=True)
+    bpy.data.collections.remove(collection)
+    

+ 155 - 1
Gems/O3DE/GeomNodes/External/Scripts/materials/mat_helper.py

@@ -1,4 +1,7 @@
 import bpy
+import shutil
+from pathlib import Path
+import constants
 
 def get_material_node_attributes(node, target_properties: dict, use_o3de_mat_names=False):
     attribute_dictionary = {}
@@ -18,4 +21,155 @@ def get_dict_safe_value(value):
     if type(value) == bpy.types.bpy_prop_array:
         return list(value)
     else:
-        return value
+        return value
+    
+def get_selected_materials(selected):
+    """!
+    This function will check the selected mesh and find material are connected
+    then build a material list for selected.
+    @param selected This is your current selected mesh(s)
+    """
+    materials = []
+    # Find Connected materials attached to mesh
+    for obj in selected:
+        try:
+            materials = get_all_materials(obj, materials)
+        except AttributeError:
+            pass
+    return materials
+
+def get_all_materials(obj, materials):
+    """!
+    This function will loop through all assigned materails slots on a mesh and the attach textures.
+    then build a material list for selected.
+    @param selected This is your current selected mesh(s)
+    """
+    for mat in obj.material_slots:
+        try:
+            if mat.name not in materials:
+                materials.append(mat.name)
+        except AttributeError:
+            pass
+    return materials
+
+def loop_through_selected_materials(texture_file_path, stored_image_source_paths):
+    """!
+    This function will loop the the selected materials and copy the files to the o3de project folder.
+    @param texture_file_path This is the current o3de projects texture file path selected for texture export.
+    @param stored_image_source_paths contains a copy of all store image source paths
+    """
+    if not Path(texture_file_path).exists():
+        Path(texture_file_path).mkdir(exist_ok=True)
+    # retrive the list of seleted mesh materials
+    selected_materials = get_selected_materials(bpy.context.selected_objects)
+    # Loop through Materials
+    for mat in selected_materials:
+        if mat:
+            # Get the material
+            material = bpy.data.materials[mat]
+            # Loop through material node tree and get all the texture iamges
+            for img in material.node_tree.nodes:
+                if img.type == 'TEX_IMAGE':
+                    # First make sure the image is not packed inside blender
+                    if img.image.packed_file:
+                        if Path(img.image.name).suffix in constants.IMAGE_EXT:
+                            bpy.data.images[img.image.name].filepath = str(Path(texture_file_path).joinpath(img.image.name))
+                        else:
+                            ext = '.png'
+                            build_string = f'{img.image.name}{ext}'
+                            bpy.data.images[img.image.name].filepath = str(Path(texture_file_path).joinpath(build_string))
+                        
+                        bpy.data.images[img.image.name].save()
+                    full_path = Path(bpy.path.abspath(img.image.filepath, library=img.image.library))
+                    base_name = Path(full_path).name
+                    if not full_path.exists(): # if embedded path doesnt exist, check current folder
+                        full_path = Path(bpy.data.filepath).parent.joinpath(base_name)
+                    o3de_texture_path = Path(texture_file_path).joinpath(base_name)
+                    # Add to stored_image_source_paths to replace later
+                    if not check_file_paths_duplicate(full_path, o3de_texture_path): # We check first if its not already copied over.
+                        stored_image_source_paths[img.image.name] = full_path
+                        # Copy the image to Destination
+                        try:
+                            bpy.data.images[img.image.name].filepath = str(o3de_texture_path)
+                            if full_path.exists():
+                                copy_texture_file(full_path, o3de_texture_path)
+                            else:
+                                bpy.data.images[img.image.name].save() 
+                                # Save image to location
+                        except (FileNotFoundError, RuntimeError):
+                            pass
+                    img.image.reload()
+
+def copy_texture_file(source_path, destination_path):
+    """!
+    This function will copy project texture files to a O3DE textures folder
+    @param source_path This is the texture source path
+    @param destination_path This is the O3DE destination path
+    """
+    destination_size = -1
+    source_size = source_path.stat().st_size
+    if destination_path.exists():
+        destination_size = destination_path.stat().st_size
+    if source_size != destination_size:
+        shutil.copyfile(source_path, destination_path)
+
+def clone_repath_images(texture_file_path, stored_image_source_paths):
+    """!
+    This function will copy project texture files to a
+    O3DE textures folder and repath the Blender materials
+    then repath them to thier orginal.
+    @param texture_file_path This is our o3de projects texture file export path
+    @param stored_image_source_paths contains a copy of all store image source paths
+    """
+    
+    dir_path = Path(texture_file_path)
+    texture_file_path = Path(dir_path.parent).joinpath('textures')
+    if not Path(texture_file_path).exists():
+        Path(texture_file_path).mkdir(exist_ok=True)
+    loop_through_selected_materials(texture_file_path, stored_image_source_paths)
+
+def check_file_paths_duplicate(source_path, destination_path):
+    """!
+    This function check to see if Source Path and Dest Path are the same
+    @param source_path This is the non o3de source texture path of the texture
+    @param destination_path This is the destination o3de project texture path
+    """
+    try:
+        check_file = Path(source_path)
+        if check_file.samefile(destination_path):
+            return True
+        else:
+            return False
+    except FileNotFoundError:
+        pass
+
+def replace_stored_paths(stored_image_source_paths):
+    """!
+    This Function will replace all the repathed image paths to thier origninal source paths
+    @param stored_image_source_paths contains a copy of all store image source paths
+    """
+    for image in bpy.data.images:
+        try:
+            # Find image and replace source path
+            bpy.data.images[image.name].filepath = str(stored_image_source_paths[image.name])
+        except KeyError:
+            pass
+        image.reload()
+    stored_image_source_paths = {}
+
+
+def duplicate_selected(selected_objects, rename):
+    """!
+    This function will duplicate selected objects with a new name string
+    @param selected_objects This is the current selected object
+    @param rename this is the duplicate object name
+    @param return duplicated object
+    """
+    # Duplicate the mesh and add add the UDP name extension
+    duplicate_object = bpy.data.objects.new(f'{selected_objects.name}{bpy.types.Scene.udp_type}', bpy.data.objects[selected_objects.name].data)
+    # Add copy to current collection in scene
+    bpy.context.collection.objects.link(duplicate_object)
+    # Rename duplicated object
+    duplicate_object.name = rename
+    duplicate_object.data.name = rename
+    return duplicate_object

+ 6 - 0
Gems/O3DE/GeomNodes/External/Scripts/messages.py

@@ -8,6 +8,8 @@ from lib_loader import MessageReader, MessageWriter
 from params import get_geometry_nodes_objs
 from mesh_data_builder import build_mesh_data
 from utils import get_geomnodes_obj
+from exporter import fbx_exporter
+
 import logging as _logging
 _LOGGER = _logging.getLogger('GeomNodes.External.Scripts.messages')
 
@@ -94,6 +96,10 @@ def poll_for_messages():
                     map_id = msg_dict['MapId']
                     from lib_loader import GNLibs
                     GNLibs.ClearSHM(map_id)
+                elif 'Export' in msg_dict:
+                    fbx_exporter.fbx_file_exporter(msg_dict['Object'], msg_dict['FBXPath'], True)
+                    #TODO: do some error checks. Inform the gem if there's an error.
+                    MessageWriter().from_buffer(bytes(json.dumps({'Export' : True, 'Error' : "" }), "UTF-8"))
                 elif 'Alive' in msg_dict:
                     return PollReturn.HEARTBEAT
         return PollReturn.MESSAGE # reset idle_time as we got a message from the server

+ 34 - 4
Gems/O3DE/GeomNodes/External/Scripts/utils.py

@@ -1,14 +1,44 @@
 import bpy
 import numpy as np
+from pathlib import Path
+import re
 
 def get_geomnodes_obj():
     for obj in bpy.data.objects:
-            for modifier in obj.modifiers:
-                if modifier.type == 'NODES':
-                    return obj
+        for modifier in obj.modifiers:
+            if modifier.type == 'NODES':
+                return obj
 
 def get_prop_collection(prop_collection, attr, multipler, data_type):
     array = np.empty(len(prop_collection) * multipler, dtype=data_type)  
     prop_collection.foreach_get(attr, array)
 
-    return array
+    return array
+
+def add_remove_modifier(modifier_name, add):
+    """!
+    This function will add or remove a modifier to selected
+    @param modifier_name is the name of the modifier you wish to add or remove
+    @param add if Bool True will add the modifier, if False will remove 
+    """
+    context = bpy.context
+
+    for selected_obj in context.selected_objects:
+        if selected_obj is not []:
+            if selected_obj.type == "MESH":
+                if add:
+                    # Set the mesh active
+                    bpy.context.view_layer.objects.active = selected_obj
+                    # Add Modifier
+                    bpy.ops.object.modifier_add(type=modifier_name)
+                    if modifier_name == "TRIANGULATE":
+                        bpy.context.object.modifiers["Triangulate"].keep_custom_normals = True
+                else:
+                    # Set the mesh active
+                    bpy.context.view_layer.objects.active = selected_obj
+                    # Remove Modifier
+                    bpy.ops.object.modifier_remove(modifier=modifier_name)
+    
+
+def remove_special_chars(string: str) -> str:
+    return re.sub(r'[^\w\s]+', '_', string)

+ 9 - 0
Gems/O3DE/GeomNodes/Registry/geomnodes.setreg

@@ -0,0 +1,9 @@
+{
+    "O3DE" : {
+        "Preferences" : {
+            "Prefabs" : {
+                "CreateDefaults" : false
+            }
+        }
+    } 
+}