Browse Source

Implement OBJ export functionality in editor

- Global utility method for writing Drawables to an OBJ in Drawable
- AllContentOctreeQuery added
- Minor fixes to Octree query angelscript bindings
JSandusky 10 years ago
parent
commit
4a16d34367

+ 23 - 2
Source/Urho3D/AngelScript/GraphicsAPI.cpp

@@ -1837,7 +1837,7 @@ static CScriptArray* OctreeGetDrawablesFrustum(const Frustum& frustum, unsigned
     PODVector<Drawable*> result;
     FrustumOctreeQuery query(result, frustum, drawableFlags, viewMask);
     ptr->GetDrawables(query);
-    return VectorToHandleArray<Drawable>(result, "Array<Node@>");
+    return VectorToHandleArray<Drawable>(result, "Array<Drawable@>");
 }
 
 static CScriptArray* OctreeGetDrawablesSphere(const Sphere& sphere, unsigned char drawableFlags, unsigned viewMask, Octree* ptr)
@@ -1845,7 +1845,15 @@ static CScriptArray* OctreeGetDrawablesSphere(const Sphere& sphere, unsigned cha
     PODVector<Drawable*> result;
     SphereOctreeQuery query(result, sphere, drawableFlags, viewMask);
     ptr->GetDrawables(query);
-    return VectorToHandleArray<Drawable>(result, "Array<Node@>");
+    return VectorToHandleArray<Drawable>(result, "Array<Drawable@>");
+}
+
+static CScriptArray* OctreeGetAllDrawables(unsigned char drawableFlags, unsigned viewMask, Octree* ptr)
+{
+    PODVector<Drawable*> result;
+    AllContentOctreeQuery query(result, drawableFlags, viewMask);
+    ptr->GetDrawables(query);
+    return VectorToHandleArray<Drawable>(result, "Array<Drawable@>");
 }
 
 static Octree* SceneGetOctree(Scene* ptr)
@@ -1888,12 +1896,24 @@ static void RegisterOctree(asIScriptEngine* engine)
     engine->RegisterObjectMethod("Octree", "Array<Drawable@>@ GetDrawables(const BoundingBox&in, uint8 drawableFlags = DRAWABLE_ANY, uint viewMask = DEFAULT_VIEWMASK)", asFUNCTION(OctreeGetDrawablesBox), asCALL_CDECL_OBJLAST);
     engine->RegisterObjectMethod("Octree", "Array<Drawable@>@ GetDrawables(const Frustum&in, uint8 drawableFlags = DRAWABLE_ANY, uint viewMask = DEFAULT_VIEWMASK)", asFUNCTION(OctreeGetDrawablesFrustum), asCALL_CDECL_OBJLAST);
     engine->RegisterObjectMethod("Octree", "Array<Drawable@>@ GetDrawables(const Sphere&in, uint8 drawableFlags = DRAWABLE_ANY, uint viewMask = DEFAULT_VIEWMASK)", asFUNCTION(OctreeGetDrawablesSphere), asCALL_CDECL_OBJLAST);
+    engine->RegisterObjectMethod("Octree", "Array<Drawable@>@ GetAllDrawables(uint8 drawableFlags = DRAWABLE_ANY, uint viewMask = DEFAULT_VIEWMASK)", asFUNCTION(OctreeGetAllDrawables), asCALL_CDECL_OBJLAST);
     engine->RegisterObjectMethod("Octree", "const BoundingBox& get_worldBoundingBox() const", asMETHODPR(Octree, GetWorldBoundingBox, () const, const BoundingBox&), asCALL_THISCALL);
     engine->RegisterObjectMethod("Octree", "uint get_numLevels() const", asMETHOD(Octree, GetNumLevels), asCALL_THISCALL);
     engine->RegisterObjectMethod("Scene", "Octree@+ get_octree() const", asFUNCTION(SceneGetOctree), asCALL_CDECL_OBJLAST);
     engine->RegisterGlobalFunction("Octree@+ get_octree()", asFUNCTION(GetOctree), asCALL_CDECL);
 }
 
