Dear Community,
we got a few follow-up questions, and I thought I make them public too. This is mostly about how to support material drag events, e.g., dragging a texture into the viewport, so that it can be put on an object as a material.
Cheers,
Ferdinand
Explanation
Code
"""Provides an example for implementing a custom control (GeUserArea) that can receive drag events
and generate drag events itself.
This is here demonstrated at the common case of a bitmap GUI, into which image file paths and texture
assets can be dragged, and which itself generates image file path drag events. The example can simply
be run from the script manager.
This version also implements dragging the textures as materials on objects in the viewport. This is
done by creating a Redshift material with the texture as input and generating a drag event for that
material.
"""
__author__ = "Ferdinand Hoppe"
__copyright__ = "Copyright 2025 Maxon Computer GmbH"
import os
import re
import c4d
import maxon
import mxutils
# 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+\-.]*:\/\/\/")
class BitmapCard(c4d.gui.GeUserArea):
"""Implements a control that can receive bitmap path drag events and generates drag events itself.
"""
def __init__(self) -> None:
"""Constructs a new BitmapCard object.
"""
self._bitmap: c4d.bitmaps.BaseBitmap | None = None
self._path: str | 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.
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.
"""
# 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:
return True # Invalid asset but we are not in an error state.
# 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):
return True # Invalid asset but we are not in an error state.
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 [".png", ".jpg", ".jpeg", ".tif"])):
return True # Invalid file type but we are not in an error state.
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.
"""
# We could be here more elegant and only generate drag events when the user is clicking a
# specific area of the user area, or meeting some other conditions. I am just blindly
# checking for the left mouse button being pressed and the user area being valid.
if (msg.GetInt32(c4d.BFM_INPUT_DEVICE) == c4d.BFM_INPUT_MOUSE and
msg.GetInt32(c4d.BFM_INPUT_CHANNEL) == c4d.BFM_INPUT_MOUSELEFT and
self._path):
# When the user is holding the ctrl key, we generate a drag event for a simple bitmap
# file. This can be dragged into anything that accepts bitmap file drag events. Dragging
# such event onto an object in the viewport will create a new standard material with the
# bitmap as texture.
if msg.GetInt32(c4d.BFM_INPUT_QUALIFIER, 0) & c4d.QUALIFIER_CTRL:
return self.HandleMouseDrag(msg, c4d.DRAGTYPE_FILENAME_IMAGE, self._path, 0)
# When the user is not holding the ctrl key, we create a material for the bitmap and
# generate a drag event for the material.
# Poll for the shift key being pressed.
elif c4d.threading.GeIsMainThread():
# We should be more graceful here and check if a material for that texture already
# exists, but I am just blindly creating a new one. And we are creating a Redshift
# material, we could of course also check for the active renderer and be more
# sophisticated here.
# Create a new material and get its Redshift node graph.
material: c4d.BaseMaterial = mxutils.CheckType(c4d.BaseMaterial(c4d.Mmaterial))
graph: maxon.NodesGraphModelRef = maxon.GraphDescription.GetGraph(
material, maxon.NodeSpaceIdentifiers.RedshiftMaterial)
# Create a simple graph using that texture, and insert the material into the
# active document.
maxon.GraphDescription.ApplyDescription(graph,
{
"$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: c4d.documents.BaseDocument = c4d.documents.GetActiveDocument()
doc.InsertMaterial(material)
# Create a drag event for our custom material for the bitmap.
return self.HandleMouseDrag(msg, c4d.DRAGTYPE_ATOMARRAY, [material], 0)
return False
class BtmapStackDialog(c4d.gui.GeDialog):
"""Implements a a simple dialog that stacks multiple BitmapCard controls.
"""
def __init__(self):
"""Constructs a new ExampleDialog object.
"""
self._cards: list[BitmapCard] = [BitmapCard() for _ in range(4)]
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)
for i, card in enumerate(self._cards):
self.AddUserArea(1000 + i, c4d.BFH_LEFT | c4d.BFV_TOP)
self.AttachUserArea(card, 1000 + i)
return True
# Define a global variable of the dialog to keep it alive. ASYNC dialogs should not be opened in
# production code from a script manager script, enabled by this global variable hack, as this
# results in a dangling dialog. Implement a command plugin when you need async dialogs in production.
# Opening modal dialogs from the script manager is fine.
dlg: BtmapStackDialog = BtmapStackDialog()
if __name__ == "__main__":
dlg.Open(dlgtype=c4d.DLG_TYPE_ASYNC, defaultw=150, default=250)