Dear community,
We recently got a non-public support request about handling drag and drop events for a custom image control in a dialog. And while we have shown this, drag event handling, multiple times before, I thought it does not hurt to answer this publicly, as this very case - an image control - is quite common, and it does not hurt having a bit more verbose public example for it.
edit: I have updated this topic with new code and a new video, to also handle outgoing asset drag events, and talk a bit about the pros and cons of different approaches, and what you could do when not implementing a code example as I did here, and therefore have a bit more leverage room when it comes to complexity.
In general, doing all this is possible. It is just that not everything is a ready-made solution for you, where you just call a function. At some point you have to "swim" here yourself, as we cannot write your code for you, but I hope the example helps shedding some light on the probably uncessarily complicated drag handling in Cinema 4D.
Cheers,
Ferdinand
Result
Code
"""Provides an example for implementing a custom control that can generate and receive drag events.
The example implements a dialog which holds multiple "cards" (BitmapCard) that display an image
and can receive drag events. The user can drag images from the file system into these cards (file
paths) or drag texture assets from the Asset Browser into these cards. The cards can also generate
drag events themselves, e.g., when the user drags from a card, it will generate a drag event.
Showcased (selectable via the combo box in the menu of the dialog) are three drag types:
1. File paths: Dragging a card will generate an image file path drag event, which for example could
be dragged into a file path field of a shader, onto an object in the viewport, or onto other
BitmapCard controls. The advantage of this approach is that we do not have to create
materials or assets, but can just use the file path directly.
2. Materials: Dragging a card will generate an atom drag event, here populated with a Redshift
material that has the texture of the card as an input. This material can then be dragged onto
anything that accepts materials, like objects in the viewport. The disadvantage of this
approach is that we have to create a material, and that we have to exactly define how the
material is constructed.
3. Assets: Dragging a card will generate an asset drag event, here populated with a texture asset
that has the texture of the card as an input. This asset can then be dragged onto anything that
texture accepts assets, such as an object in the viewport. The disadvantage of this
approach is that we have to create an asset.
- The example also showcases a variation oof this approach, where we exploit a bit how the
texture asset drag handling works, to avoid having to create an asset.
Overview:
- BitmapCard: A user area control that can receive drag events and generate drag events itself. Here
you will find almost all the relevant code for drag and drop handling
- BitmapStackDialog: A dialog that holds multiple BitmapCard controls and allows the user to select
the drag type via a combo box. This dialog is the main entry point of the example
but does not contain much relevant code itself.
"""
__author__ = "Ferdinand Hoppe"
__copyright__ = "Copyright 2025 Maxon Computer GmbH"
import os
import re
import c4d
import maxon
import mxutils
# The file types that are supported to be dragged into a BitmapCard control.
DRAG_IN_FILE_TYPES: list[str] = [".png", ".jpg", ".jpeg", ".tif"]
# A regex to check of a string (which is meant to be a path/url) already as a scheme (like file:///
# or http:///). The protocol we are mostly checking for is "asset:///", but we keep this regex generic
# to allow for other schemes as well.
RE_URL_HAS_SCHEME = re.compile("^[a-zA-Z][a-zA-Z\d+\-.]*:\/\/\/")
# A non-public core message which need when we want to properly generate asset drag events.
COREMSG_SETCOMMANDEDITMODE: int = 300001000
class BitmapCard(c4d.gui.GeUserArea):
"""Implements a control that can receive bitmap path drag events and generates drag events itself.
"""
def __init__(self, host: "BitmapStackDialog") -> None:
"""Constructs a new BitmapCard object.
"""
# The host dialog that holds this card. This is used to access the drag type selected in the
# combo box of the dialog.
self._host: BitmapStackDialog = host
# The image file path and the bitmap that is displayed in this card.
self._path: str | None = ""
self._bitmap: c4d.bitmaps.BaseBitmap | None = None
def GetMinSize(self) -> tuple[int, int]:
"""Called by Cinema 4d to evaluate the minimum size of the user area.
"""
return 250, 100
def DrawMsg(self, x1, y1, x2, y2, msg_ref):
"""Called by Cinema 4D to let the user area draw itself.
"""
self.OffScreenOn()
self.SetClippingRegion(x1, y1, x2, y2)
# Draw the background and then the bitmap if it exists. In the real world, we would have to
# either adapt #GetMinSize to the size of the bitmap, or draw the bitmap in a way that it
# fits into the user area, e.g., by scaling it down. Here we just draw it at a fixed size.
self.DrawSetPen(c4d.gui.GetGuiWorldColor(c4d.COLOR_BGGADGET))
self.DrawRectangle(x1, y1, x2, y2)
if self._bitmap:
w, h = self._bitmap.GetSize()
self.DrawBitmap(self._bitmap, 5, 5, 240, 90, 0,
0, w, h, c4d.BMP_NORMALSCALED)
def Message(self, msg: c4d.BaseContainer, result: c4d.BaseContainer) -> int:
"""Called by Cinema 4D to handle messages sent to the user area.
Here we implement receiving drag events when the user drags something onto this user area.
"""
# A drag event is coming in.
if msg.GetId() == c4d.BFM_DRAGRECEIVE:
# Get out when the drag event has been discarded.
if msg.GetInt32(c4d.BFM_DRAG_LOST) or msg.GetInt32(c4d.BFM_DRAG_ESC):
return self.SetDragDestination(c4d.MOUSE_FORBIDDEN)
# Get out when this is not a drag type we support (we just support images). Note that we
# cannot make up drag types ourselves, so when we want to send/receive complex data, we
# must use the DRAGTYPE_ATOMARRAY type and pack our data into a BaseContainer attached
# to the nodes we drag/send.
data: dict = self.GetDragObject(msg)
dragType: int = data.get("type", 0)
if dragType not in [c4d.DRAGTYPE_FILENAME_IMAGE, maxon.DRAGTYPE_ASSET]:
return self.SetDragDestination(c4d.MOUSE_FORBIDDEN)
# Here we could optionally check the drag event hitting some target area.
# yPos: float = self.GetDragPosition(msg).get('y', 0.0)
# if not 0 < yPos < 50 or not self.CheckDropArea(msg, True, True):
# return self.SetDragDestination(c4d.MOUSE_FORBIDDEN)
# After this point, we are dealing with a valid drag event.
# The drag is still going on, we just set the mouse cursor to indicate that we are
# ready to receive the drag event.
if msg.GetInt32(c4d.BFM_DRAG_FINISHED) == 0:
return self.SetDragDestination(c4d.MOUSE_MOVE)
# The drag event is finished, we can now process the data.
else:
# Unpack a file being dragged directly.
path: str | None = None
if dragType == c4d.DRAGTYPE_FILENAME_IMAGE:
path = data.get("object", None)
# Unpack an asset being dragged.
elif dragType == maxon.DRAGTYPE_ASSET:
array: maxon.DragAndDropDataAssetArray = data.get(
"object", None)
descriptions: tuple[tuple[maxon.AssetDescription, maxon.Url, maxon.String]] = (
array.GetAssetDescriptions())
if not descriptions:
# Invalid asset but we are not in an error state.
return True
# Check that we are dealing with an image asset.
asset: maxon.AssetDescription = descriptions[0][0]
metadata: maxon.BaseContainer = asset.GetMetaData()
subType: maxon.Id = metadata.Get(
maxon.ASSETMETADATA.SubType, maxon.Id())
if (subType != maxon.ASSETMETADATA.SubType_ENUM_MediaImage):
# Invalid asset but we are not in an error state.
return True
path = str(maxon.AssetInterface.GetAssetUrl(asset, True))
if not isinstance(path, str):
return False # Critical failure.
if (dragType == c4d.DRAGTYPE_FILENAME_IMAGE and
(not os.path.exists(path) or
os.path.splitext(path)[1].lower() not in DRAG_IN_FILE_TYPES)):
# Invalid file type but we are not in an error state.
return True
self._path = path
self._bitmap = c4d.bitmaps.BaseBitmap()
if self._bitmap.InitWith(path)[0] != c4d.IMAGERESULT_OK:
return False # Critical failure.
self.Redraw() # Redraw the user area to show the new bitmap.
return True
# Process other messages, doing this is very important, as we otherwise break the
# message chain.
return c4d.gui.GeUserArea.Message(self, msg, result)
def InputEvent(self, msg: c4d.BaseContainer) -> bool:
"""Called by Cinema 4D when the user area receives input events.
Here we implement creating drag events when the user drags from this user area. The type of
drag event which is initiated is determined by the drag type selected in the combo box
of the dialog.
"""
# When this is not a left mouse button event on this user area, we just get out without
# consuming the event (by returning False).
if (msg.GetInt32(c4d.BFM_INPUT_DEVICE) != c4d.BFM_INPUT_MOUSE or
msg.GetInt32(c4d.BFM_INPUT_CHANNEL) != c4d.BFM_INPUT_MOUSELEFT):
return False
# Get the type of drag event that should be generated, and handle it.
dragType: int = self._host.GetInt32(self._host.ID_DRAG_TYPE)
return self.HandleDragEvent(msg, dragType)
# --- Custom drag handling methods -------------------------------------------------------------
# I have split up thing into three methods for readability, this all could also be done directly
# in the InputEvent method.
def HandleDragEvent(self, event: c4d.BaseContainer, dragType: int) -> bool:
"""Handles starting a drag event by generating the drag data and sending it to the system.
This is called when the user starts dragging from this user area.
"""
# This requires us to modify the document, so we must be on the main thread (technically
# not true when the type is DRAGTYPE_FILENAME_IMAGE, but that would be for you to optimize).
if not c4d.threading.GeIsMainThread():
raise False
# Generate our drag data, either a file path, a material, or an asset.
doc: c4d.documents.BaseDocument = c4d.documents.GetActiveDocument()
data: object = self.GenerateDragData(doc, dragType)
# Now we set off the drag event. When we are dragging assets, we have to sandwich the event
# in these core message calls, as otherwise the palette edit mode toggling will not work
# correctly.
if dragType == maxon.DRAGTYPE_ASSET:
bc: c4d.BaseContainer = c4d.BaseContainer(
COREMSG_SETCOMMANDEDITMODE)
bc.SetInt32(1, True)
c4d.SendCoreMessage(c4d.COREMSG_CINEMA, bc, 0)
# When #HandleMouseDrag returns #False, this means that the user has cancelled the drag
# event, one case could be that the user actually did not intend to drag, but just
# clicked on the user area.
#
# In this case we remove the drag data we generated, as it is not needed anymore. Note that
# this will NOT catch the case that the drag event is cancelled by the recipient, e.g., the
# user drags a material onto something that does not accept materials.
if not self.HandleMouseDrag(event, dragType, data, 0):
self.RemoveDragData(doc, data)
return True
# Other half of the sandwich, we toggle the palette edit mode back to normal.
if dragType == maxon.DRAGTYPE_ASSET:
bc: c4d.BaseContainer = c4d.BaseContainer(
COREMSG_SETCOMMANDEDITMODE)
bc.SetInt32(1, False)
c4d.SendCoreMessage(c4d.COREMSG_CINEMA, bc, 0)
return True
def GenerateDragData(self, doc: c4d.documents.BaseDocument, dragType
) -> str | c4d.BaseMaterial | maxon.DragAndDropDataAssetArray:
"""Generates the drag data for the given drag type.
Each tile just encapsulates a file path, but we realize dragging them as file paths,
materials, or assets. So, when the type is material or asset, the drag data must be
generated from the file path. Which is why we have this method here.
"""
if not c4d.threading.GeIsMainThread():
raise RuntimeError(
"GenerateDragData must be called from the main thread.")
# The user has selected "File" as the drag type, we just return the file path.
if dragType == c4d.DRAGTYPE_FILENAME_IMAGE:
return self._path
# The user has selected "Material" as the drag type, we create a Redshift material with the
# texture as input and return that material.
elif dragType == c4d.DRAGTYPE_ATOMARRAY:
material: c4d.BaseMaterial = mxutils.CheckType(
c4d.BaseMaterial(c4d.Mmaterial))
# Create a simple graph using that texture, and insert the material into the
# active document.
maxon.GraphDescription.ApplyDescription(
maxon.GraphDescription.GetGraph(
material, maxon.NodeSpaceIdentifiers.RedshiftMaterial),
{
"$type": "Output",
"Surface": {
"$type": "Standard Material",
"Base/Color": {
"$type": "Texture",
# Set the texture. When our source is a file we will just have a plain
# path, e.g. "C:/path/to/file.png" and we have to prefix with a scheme
# (file) to make it a valid URL. When we are dealing with an asset, the
# texture path will already be a valid URL in the "asset:///" scheme.
"Image/Filename/Path": (maxon.Url(f"{self._path}")
if RE_URL_HAS_SCHEME.match(self._path) else
maxon.Url(f"file:///{self._path}"))
}
}
})
doc.InsertMaterial(material)
return [material]
# The user has selected "Asset" as the drag type, we create a texture asset and return that.
elif dragType == maxon.DRAGTYPE_ASSET:
# So we have to cases here, either that we want truly want to generate an asset drag
# event or that we just want to piggy-back onto the texture asset drag and drop
# mechanism of Cinema 4D, which is what we do here.
# Code for truly generating an asset drag event, where we would store the asset in the
# scene repository.
generateAssets: bool = False
if generateAssets:
# Get the scene repository and create an asset storage structure for the asset.
repo: maxon.AssetRepository = doc.GetSceneRepository(True)
store: maxon.StoreAssetStruct = maxon.StoreAssetStruct(
maxon.Id(), repo, repo)
# Now save the texture asset to the repository.
url: maxon.Url = maxon.Url(self._path)
name: str = url.GetName()
asset, _ = maxon.AssetCreationInterface.SaveTextureAsset(
url, name, store, (), True)
# Create a drag array for that asset. It is important that we use the URL of the
# asset (maxon.AssetInterface.GetAssetUrl) and not #url or asset.GetUrl(), as both
# are physical file paths, and will then cause the drag and drop handling to use
# these physical files, including the popup asking for wether the file should be
# copied. We will exploit exactly this behavior in the second case.
dragArray: maxon.DragAndDropDataAssetArray = maxon.DragAndDropDataAssetArray()
dragArray.SetLookupRepository(repo)
dragArray.SetAssetDescriptions((
(asset, maxon.AssetInterface.GetAssetUrl(asset, True), maxon.String(name)),
))
return dragArray
# With this case we can piggy-back onto the existing drag and drop texture asset
# handling of Cinema 4D, without actually having to create an asset. THIS IS A
# WORKAROUND, we could decide at any moment to change how the drag and drop
# handling of assets works, possibly rendering this approach obsolete.
else:
# The slight disadvantage of this approach (besides it being a hack and us possibly
# removing it at some point) is that we have to pay the download cost of
# #self._host._dummyAssetId once. I.e., the user has to download that texture once
# either by using it in the Asset Browser or by running this code. Once the asset has
# been cached, this will not happen again.
#
# But since we search below in a 'whoever comes first' manner, what is the first
# texture asset could change, and this could then be an asset which has not been
# downloaded yet. So, in a more robust world we would pick a fixed asset below. But
# this comes then with the burden of maintenance, as we could remove one of the
# builtin texture assets at any time. So, the super advanced solution would be to
# ship the plugin with its own asset database, and mount and use that. Here we could
# use a hard-coded asset ID, and would have to have never pay download costs, as
# the repository would be local.
# Find the asset by its Id, which we have stored in the dialog.
repo: maxon.AssetRepositoryInterface = maxon.AssetInterface.GetUserPrefsRepository()
dummy: maxon.AssetDescription = repo.FindLatestAsset(
maxon.AssetTypes.File().GetId(), self._host._dummyAssetId, maxon.Id(),
maxon.ASSET_FIND_MODE.LATEST)
# Setup the drag array with the asset. Note that #asset must be a valid texture
# asset, so just passing maxon.AssetDescription() will not work. But the backend
# for drag and drop handling will actually ignore the asset except for type checking
# it, and use the URL we provided instead.
dragArray: maxon.DragAndDropDataAssetArray = maxon.DragAndDropDataAssetArray()
dragArray.SetLookupRepository(repo)
dragArray.SetAssetDescriptions((
(dummy, maxon.Url(self._path), maxon.String(os.path.basename(self._path))),
))
return dragArray
else:
raise ValueError(f"Unsupported drag type: {dragType}")
def RemoveDragData(self, doc: c4d.documents.BaseDocument,
data: str | list[c4d.BaseMaterial] | maxon.DragAndDropDataAssetArray) -> bool:
"""Removes generated content when the user cancels a drag event.
Sometimes the user starts a drag event, but then cancels it, e.g., by pressing the
escape key. In this case, we have to remove the generated content, e.g., the material or
asset that we created for the drag event.
"""
if not c4d.threading.GeIsMainThread():
return False
if isinstance(data, list):
# Remove the dragged materials from the document. Since we are always generating a new
# material, we can just remove them. That is in general probably not the best design,
# and a real world application should avoid duplicating materials.
for item in data:
if isinstance(item, c4d.BaseList2D):
item.Remove()
elif isinstance(data, str):
# Here we could technically remove a file.
pass
elif isinstance(data, maxon.DragAndDropDataAssetArray):
# We could remove the asset, but other than for the material, there is no guarantee
# that we are the only user of it, as the Asset API has a duplicate preventing
# mechanism. So, the #SaveTextureAsset call above could have returned an already
# existing asset. Since we could operate with a document bound repository, we could
# search the whole document for asset references and only when we find none, remove
# the asset.
#
# We use here the little hack that we actually do not create an asset, to just to piggy-
# back onto the material drag and drop mechanism of assets, so we can just ignore
# this case.
pass
return True
class BitmapStackDialog(c4d.gui.GeDialog):
"""Implements a a simple dialog that stacks multiple BitmapCard controls.
"""
ID_DRAG_TYPE: int = 1002
def __init__(self):
"""Constructs a new ExampleDialog object.
"""
# The user area controls that will generate and receive drag events.
self._cards: list[BitmapCard] = [
BitmapCard(host=self) for _ in range(4)]
# The id for the dummy asset that used to generate 'fake' texture asset drag events. We
# search for it here once, so that we do not have to do it in the __init__ of each
# BitmapCard, or even worse, in the HandleDragEvent of each BitmapCard.
# We could technically also store here the AssetDescription instead of the Id, but that
# reference can go stale, so we just store the Id and then grab with it the asset when we
# need it. Which is much faster than searching asset broadly as we do here. In the end, we
# could probably also do all this in the drag handling of the BitmapCard, but a little bit
# of optimization does not hurt.
self._dummyAssetId: maxon.Id | None = None
if not maxon.AssetDataBasesInterface.WaitForDatabaseLoading():
raise RuntimeError("Could not initialize the asset databases.")
def findTexture(asset: maxon.AssetDescription) -> bool:
"""Finds the first texture asset in the user preferences repository.
"""
# When this is not a texture asset, we just continue searching.
meta: maxon.AssetMetaData = asset.GetMetaData()
if (meta.Get(maxon.ASSETMETADATA.SubType, maxon.Id()) !=
maxon.ASSETMETADATA.SubType_ENUM_MediaImage):
return True
# When it is a texture asset, we store it as the dummy asset and stop searching.
self._dummyAssetId = asset.GetId()
return False
# Search for our dummy asset in the user preferences repository.
repo: maxon.AssetRepositoryInterface = maxon.AssetInterface.GetUserPrefsRepository()
repo.FindAssets(maxon.AssetTypes.File().GetId(), maxon.Id(), maxon.Id(),
maxon.ASSET_FIND_MODE.LATEST, findTexture)
def CreateLayout(self):
"""Called by Cinema 4D to populate the dialog with controls.
"""
self.SetTitle("Drag and Drop Example")
self.GroupSpace(5, 5)
self.GroupBorderSpace(5, 5, 5, 5)
# Build the combo box in the menu bar of the dialog.
self.GroupBeginInMenuLine()
self.GroupBegin(1000, c4d.BFH_LEFT | c4d.BFV_TOP, cols=2)
self.GroupBorderSpace(5, 5, 5, 5)
self.GroupSpace(5, 5)
self.AddStaticText(1001, c4d.BFH_LEFT |
c4d.BFV_CENTER, name="Drag Events as:")
self.AddComboBox(1002, c4d.BFH_RIGHT | c4d.BFV_TOP)
self.GroupEnd()
self.GroupEnd()
# Add the BitmapCard controls to the dialog.
for i, card in enumerate(self._cards):
self.AddUserArea(2000 + i, c4d.BFH_LEFT | c4d.BFV_TOP)
self.AttachUserArea(card, 2000 + i)
# Add items to the combo box to select the drag type.
self.AddChild(self.ID_DRAG_TYPE, c4d.DRAGTYPE_FILENAME_IMAGE, "Files")
self.AddChild(self.ID_DRAG_TYPE, c4d.DRAGTYPE_ATOMARRAY, "Materials")
self.AddChild(self.ID_DRAG_TYPE, maxon.DRAGTYPE_ASSET, "Assets")
self.SetInt32(self.ID_DRAG_TYPE, c4d.DRAGTYPE_FILENAME_IMAGE)
return True
# Define a global variable of the dialog to keep it alive in a Script Manager script. ASYNC dialogs
# should not be opened in production code from a Script Manager script, as this results in a
# dangling dialog. Implement a command plugin when you need async dialogs in production.
dlg: BitmapStackDialog = BitmapStackDialog()
if __name__ == "__main__":
dlg.Open(dlgtype=c4d.DLG_TYPE_ASYNC, defaultw=150, defaulth=250)