+bool ObjWriteDrawablesToOBJ(CScriptArray* drawablesArray, File* file, bool writeLightmapUV)
+{
+    PODVector<Drawable*> drawables = ArrayToPODVector<Drawable*>(drawablesArray);
+    return WriteDrawablesToOBJ(drawables, file, writeLightmapUV);
+}
+
+static void RegisterOBJExport(asIScriptEngine* engine)
+{
+    engine->RegisterGlobalFunction("bool WriteDrawablesToOBJ(Array<Drawable@>@, File@+, bool = false)", asFUNCTION(ObjWriteDrawablesToOBJ), asCALL_CDECL);
+}
+
 void RegisterGraphicsAPI(asIScriptEngine* engine)
 {
     RegisterSkeleton(engine);
@@ -1922,6 +1942,7 @@ void RegisterGraphicsAPI(asIScriptEngine* engine)
     RegisterOctree(engine);
     RegisterGraphics(engine);
     RegisterRenderer(engine);
+    RegisterOBJExport(engine);
 }
 
 }

+ 8 - 0
Source/Urho3D/Container/Str.cpp

@@ -1146,6 +1146,14 @@ String& String::AppendWithFormatArgs(const char* formatString, va_list args)
                 break;
             }
 
+        // Unsigned long
+        case 'l':
+            {
+                unsigned long arg = va_arg(args, unsigned long);
+                Append(String(arg));
+                break;
+            }
+
         // Real
         case 'f':
             {

+ 173 - 0
Source/Urho3D/Graphics/Drawable.cpp

@@ -26,9 +26,12 @@
 #include "../Core/Context.h"
 #include "../Graphics/Camera.h"
 #include "../Graphics/DebugRenderer.h"
+#include "../IO/File.h"
+#include "../Graphics/Geometry.h"
 #include "../Graphics/Material.h"
 #include "../Graphics/Octree.h"
 #include "../Graphics/Renderer.h"
+#include "../Graphics/VertexBuffer.h"
 #include "../Graphics/Zone.h"
 #include "../IO/Log.h"
 #include "../Scene/Scene.h"
@@ -423,4 +426,174 @@ void Drawable::RemoveFromOctree()
     }
 }
 
