SOLVED Attach Export Dialog

Hello everyone. I want to attach in my CommandData plugin interface an export menu like fbx (e.g. using AttachSubDialog). So that it displays up-to-date information with access to export presets. If this is possible then how?
If I change any settings in my plugin window, will those changes be applied to the global export settings? If so, how can this be avoided?
Thanks!

Hi @JohnSmith the easiest solution would be to integrate directly the command from the export menu rather than re-creating everything under the hoods. Of course doing so will means it behave exactly as if the user pressed the FBX entry within the export menu but I guess it's what you are looking for, isn't it? This way you don't have to deal with preset since it's the exporter dialog that already does it but the drawback of that is that if you change a settings, then it will change the global export settings. This is how to do it (including the same logic as we have internally to build the export menu with correct Id and order)

import c4d
from typing import Tuple

def HierarchyIterator(obj):
    """A Python Generator yielding all objects recursively of a Cinema 4D BaseList2D tree.
    
    Used to iterate over all registered Plugins (being BaseList2D) to find SceneSaver plugins.
    """
    while obj:
        yield obj
        for opChild in HierarchyIterator(obj.GetDown()):
            yield opChild
        obj = obj.GetNext()

def IsHidden(plugin: c4d.plugins.BasePlugin) -> bool:
    """Return if a plugin is hidden. """
    return plugin.GetInfo() & c4d.PLUGINFLAG_HIDE != 0

def CreateExportFilterBaseContainer(idOffset: int) -> Tuple[c4d.BaseContainer, int]:
    """Create a BaseContainer with items order as the Export Menu of Cinema 4D.
    
        Args:
            idOffset (int): The offset apply to the If of the inserted entries in the BaseContainer.
            
        Return:
            The feeded BaseContainer
            The last entryId inserted into the previously returned BaseContainer.
    """
    bc: c4d.BaseContainer = c4d.BaseContainer(1)
    entryId: int = 2 + idOffset
    
    # Iterates all plugins and all SceneSaver to the BaseContainer
    for plugin in HierarchyIterator(c4d.plugins.GetFirstPlugin()):
        if plugin.GetType() == c4d.PLUGINTYPE_SCENESAVER and not IsHidden(plugin):
            bc.InsData(entryId, plugin.GetName())
            entryId += 1
    
    # Perform a string sorting over the name of the entries in the BaseContainer
    bc.Sort()
    return bc, entryId

class ExportMenuDialog(c4d.gui.GeDialog):

    def __init__(self):
        # Defines an offset where the export menu ID starts
        self.ID_MENU_EXPORT_START = 100000
        # Build ExportFilter BaseContainer and assign the end range of possible ID for the export menu
        self.exportFilterBc, self.ID_MENU_EXPORT_END = CreateExportFilterBaseContainer(self.ID_MENU_EXPORT_START)

    def CreateLayout(self):
        self.SetTitle("Export Menu Dialog")
        
        # Flushes all the already existing top bar menu to create our one. 
        # Content will be on the left.
        # Creates a Sub menu begin to inserts new menu entry
        self.MenuFlushAll()
        self.MenuSubBegin("Export")
        
        # Iterate over the BaseContainer to insert entries in the menu
        # The ##entryId will be passed to the Command method when the user click on the entry
        for entryId, entryName in self.exportFilterBc:
            print(entryId, entryName)
            self.MenuAddString(entryId, entryName)
            
        # Finalizes the Sub Menu and the top bar menu
        self.MenuSubEnd()
        self.MenuFinished()

        return True
    
    def Command(self, msgId, msgData):
        # When the user click on an entry in the menu it's id is passed as msgId
        # We compare if the received msgId is between the start and the end of our menu ID
        # Then we compute the correct Id (same as used in the Cinema 4D File menu)
        # Finally execute CallCommand with the Id 6000 == the exporter (hardcoded)
        # and the exporterId as the subId 
        if self.ID_MENU_EXPORT_START <= msgId <= self.ID_MENU_EXPORT_END:
            realExporterId = msgId - self.ID_MENU_EXPORT_START
            c4d.CallCommand(60000, realExporterId)
        
        return True

def main():
    global myDialog
    myDialog = ExportMenuDialog()
    myDialog.Open(c4d.DLG_TYPE_ASYNC)


