Hello @dunhou,
Thank you for reaching out to us. What you want to do is possible but not as trivial as you might think it is. Find at the end of my posting a script which implements the fundamental principles for what you want to do. I would also like to point out Light Lister by @m_adam which does what you want to do. As a warning: @m_adam wrote this before he joined Maxon, so not everything you find there is reference material. But it will certainly be worth a look.
Cheers,
Ferdinand
Technical Explanation
The classic API of Cinema 4D has only a very coarse event system. To track changes in a scene, only the broad message c4d.EVMSG_CHANGE
is being broadcasted to inform the user that something has changed, but not what. Cinema 4D has no (core message) mechanism which would inform specifically about an object, or a light being added. EVMSG_CHANGE
is a core message which can be received in the CoreMessage
methods of a MessageData
plugin, a GeDialog
, or a GeUserArea
.
Identifying Objects
To solve this, you must track the scene graph and its changes yourself. In a simplified manner this means that you traverse everything, in your case the objects, in the scene and create a look-up table of things which you have encountered. So, when we have this scene,
Cube
| +- Light_0
| +- Cone
Sphere
+- Light_1
you would end up with the list [Light_0, Light_1]
and if on the next EVMSG_CHANGE
the scene would be as follows,
Cube
| +- Light_0
| +- Cone
Sphere
+- Light_1
+- Light_2
then you could deduce that the change event was for Light_2
having been added. There is however one problem with this: You cannot simply store references to the objects themselves as Cinema 4D reallocates nodes in background. So, when you have established this list
myLookup: list[c4d.BaseObject] = [Light_0, Light_1]
at some point, then an EVMSG_CHANGE
is being broadcasted at a later point, you then traverse the scene graph, and reach the object which is named Light_0 again, and do this:
if Light_0 in myLookup:
DoSomething()
DoSomething()
might not trigger, although you have put Light_0
into myLookup
before. The reason is then that Cinema 4D has reallocated Light_0
in the background at some point. The reference in myLookup
to now a dangling reference to an object which does not exist anymore and was located at an entirely different place in memory than the currently existing Light_0
. The data of both objects can still be entirely the same, it is only that in C++ you cannot rely on BaseList2D
pointers for storing access to parts of a scene, and by extension storing references to them in Python. You can test for something like this having happened in the Python API with C4DAtom.IsAlive()
, such dangling scene element, an object, material, shader, document, etc., will then return False.
This problem can be solved in multiple ways, but one intended by Cinema 4D is markers. Cinema 4D allows metadata to be attached to scene elements to signify a context. In Python this mechanism is primarily accessible with C4DAtom.FindUniqueID()
which returns the stored data of such markers. The marker, which is here of particular interest to you, is the one with the identifier c4d.MAXON_CREATOR_ID
. When Cinema 4D creates a new object from scratch, it will store there a hash which uniquely identifies the object; this also works over saving and reloading a scene. The important bit is that this hash will not change when Cinema 4D reallocates a node. So, this statement in pseudo code holds true:
uuid_0: bytes = bytes(Light_0.FindUniqueID(c4d.MAXON_CREATOR_ID))
uuid_1: bytes = bytes(Light_0_Reallocated.FindUniqueID(c4d.MAXON_CREATOR_ID))
uuid_0 == uuid_1
In practice it does not work exactly like that, because when there is already a Light_0_Reallocated
, Light_0
is already a dangling reference by then, a 'dead' node in Python terms. So, you must store the UUID of Light_0
beforehand.
Identifying Parameter Changes
When you have jumped though all these hoops, tracking parameter changes is relatively simple. All atoms, e.g., light-objects, have the method C4DAtom.GetDirty
which tracks the change checksums of that atom. With the identifier c4d.DIRTYFLAGS_DATA
you can retrieve the change checksums for the data container of an object, i.e., its parameter values. You only have then to store that information in your tracking data.
Cheers,
Ferdinand
Code:
"""Provides a bare-bones example for tracking changes in the scene graph of a document.
Run this example as a Script Manager script. It will track you adding standard or Redshift lights to a scene and if you change their parameters.
"""
import c4d
import typing
doc: c4d.documents.BaseDocument # The active document
op: typing.Optional[c4d.BaseObject] # The selected object, can be None.
class LightTrackerDialog (c4d.gui.GeDialog):
"""Tracks the existence of light objects and their parameter changes in a scene.
This is a very simplistic implementation, as it for example assumes always to be fed by data
from the same active document. You should add more rigorous checks.
"""
# The light types we are going to track.
Orslight: int = 1036751
TRACKED_TYPES: tuple[int] = (c4d.Olight, Orslight)
# Some minor symbols for managing the internal lookup table.
NEW_LIGHT: int = 0 # A new light has been found
UPDATED_LIGHT: int = 1 # An existing light has been updated.
def __init__(self) -> None:
"""Initializes a LightTrackerDialog and its internal table tracking a scene state.
"""
self._lightTable: dict[bytes: dict] = {}
def CreateLayout(self) -> bool:
"""Adds GUI gadgets to the dialog.
Not needed in this case, as we do not want to use GeDialog as a dialog, but for its ability
to receive core messages.
"""
self.SetTitle("Light Tracker Dialog")
self.GroupBorderSpace(5, 5, 5, 5)
self.AddStaticText(id=1000, flags=c4d.BFH_SCALEFIT, inith=25, name='This GUI has no items.')
return True
def CoreMessage(self, mid: int, data: c4d.BaseContainer) -> bool:
"""Receives core messages broadcasted by Cinema 4D.
"""
# Some change has been made to a document.
if mid == c4d.EVMSG_CHANGE:
# Set off the _trackLights() method with the currently active document.
self._trackLights(doc=c4d.documents.GetActiveDocument())
return 0
def _trackLights(self, doc: c4d.documents.BaseDocument) -> None:
"""Handles both finding new light objects, and tracking parameter changes on already tracked
objects.
"""
# The meat of this example, _getLights will traverse the scene graph and compare the results
# to the data stored by this dialog, _lightTable. The returned list of byte objects are hashes
# for the light objects which are new, i.e., light objects which have an UUID which has
# been never encountered before.
newLights: list[bytes] = self._getLights(doc)
# This tracks if the data container checksum of any of the tracked lights is higher than its
# cached value.
modifiedLights: list[bytes] = self._checkLights()
# Print out the changes.
if len(newLights) < 1:
print ("No lights have been added.")
else:
print ("The following lights have been found:")
for key in newLights:
light: c4d.BaseObject = self._lightTable[key]["light"]
print(f"\tuuid: {key}, light: {light}")
if len(modifiedLights) < 1:
print ("No lights have been modified.")
else:
print ("The following lights have been modified:")
for key in modifiedLights:
light: c4d.BaseObject = self._lightTable[key]["light"]
print(f"\tuuid: {key}, light: {light}")
def _setLightData(self, key: bytes, light: c4d.GeListNode) -> int:
"""Sets the data of an entry in the internal light object tracking table.
Returns:
NEW_LIGHT if #key was never encountered before, UPDATED_LIGHT otherwise.
"""
if key in self._lightTable.keys():
self._lightTable[key]["light"] = light
return LightTrackerDialog.UPDATED_LIGHT
else:
self._lightTable[key] = {"dirty": light.GetDirty(c4d.DIRTYFLAGS_DATA), "light": light}
return LightTrackerDialog.NEW_LIGHT
def _getLights(self, doc: c4d.documents.BaseDocument) -> list[bytes]:
"""Traverses the object tree of #doc to find all light objects in it and update the internal
tracking table.
Returns:
A list of light object UUIDs which have never been encountered before.
"""
def iterate(node: c4d.BaseObject) -> c4d.BaseObject:
"""Walks an object tree depth first and yields all nodes that are of a type which is
contained in TRACKED_TYPES.
"""
while isinstance(node, c4d.BaseObject):
if node.GetType() in LightTrackerDialog.TRACKED_TYPES:
yield node
for child in iterate(node.GetDown()):
yield child
node = node.GetNext()
# The list to store the newly encountered UUIDs in.
result: list[bytes] = []
# For all tracked light type objects in the passed document.
for light in iterate(doc.GetFirstObject()):
# Get the MAXON_CREATOR_ID marker to uniquely identify the object.
uuid: memoryview = light.FindUniqueID(c4d.MAXON_CREATOR_ID)
if not isinstance(uuid, memoryview):
print (f"Skipping illegal non-marked light object: {light}")
continue
# FindUniqueID() returns a memoryview, we cast this to bytes as this more convenient
# for us here.
uuid: bytes = bytes(uuid)
# Write the light object #light under #uuid into the internal tracking table. The
# method _setLightData() will return NEW_LIGHT when #uuid has never been encountered
# before. We know then that this must be a new object.
if self._setLightData(uuid, light) is LightTrackerDialog.NEW_LIGHT:
result.append(uuid)
# Return the newly encountered object UUIDs.
return result
def _checkLights(self) -> list[bytes]:
"""Traverses the internal light table to search for light objects where the parameters have
changed.
Returns:
A list of light object UUIDs for which the data container has changed.
"""
# The result and a new internal light object table. #newTable is technically not necessary,
# as we could fashion #_lightTable and this method in such way that we could modify it in
# place, but that would be harder to read and this is an example :)
result: list[bytes] = []
newTable: dict = {}
# For each key and data dictionary in the internal table.
for key, data in self._lightTable.items():
# Get the old dirty count and the light object reference.
oldDirty: int = data.get("dirty", None)
light: c4d.BaseObject = data.get("light", None)
# The data container was malformed, should not happen.
if not isinstance(light, c4d.BaseObject) or not isinstance(oldDirty, int):
raise RuntimeError(f"Found malformed light tracker data.")
# There is a dangling object reference in the table, we step over it and by that remove
# it.
if not light.IsAlive():
print (f"Found severed object pointer.")
continue
# Get the current DIRTYFLAGS_DATA checksum of the object and compare it to the cached
# value. When it is different, we know the object parameters must have changed.
newDirty: int = light.GetDirty(c4d.DIRTYFLAGS_DATA)
if newDirty != oldDirty:
result.append(key)
# Write the light object and its new dirty checksum into the new table.
newTable[key] = {"dirty": newDirty, "light": light}
# Replace the old table with the new one, and by that drop dangling objects and update all
# dirty checksums to their current state.
self._lightTable = newTable
# Return the UUIDs of the light objects where parameter changes occurred.
return result
if __name__ == '__main__':
# This global variable is a hack to keep async dialogs alive in a Script Manager script, please do
# not use it in a production environment. It is only being used here to demonstrate the issue.
# You must implement a plugin, e.g., a CommandData plugin, in a production environment to safely
# handle async dialogs.
global dlg
dlg = LightTrackerDialog()
dlg.Open(c4d.DLG_TYPE_ASYNC)