+bool WriteDrawablesToOBJ(PODVector<Drawable*> drawables, File* outputFile, bool writeLightmapUV)
+{
+    // Must track indices independently to deal with potential mismatching of drawables vertex attributes (ie. one with UV, another without, then another with)
+    // Using long because 65,535 isn't enough as OBJ indices do not reset the count with each new object
+    unsigned long currentPositionIndex = 1;
+    unsigned long currentUVIndex = 1;
+    unsigned long currentNormalIndex = 1;
+    bool anythingWritten = false;
+
+    // Write the common "I came from X" comment
+    outputFile->WriteLine("# OBJ file exported from Urho3D");
+
+    for (unsigned i = 0; i < drawables.Size(); ++i)
+    {
+        Drawable* drawable = drawables[i];
+
+        // Only write enabled drawables
+        if (!drawable->IsEnabledEffective())
+            continue;
+
+        Node* node = drawable->GetNode();
+        Matrix3x4 transMat = drawable->GetNode()->GetWorldTransform();
+
+        const Vector<SourceBatch>& batches = drawable->GetBatches();
+        for (unsigned geoIndex = 0; geoIndex < batches.Size(); ++geoIndex)
+        {
+            Geometry* geo = drawable->GetLodGeometry(geoIndex, 0);
+            if (geo == 0)
+                continue;
+            if (geo->GetPrimitiveType() != TRIANGLE_LIST)
+            {
+                LOGERRORF("%s (%u) %s (%u) Geometry %u contains an unsupported geometry type %u", node->GetName().Length() > 0 ? node->GetName().CString() : "Node", node->GetID(), drawable->GetTypeName().CString(), drawable->GetID(), geoIndex, (unsigned)geo->GetPrimitiveType());
+                continue;
+            }
+
+            // If we've reached here than we're going to actually write something to the OBJ file
+            anythingWritten = true;
+
+            const unsigned char* vertexData = 0x0;
+            const unsigned char* indexData = 0x0;
+            unsigned int elementSize = 0, indexSize = 0, elementMask = 0;
+            geo->GetRawData(vertexData, elementSize, indexData, indexSize, elementMask);
+
+            const bool hasNormals = (elementMask & MASK_NORMAL) != 0;
+            const bool hasUV = (elementMask & MASK_TEXCOORD1) != 0;
+            const bool hasLMUV = (elementMask & MASK_TEXCOORD2) != 0;
+
+            if (elementSize > 0 && indexSize > 0)
+            {
+                const unsigned vertexStart = geo->GetVertexStart();
+                const unsigned vertexCount = geo->GetVertexCount();
+                const unsigned indexStart = geo->GetIndexStart();
+                const unsigned indexCount = geo->GetIndexCount();
+
+                // Name NodeID DrawableType DrawableID GeometryIndex ("Geo" is included for clarity as StaticModel_32_2 could easily be misinterpreted or even quickly misread as 322)
+                // Generated object name example: Node_5_StaticModel_32_Geo_0 ... or ... Bob_5_StaticModel_32_Geo_0
+                outputFile->WriteLine(String("o ").AppendWithFormat("%s_%u_%s_%u_Geo_%u", node->GetName().Length() > 0 ? node->GetName().CString() : "Node", node->GetID(), drawable->GetTypeName().CString(), drawable->GetID(), geoIndex));
+
+                // Write vertex position
+                const unsigned positionOffset = VertexBuffer::GetElementOffset(elementMask, ELEMENT_POSITION);
+                for (unsigned j = 0; j < vertexCount; ++j)
+                {
+                    Vector3 vertexPosition = *((const Vector3*)(&vertexData[(vertexStart + j) * elementSize + positionOffset]));
+                    vertexPosition = transMat * vertexPosition;
+                    outputFile->WriteLine("v " + String(vertexPosition));
+                }
+
+                if (hasNormals)
+                {
+                    const unsigned normalOffset = VertexBuffer::GetElementOffset(elementMask, ELEMENT_NORMAL);
+                    for (unsigned j = 0; j < vertexCount; ++j)
+                    {
+                        Vector3 vertexNormal = *((const Vector3*)(&vertexData[(vertexStart + j) * elementSize + positionOffset]));
+                        vertexNormal = transMat * vertexNormal;
+                        vertexNormal.Normalize();
+                        outputFile->WriteLine("vn " + String(vertexNormal));
+                    }
+                }
+
+                // Write TEXCOORD1 or TEXCOORD2 if it was chosen
+                if (hasUV || (hasLMUV && writeLightmapUV))
+                {
+                    // if writing Lightmap UV is chosen, only use it if TEXCOORD2 exists, otherwise use TEXCOORD1
+                    const unsigned texCoordOffset = (writeLightmapUV && hasLMUV) ? VertexBuffer::GetElementOffset(elementMask, ELEMENT_TEXCOORD2) : VertexBuffer::GetElementOffset(elementMask, ELEMENT_TEXCOORD1);
+                    for (unsigned j = 0; j < vertexCount; ++j)
+                    {
+                        Vector2 uvCoords = *((const Vector2*)(&vertexData[(vertexStart + j) * elementSize + texCoordOffset]));
+                        outputFile->WriteLine("vt " + String(uvCoords));
+                    }
+                }
+
+                // Build obj indices for a triangle list
+                if (geo->GetPrimitiveType() == TRIANGLE_LIST)
+                {
+                    // If we don't have UV but have normals then must write a double-slash to indicate the absence of UV coords, otherwise use a single slash
+                    const String slashCharacter = hasNormals ? "//" : "/";
+
+                    for (unsigned indexIdx = indexStart; indexIdx < indexCount * indexSize; indexIdx += indexSize * 3)
+                    {
+                        // Deal with 16 or 32 bit indices, converting to long
+                        unsigned long longIndices[3];
+                        if (indexSize == 2)
+                        {
+                            //16 bit indices
+                            unsigned short indices[3];
+                            memcpy(indices, indexData + indexIdx, indexSize * 3);
+                            longIndices[0] = indices[0];
+                            longIndices[1] = indices[1];
+                            longIndices[2] = indices[2];
+                        }
+                        else
+                        {
+                            //32 bit indices
+                            unsigned indices[3];
+                            memcpy(indices, indexData + indexIdx, indexSize * 3);
+                            longIndices[0] = indices[0];
+                            longIndices[1] = indices[1];
+                            longIndices[2] = indices[2];
+                        }
+
+                        String output = "f ";
+                        if (hasNormals)
+                        {
+                            output.AppendWithFormat("%l/%l/%l %l/%l/%l %l/%l/%l",
+                                currentPositionIndex + longIndices[0],
+                                currentPositionIndex + longIndices[0],
+                                currentPositionIndex + longIndices[0],
+                                currentUVIndex + longIndices[1],
+                                currentUVIndex + longIndices[1],
+                                currentUVIndex + longIndices[1],
+                                currentNormalIndex + longIndices[2],
+                                currentNormalIndex + longIndices[2],
+                                currentNormalIndex + longIndices[2]);
+                        }
+                        else if (hasNormals || hasUV)
+                        {
+                            const unsigned secondTraitIndex = hasNormals ? currentNormalIndex : currentUVIndex;
+                            output.AppendWithFormat("%l%s%l %l%s%l %l%s%l",
+                                currentPositionIndex + longIndices[0],
+                                slashCharacter.CString(),
+                                secondTraitIndex + longIndices[0],
+                                currentPositionIndex + longIndices[1],
+                                slashCharacter.CString(),
+                                secondTraitIndex + longIndices[1],
+                                currentPositionIndex + longIndices[2],
+                                slashCharacter.CString(),
+                                secondTraitIndex + longIndices[2]);
+                        }
+                        else
+                        {
+                            output.AppendWithFormat("%l %l %l",
+                                currentPositionIndex + longIndices[0],
+                                currentPositionIndex + longIndices[1],
+                                currentPositionIndex + longIndices[2]);
+                        }
+                        outputFile->WriteLine(output);
+                    }
+                }
+
+                // Increment our positions based on what vertex attributes we have
+                currentPositionIndex += vertexCount;
+                currentNormalIndex += hasNormals ? vertexCount : 0;
+                // is it possible to have TEXCOORD2 but not have TEXCOORD1, assume anything
+                currentUVIndex += (hasUV || hasLMUV) ? vertexCount : 0;
+            }
+        }
+    }
+    return anythingWritten;
+}
+
 }