if __name__ == '__main__':
    main()

The other way around would means recreate all windows manually since it is not possible to call "AttachSubDialog" in this context so this is a lot of work then you would also need to map this windows to the SceneSaver setting, then manually call the SceneSaver for each one (see example in export_alembic_r14). Then you also need to deal with Preset loading, while possible this is again a bit cobblestone, so I would really not advise go this way as it represents a lot of work, that you will need to maintain and therefor go with the first option.

Cheers,
Maxime.

@m_adam Thank you very much for your reply. Unfortunately, this solution does not suit me and I will have to recreate the menu manually. Export is carried out automatically in certain situations and the export settings menu should be in a separate "Settings" window.

Hi @JohnSmith sorry for the long wait, I was busy with other projects.
I guess we did not understand each other with the term "menu" as I understood it as a regular Cinema 4D Menu, but you mean more that your tool act as a menu.

So in order to integrate the setting within your windows you should create a c4d.gui.DescriptionCustomGui and link it to the Exporter Plugin. In cinema 4D what we call a "Description" correspond to the UI of a BaseList2D. A BaseList2D is an object that can be inserted within a tree of item and this is what is used as base for object, tags, material but even plugins like importer and exporter. In a case of exporters there is always only 1 instance of this BaseList2D meaning there is only 1 settings, so any modification done to it is global.

With that said here an example of a Dialog with a menu to select an exporter from the menu. The exporter settings are then displayed within the dialog and then if you press the export button it export the current document to the selected format.

import c4d


def CreateExportsList(idStart: int) -> list[tuple[int, str, int]]:
    """Create a list with items ordered as the Export Menu of Cinema 4D.

        Args:
            idStart : The offset applied to the Index of the inserted entries.

        Return:
            The list fed with tuples constructed like so:
                int: The entry index in the menu.
                str: Name of the exporter plugin.
                int: Plugin Id of the exporter plugin.
    """
    entryId: int = idStart
    outputList = []

    # Iterate over all exporters plugins
    for plugin in c4d.plugins.FilterPluginList(c4d.PLUGINTYPE_SCENESAVER, True):

        # If the plugin is Hidden in this case skip it
        if plugin.GetInfo() & c4d.PLUGINFLAG_HIDE != 0:
            continue

        outputList.append((entryId, plugin.GetName(), plugin.GetID()))
        entryId += 1

    return outputList