+ 3 - 0
Source/Urho3D/Graphics/Drawable.h

@@ -42,6 +42,7 @@ static const int MAX_VERTEX_LIGHTS = 4;
 static const float ANIMATION_LOD_BASESCALE = 2500.0f;
 
 class Camera;
+class File;
 class Geometry;
 class Light;
 class Material;
@@ -383,4 +384,6 @@ inline bool CompareDrawables(Drawable* lhs, Drawable* rhs)
     return lhs->GetSortValue() < rhs->GetSortValue();
 }
 
+URHO3D_API bool WriteDrawablesToOBJ(PODVector<Drawable*> drawables, File* outputFile, bool writeLightmapUV = false);
+
 }

+ 17 - 0
Source/Urho3D/Graphics/OctreeQuery.cpp

@@ -117,4 +117,21 @@ void FrustumOctreeQuery::TestDrawables(Drawable** start, Drawable** end, bool in
     }
 }
 
+
+Intersection AllContentOctreeQuery::TestOctant(const BoundingBox& box, bool inside)
+{
+    return INSIDE;
+}
+
+void AllContentOctreeQuery::TestDrawables(Drawable** start, Drawable** end, bool inside)
+{
+    while (start != end)
+    {
+        Drawable* drawable = *start++;
+
+        if ((drawable->GetDrawableFlags() & drawableFlags_) && (drawable->GetViewMask() & viewMask_))
+            result_.Push(drawable);
+    }
+}
+
 }

+ 15 - 0
Source/Urho3D/Graphics/OctreeQuery.h

@@ -256,4 +256,19 @@ private:
     RayOctreeQuery& operator =(const RayOctreeQuery& rhs);
 };
 
+class URHO3D_API AllContentOctreeQuery : public OctreeQuery
+{
+public:
+    /// Construct.
+    AllContentOctreeQuery(PODVector<Drawable*>& result, unsigned char drawableFlags, unsigned viewMask) :
+        OctreeQuery(result, drawableFlags, viewMask)
+    {
+    }
+
+    /// Intersection test for an octant.
+    virtual Intersection TestOctant(const BoundingBox& box, bool inside);
+    /// Intersection test for drawables.
+    virtual void TestDrawables(Drawable** start, Drawable** end, bool inside);
+};
+
 }

+ 17 - 0
Source/Urho3D/LuaScript/pkgs/Graphics/Octree.pkg

@@ -12,6 +12,7 @@ class Octree : public Component
     tolua_outside const PODVector<OctreeQueryResult>& OctreeGetDrawablesBoundingBox @ GetDrawables(const BoundingBox& box, unsigned char drawableFlags = DRAWABLE_ANY, unsigned viewMask = DEFAULT_VIEWMASK) const;
     tolua_outside const PODVector<OctreeQueryResult>& OctreeGetDrawablesFrustum @ GetDrawables(const Frustum& frustum, unsigned char drawableFlags = DRAWABLE_ANY, unsigned viewMask = DEFAULT_VIEWMASK) const;
     tolua_outside const PODVector<OctreeQueryResult>& OctreeGetDrawablesSphere @ GetDrawables(const Sphere& sphere, unsigned char drawableFlags = DRAWABLE_ANY, unsigned viewMask = DEFAULT_VIEWMASK) const;
+    tolua_outside const PODVector<OctreeQueryResult>& OctreeGetAllDrawables @ GetAllDrawables(unsigned char drawableFlags = DRAWABLE_ANY, unsigned viewMask = DEFAULT_VIEWMASK) const;
 
     // void Raycast(RayOctreeQuery& query) const;
     tolua_outside const PODVector<RayQueryResult>& OctreeRaycast @ Raycast(const Ray& ray, RayQueryLevel level, float maxDistance, unsigned char drawableFlags, unsigned viewMask = DEFAULT_VIEWMASK) const;
@@ -91,6 +92,22 @@ static const PODVector<OctreeQueryResult>& OctreeGetDrawablesSphere(const Octree
     return result;
 }
 
+static const PODVector<OctreeQueryResult>& OctreeGetAllDrawables(const Octree* octree, unsigned char drawableFlags = DRAWABLE_ANY, unsigned viewMask = DEFAULT_VIEWMASK)
+{
+    PODVector<Drawable*> drawableResult;
+    AllContentOctreeQuery query(drawableResult, drawableFlags, viewMask);
+    octree->GetDrawables(query);
+
+    static PODVector<OctreeQueryResult> result;
+    result.Resize(drawableResult.Size());
+    for (unsigned i = 0; i < drawableResult.Size(); ++i)
+    {
+        result[i].drawable_ = drawableResult[i];
+        result[i].node_ = drawableResult[i]->GetNode();
+    }
+    return result;
+}
+
 static RayQueryResult OctreeRaycastSingle(const Octree* octree, const Ray& ray, RayQueryLevel level, float maxDistance, unsigned char drawableFlags, unsigned viewMask = DEFAULT_VIEWMASK)
 {
     PODVector<RayQueryResult> result;

+ 1 - 0
bin/Data/Scripts/Editor.as

@@ -14,6 +14,7 @@
 #include "Scripts/Editor/EditorSecondaryToolbar.as"
 #include "Scripts/Editor/EditorUI.as"
 #include "Scripts/Editor/EditorImport.as"
+#include "Scripts/Editor/EditorExport.as"
 #include "Scripts/Editor/EditorResourceBrowser.as"
 #include "Scripts/Editor/EditorSpawn.as"
 #include "Scripts/Editor/EditorSoundType.as"

+ 94 - 0
bin/Data/Scripts/Editor/EditorExport.as

@@ -0,0 +1,94 @@
+
+void ExportSceneToOBJ(String fileName)
+{
+    if (fileName.empty)
+    {
+        MessageBox("File name for OBJ export unspecified");
+        return;
+    }
+    // append obj extension if missing
+    if (GetExtension(fileName).empty)
+        fileName += ".obj";
+        
+    Octree@ octree = scene.GetComponent("Octree");
+    if (octree is null)
+    {
+        MessageBox("Octree missing from scene");
+        return;
+    }
+        
+    Array<Drawable@> drawables = octree.GetAllDrawables();
+    if (drawables.length == 0)
+    {
+        MessageBox("No drawables to export in the scene");
+        return;
+    }
+        
+    File@ file = File(fileName, FILE_WRITE);
+    if (WriteDrawablesToOBJ(drawables, file))
+    {
+        MessageBox("OBJ file written to " + fileName, "Success");
+        file.Close();
+    }
+    else
+    {
+        // Cleanup our file so we don't mislead anyone
+        MessageBox("Unable to write OBJ file");
+        file.Close();
+        fileSystem.Delete(fileName);
+    }
+}
+
+void ExportSelectedToOBJ(String fileName)
+{
+    if (fileName.empty)
+    {
+        MessageBox("File name for OBJ export unspecified");
+        return;
+    }
+    if (GetExtension(fileName).empty)
+        fileName += ".obj";
+    
+    Array<Drawable@> drawables;
+    
+    // Add any explicitly selected drawables
+    for (uint i = 0; i < selectedComponents.length; ++i)
+    {
+        Drawable@ drawable = cast<Drawable>(selectedComponents[i]);
+        if (drawable !is null)
+            drawables.Push(drawable);
+    }
+    
+    // Add drawables of any selected nodes
+    for (uint i = 0; i < selectedNodes.length; ++i)
+    {
+        Array<Component@>@ components = selectedNodes[i].GetComponents();
+        for (uint comp = 0; comp < components.length; ++comp)
+        {
+            Drawable@ drawable = cast<Drawable>(components[comp]);
+            if (drawable !is null && drawables.FindByRef(drawable) < 0)
+                drawables.Push(drawable);
+        }
+    }
+    
+    if (drawables.length > 0)
+    {
+        File@ file = File(fileName, FILE_WRITE);
+        if (WriteDrawablesToOBJ(drawables, file))
+        {
+            MessageBox("OBJ file written to " + fileName, "Success");
+            file.Close();
+        }
+        else
+        {
+            MessageBox("Unable to write OBJ file");
+            // Cleanup our file so we don't mislead anyone
+            file.Close();
+            fileSystem.Delete(fileName);
+        }
+    }
+    else
+    {
+        MessageBox("No selected drawables to export to OBJ");
+    }
+}

+ 28 - 0
bin/Data/Scripts/Editor/EditorUI.as

@@ -37,6 +37,7 @@ Array<String> uiAllFilters = {"*.*"};
 Array<String> uiScriptFilters = {"*.as", "*.*"};
 Array<String> uiParticleFilters = {"*.xml"};
 Array<String> uiRenderPathFilters = {"*.xml"};
+Array<String> uiExportPathFilters = {"*.obj"};
 uint uiSceneFilter = 0;
 uint uiElementFilter = 0;
 uint uiNodeFilter = 0;
@@ -44,10 +45,12 @@ uint uiImportFilter = 0;
 uint uiScriptFilter = 0;
 uint uiParticleFilter = 0;
 uint uiRenderPathFilter = 0;
+uint uiExportFilter = 0;
 String uiScenePath = fileSystem.programDir + "Data/Scenes";
 String uiElementPath = fileSystem.programDir + "Data/UI";
 String uiNodePath = fileSystem.programDir + "Data/Objects";
 String uiImportPath;
+String uiExportPath;
 String uiScriptPath = fileSystem.programDir + "Data/Scripts";
 String uiParticlePath = fileSystem.programDir + "Data/Particles";
 String uiRenderPathPath = fileSystem.programDir + "CoreData/RenderPaths";
@@ -313,6 +316,9 @@ void CreateMenuBar()
         popup.AddChild(CreateMenuItem("Import model...", @PickFile));
         popup.AddChild(CreateMenuItem("Import scene...", @PickFile));
         CreateChildDivider(popup);
+        popup.AddChild(CreateMenuItem("Export scene to OBJ...", @PickFile));
+        popup.AddChild(CreateMenuItem("Export selected to OBJ...", @PickFile));
+        CreateChildDivider(popup);
         popup.AddChild(CreateMenuItem("Run script...", @PickFile));
         popup.AddChild(CreateMenuItem("Set resource path...", @PickFile));
         CreateChildDivider(popup);
@@ -613,6 +619,16 @@ bool PickFile()
         CreateFileSelector("Import scene", "Import", "Cancel", uiImportPath, uiAllFilters, uiImportFilter);
         SubscribeToEvent(uiFileSelector, "FileSelected", "HandleImportScene");
     }