class ExportMenuDialog(c4d.gui.GeDialog):

    def __init__(self):
        # Defines an offset where the export menu ID starts
        self.ID_MENU_EXPORT_START = 100000
        # Build ExportFilter list
        self.outputList = CreateExportsList(self.ID_MENU_EXPORT_START)

        # Assign the end range of possible ID for the export menu and other ID for the export Button and the Description CustomGUI
        self.ID_MENU_EXPORT_END = self.ID_MENU_EXPORT_START + self.outputList[-1][0]
        self.ID_DESCRIPTION = self.ID_MENU_EXPORT_END + 1
        self.ID_EXPORT_BUTTON = self.ID_DESCRIPTION + 1

        # Define the default active exporter
        self._activeExporterMenuId = self.outputList[0][0]

        # Will store the description custom gui, responsible to display a BaseList2D interface.
        self.descriptionGUI = None

    @property
    def activeExporterMenuId(self) -> int:
        """Return the selected item in the menu."""
        return self._activeExporterMenuId

    @activeExporterMenuId.setter
    def activeExporterMenuId(self, value: int):
        """ Update the menu when the selected item from the menu is changed.

        Arg:
            value: The id of the item to be selected in the menu.
        """
        if value == self._activeExporterMenuId:
            return

        self._activeExporterMenuId = value
        self.UpdateMenuCheckedStatus()

    @property
    def activeExporterPluginId(self) -> int:
        """Return the plugin identifier of the selected item."""
        plugId: int = 0
        for entryId, entryName, pluginId in self.outputList:
            if entryId == self.activeExporterMenuId:
                plugId = pluginId
                break

        if plugId == 0:
            raise ValueError(f"Unable to find plugin for {self.activeExporterMenuId}.")

        return plugId

    @property
    def exporterBaseList2D(self) -> c4d.BaseList2D:
        """
        Retrieve the plugin instance of the active exporter.
        """
        plug = c4d.plugins.FindPlugin(self.activeExporterPluginId, c4d.PLUGINTYPE_SCENESAVER)
        if plug is None:
            raise RuntimeError("Failed to retrieve the exporter plugin.")

        data = dict()
        if not plug.Message(c4d.MSG_RETRIEVEPRIVATEDATA, data):
            raise RuntimeError("Failed to retrieve private data.")

        # BaseList2D object stored in "imexporter" key hold the settings
        exportBaseList2D = data.get("imexporter", None)
        if exportBaseList2D is None:
            raise RuntimeError("Failed to retrieve BaseList 2D of the exporter.")

        return exportBaseList2D

    def UpdateMenuCheckedStatus(self):
        """ Update the menu to have only one item selected in the menu."""

        # Iterate over the list of exporter to edit their check status
        for entryId, entryName, pluginId in self.outputList:
            enableState = entryId == self.activeExporterMenuId
            self.MenuInitString(entryId, True, enableState)
            if enableState:
                self.SetTitle(f"Export Dialog: {entryName}")

    def CreateLayout(self) -> bool:
        # Flushes all the already existing top bar menu to create our one.
        # Content will be on the left.
        # Creates a Sub menu begin to insert new menu entry
        self.MenuFlushAll()
        self.MenuSubBegin("Export")

        # Iterate over the list of exporters to insert entries in the menu
        # The ##entryId will be passed to the Command method when the user click on the entry
        for entryId, entryName, pluginId in self.outputList:
            self.MenuAddString(entryId, entryName)

        self.UpdateMenuCheckedStatus()

        # Finalizes the Sub Menu and the top bar menu
        self.MenuSubEnd()
        self.MenuFinished()

        # Create a CustomGui that will display a Description
        bc = c4d.BaseContainer()
        bc[c4d.DESCRIPTION_ALLOWFOLDING] = True
        bc[c4d.DESCRIPTION_OBJECTSNOTINDOC] = True
        bc[c4d.DESCRIPTION_NOUNDO] = False
        bc[c4d.DESCRIPTION_SHOWTITLE] = False
        bc[c4d.DESCRIPTION_OBJECTSNOTINDOC] = True
        bc[c4d.DESCRIPTION_NO_TAKE_OVERRIDES] = False
        self.descriptionGUI = self.AddCustomGui(self.ID_DESCRIPTION,
                                                c4d.CUSTOMGUI_DESCRIPTION,
                                                "",
                                                c4d.BFH_SCALEFIT | c4d.BFV_SCALEFIT,
                                                300,
                                                300,
                                                bc)

        # Define the BaseList2D to display
        self.descriptionGUI.SetObject(self.exporterBaseList2D)

        # Add a button to export based on the settings
        self.AddButton(self.ID_EXPORT_BUTTON, c4d.BFH_CENTER, name="Export")

        return True

    def Command(self, msgId: int, msgData: c4d.BaseContainer) -> bool:
        # When the user click on an entry in the menu it's id is passed as msgId
        # We compare if the received msgId is between the start and the end of our menu ID
        # Then we compute the correct Id (same as used in the Cinema 4D File menu)
        # Finally execute CallCommand with the Id 6000 == the exporter (hardcoded)
        # and the exporterId as the subId
        if self.ID_MENU_EXPORT_START <= msgId <= self.ID_MENU_EXPORT_END:
            self.activeExporterMenuId = msgId
            self.descriptionGUI.SetObject(self.exporterBaseList2D)

        elif msgId == self.ID_EXPORT_BUTTON:
            # Retrieves a path to save the exported file
            filePath = c4d.storage.LoadDialog(title="Save File for the exporter", flags=c4d.FILESELECT_SAVE)
            if not filePath:
                return True

            if not c4d.documents.SaveDocument(c4d.documents.GetActiveDocument(),
                                              filePath,
                                              c4d.SAVEDOCUMENTFLAGS_DONTADDTORECENTLIST,
                                              self.activeExporterPluginId):
                c4d.gui.MessageDialog("Failed to Export the document.")

        return True


def main():
    global myDialog
    myDialog = ExportMenuDialog()
    myDialog.Open(c4d.DLG_TYPE_ASYNC)


if __name__ == '__main__':
    main()

Cheers,
Maxime.

@m_adam Thank you very much! This is what I was looking for.