+    else if (action == "Export scene to OBJ...")
+    {
+        CreateFileSelector("Export scene to OBJ", "Save", "Cancel", uiExportPath, uiExportPathFilters, uiExportFilter);
+        SubscribeToEvent(uiFileSelector, "FileSelected", "HandleExportSceneOBJ");
+    }
+    else if (action == "Export selected to OBJ...")
+    {
+        CreateFileSelector("Export selected to OBJ", "Save", "Cancel", uiExportPath, uiExportPathFilters, uiExportFilter);
+        SubscribeToEvent(uiFileSelector, "FileSelected", "HandleExportSelectedOBJ");
+    }
     else if (action == "Run script...")
     {
         CreateFileSelector("Run script", "Run", "Cancel", uiScriptPath, uiScriptFilters, uiScriptFilter);
@@ -1119,6 +1135,18 @@ void HandleImportScene(StringHash eventType, VariantMap& eventData)
     ImportScene(ExtractFileName(eventData));
 }
 
+void HandleExportSceneOBJ(StringHash eventType, VariantMap& eventData)
+{
+    CloseFileSelector(uiExportFilter, uiExportPath);
+    ExportSceneToOBJ(ExtractFileName(eventData));
+}
+
+void HandleExportSelectedOBJ(StringHash eventType, VariantMap& eventData)
+{
+    CloseFileSelector(uiExportFilter, uiExportPath);
+    ExportSelectedToOBJ(ExtractFileName(eventData));
+}
+
 
 void ExecuteScript(const String&in fileName)
 {