Skip to content

Commit 3fa9bc9

Browse files
committed
Cinema 4D Python SDK Release 2026.2
1 parent c4e263f commit 3fa9bc9

54 files changed

Lines changed: 2775 additions & 466 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
**/__pycache__/
2+
*.pyc
3+
*.pyo
4+
5+
.vscode/
6+
.idea/
7+
8+
.DS_Store
811 KB
Loading
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
"""Demonstrates a tool that has a bidirectional binding to scene data at the example of a
2+
'Light Manager'.
3+
4+
Changes made to data monitored by the tool are forwarded to the tool, and changes made in the tool
5+
are forward to the scene. This is a very common pattern used in plugins such as "managers", render
6+
engines, and other tools that have to synchronize their internal state with the scene state.
7+
8+
Subjects:
9+
- Tool finalization workflow with working with a snapshot of scene data and rolling changes
10+
for edits or when the user aborts the tool, including undo handling.
11+
- Tracking scene state changes via core messages
12+
13+
Technical Overview:
14+
This example effectively builds scene metadata with which we can unambiguously identify and
15+
get scene data. Looking at the final outcome, one might ask why we are all doing this. Why do we
16+
not simply store objects and documents in lists? Let us find out together and assume we have this
17+
simple scene:
18+
19+
Scene
20+
├─ Cube
21+
│ ├─ Light_0
22+
│ └─ Cone
23+
└─ Sphere
24+
└─ Light_1
25+
26+
We now wrote a light lister plugin, which scanned this scene and stored the following data in
27+
code, where #trackedLights now hold references to #Light_0 and #Light_1:
28+
29+
trackedLights: list[c4d.BaseObject] = [
30+
light for light in mxutils.IterateTree(doc.GetFirstObject(), True)
31+
if light.GetType() == c4d.Olight
32+
]
33+
34+
That should be a safe way to access and track the light objects, right? Well, unfortunately not.
35+
Let us assume the user adds a new light and we have a scene update, and our scene now looks like
36+
this (the effect described below could also happen with no user changes at all):
37+
38+
Scene
39+
├─ Cube
40+
│ ├─ Light_0
41+
│ └─ Cone
42+
└─ Sphere
43+
├─ Light_1
44+
└─ Light_2
45+
46+
We now run code, to find out if Light_0 is still in the scene. It could look like this:
47+
48+
for item in mxutils.IterateTree(doc.GetFirstObject(), True):
49+
if item.GetName() == "Light_0" and item in trackedLights:
50+
print ("Light_0 is still there.")
51+
52+
That should print "Light_0 is still there.", right? Well, chances are good that this print
53+
statement will not be executed. But why, did we not just put that very light into #trackedLights?
54+
The reason why this can fail is that the 'Light_0' which Cinema 4D has given us when we built
55+
our #trackedLights list might not be the same 'Light_0' which we get when we traverse the scene
56+
after the update.
57+
58+
They might look and behave identical, but under the hood Cinema 4D might have destroyed the old
59+
'Light_0' and created a new one with the same name and properties. When this happens, Cinema 4D
60+
(or the user) deallocated something, and we still hold a reference to it, the respective C4DAtom
61+
will return #False for IsAlive() and all other calls to that reference will fail with the error
62+
message that the object is not alive. So, our list #trackedLights can turn into a list of dead
63+
pointers, even when the data is actually still in the scene, just in a new incarnation. This can
64+
happen with any scene element, be it a document itself, an object, a tag, a material, render
65+
settings, and so on.
66+
67+
# This might now print #False twice.
68+
for light in trackedLights:
69+
print (light.IsAlive())
70+
71+
This does not mean that we can never store any references to scene data, within the scope of a
72+
function that is okay. But we can never use references as long time storage for scene data.
73+
Because when we access it, the user or Cinema 4D might have long deleted that data.
74+
75+
This code example offers the solution to this problem. Internally, Cinema 4D marks each scene
76+
element with a marker that uniquely identifies it within the scene. This marker will stay the
77+
same, even when Cinema 4D reallocates an element. It will even stay the same over scene saves
78+
and reloads. So, if we want to long term track scene elements, we can either use builtin methods
79+
such as BaseLink or store these unique markers to identify the elements we want to track. In
80+
Python, this data can be accessed via C4DAtom.FindUniqueID() or via C4DAtom.__hash__().
81+
FindUniqueID() returns a memoryview on the unique marker data. While __hash__() returns an int
82+
which is a hash of that unique marker data. While the output is different, they both express the
83+
same underlying unique marker.
84+
85+
# Build a list of UUIDs for the found light objects.
86+
uuids: list[int] = []
87+
alsoUuids: list[bytes] = []
88+
for light in mxutils.IterateTree(doc.GetFirstObject(), True):
89+
if light.GetType() == c4d.Olight:
90+
uuids.append(hash(light))
91+
alsoUuids.append(bytes(light.FindUniqueID(c4d.MAXON_CREATOR_ID)))
92+
"""
93+
__copyright__ = "Copyright 2026, MAXON Computer"
94+
__author__ = "Ferdinand Hoppe"
95+
__date__ = "10/02/2026"
96+
__license__ = "Apache-2.0 license"
97+
98+
import c4d
99+
import mxutils
100+
import typing
101+
102+
# The title of the plugin, used for the command name and the dialog title.
103+
LIGHT_TOOL_TITLE: str = "Py - Light Tool (Scene Synchronization Logic)"
104+
105+
class LightToolDialog (c4d.gui.GeDialog):
106+
"""Tracks the existence of light objects and their parameter changes in a scene.
107+
"""
108+
# Dialog element IDs.
109+
ID_GRP_MAIN: int = 1000
110+
ID_GRP_DYN_LIGHTS_CONTAINER: int = 1001
111+
ID_GRP_DYN_LIGHT_ROW: int = 1002
112+
113+
ID_CHK_CONSOLE_OUTPUT: int = 2000
114+
ID_DYN_LIGHT_OFFSET: int = 10000
115+
116+
# Some minor symbols for managing the internal lookup table.
117+
NEW_LIGHT: int = 0 # A new light has been found
118+
UPDATED_LIGHT: int = 1 # An existing light has been updated.
119+
120+
def __init__(self) -> None:
121+
"""Initializes a LightTrackerDialog and its internal table tracking a scene state.
122+
"""
123+
# The light types and parameters we track.
124+
self._trackedTypesAndParameters: dict[int, list[int]] = {
125+
c4d.Olight: [c4d.LIGHT_TYPE, c4d.LIGHT_BRIGHTNESS, c4d.LIGHT_COLOR],
126+
c4d.Orslight: [c4d.REDSHIFT_LIGHT_TYPE,
127+
c4d.REDSHIFT_LIGHT_PHYSICAL_INTENSITY,
128+
c4d.REDSHIFT_LIGHT_PHYSICAL_COLOR],
129+
}
130+
131+
self._lightTable: dict[bytes: dict] = {}
132+
133+
# The internal data of the tool, we track light objects via their hashes (int) and store
134+
# their data as a dictionary.
135+
self._data: dict[int, int] = {}
136+
self._trackedDocument: int | None = None
137+
self._consoleOutput: bool = True
138+
139+
def CreateLayout(self) -> bool:
140+
"""Adds GUI gadgets to the dialog.
141+
142+
Not needed in this case, as we do not want to use GeDialog as a dialog, but for its ability
143+
to receive core messages.
144+
"""
145+
self.SetTitle(LIGHT_TOOL_TITLE)
146+
self.GroupBorderSpace(5, 5, 5, 5)
147+
self.AddStaticText(id=1000, flags=c4d.BFH_SCALEFIT, inith=25, name='This GUI has no items.')
148+
return True
149+
150+
def CoreMessage(self, mid: int, data: c4d.BaseContainer) -> bool:
151+
"""Receives core messages broadcasted by Cinema 4D.
152+
"""
153+
# Some change has been made to a document.
154+
if mid == c4d.EVMSG_CHANGE:
155+
self.ScanActiveScene()
156+
return 0
157+
158+
def Message(self, msg: c4d.BaseContainer, result: c4d.BaseContainer) -> any:
159+
"""Called by Cinema 4D when a message is sent to the dialog.
160+
161+
Args:
162+
msg (c4d.BaseContainer): The message data for the message event.
163+
result (c4d.BaseContainer): The message data for the result of the message event.
164+
165+
Returns:
166+
any: The result of the message event.
167+
"""
168+
# When we get focus, grab the newest scene data.
169+
if msg.GetId() == c4d.BFM_GOTFOCUS:
170+
self.ScanActiveScene()
171+
172+
return c4d.gui.GeDialog.Message(self, msg, result)
173+
174+
# --- Custom Methods ---------------------------------------------------------------------------
175+
176+
def ScanActiveScene(self) -> tuple[list[int], list[int]]:
177+
"""Scans the active document for changes in tracked light objects.
178+
"""
179+
return self.ScanScene(c4d.documents.GetActiveDocument())
180+
181+
def ScanScene(self, doc: c4d.documents.BaseDocument) -> tuple[list[int], list[int]]:
182+
"""
183+
"""
184+
# Get all tracked light objects that are of a type we want to track.
185+
mxutils.CheckType(doc, c4d.documents.BaseDocument)
186+
allLights: list[c4d.BaseObject] = [
187+
n for n in mxutils.IterateTree(doc.GetFirstObject(), True)
188+
if self._trackedTypesAndParameters.get(n.GetType(), None) is not None
189+
]
190+
if not allLights:
191+
return [], []
192+
193+
# Store of the hash of the document we are tracking, so that we can later easily verify
194+
# that a given document X is the same as the one we are tracking by comparing hash(X)
195+
# to this value. #hash() will call C4DAtom.__hash__(), which in turn will hash the
196+
# MAXON_CREATOR_ID unique ID of the element.
197+
self._trackedDocument = hash(doc)
198+
199+
# Build a list of UUIDs for the found light objects, so that we can compare them with our
200+
# stored data.
201+
allUuids: list[int] = [hash(n) for n in allLights]
202+
203+
# Now build a list of all new lights, by checking if the UUIDs are already in the internal
204+
# table. And the list of changed lights, by checking if the DIRTYFLAGS_DATA checksum of the
205+
# object is higher than the cached value in the internal table.
206+
newLights: list[int] = [u for u in allUuids if u not in self._data.keys()]
207+
changedLights: list[int] = [u for u, l in zip(allUuids, allLights)
208+
if self._data.get(u, c4d.NOTOK) != l.GetDirty(c4d.DIRTYFLAGS_DATA)]
209+
210+
# Finally, update the internal table with new dirty data.
211+
for light in allLights:
212+
uuid: int = hash(light)
213+
self._data[uuid] = light.GetDirty(c4d.DIRTYFLAGS_DATA)
214+
215+
# Print out the changes when console output is enabled.
216+
if self._consoleOutput:
217+
if len(newLights) < 1:
218+
print ("No lights have been added.")
219+
else:
220+
print ("The following new lights have been found:")
221+
for uuid in newLights:
222+
light: c4d.BaseObject = allLights[allUuids.index(uuid)]
223+
print(f"\tuuid: {uuid}: name: {light.GetName()}")
224+
225+
if len(changedLights) < 1:
226+
print ("No lights have been modified.")
227+
else:
228+
print ("The following lights have been modified:")
229+
for uuid in changedLights:
230+
light: c4d.BaseObject = allLights[allUuids.index(uuid)]
231+
print(f"\tuuid: {uuid}: name: {light.GetName()}")
232+
233+
return newLights, changedLights
234+
235+
def GetLight(self, sceneUuid: int, lightUuid: int) -> c4d.BaseObject | None:
236+
"""Returns the light object for a given uuid in a given scene.
237+
"""
238+
# Just as for the builtin BaseLink system (which works with the same basic mechanism), an
239+
# uuid is worthless without the context of a scene, as it only identifies an element within
240+
# a given scene.
241+
242+
# We can fashion this function be getting passed a document, but for demonstration purposes,
243+
# we use an document UUID. While the rest of the plugin does not actively supports this, as
244+
# the data will be updated as soon as the scene changes, this function as is would work
245+
# over active scene boundaries. In an extreme case, one could even start loading scenes
246+
# from disk, to attempt to find a scene with the given #sceneUuid, we 'just' iterate over
247+
# all open documents and check their UUIDs until we find a match.
248+
doc: c4d.documents.BaseDocument = c4d.documents.GetFirstDocument()
249+
foundScene: bool = False
250+
while doc and not foundScene:
251+
if hash(doc) == sceneUuid:
252+
foundScene = True
253+
else:
254+
doc = doc.GetNext()
255+
256+
if not foundScene:
257+
print (f"Could not find a scene with the given uuid {sceneUuid}.")
258+
return None
259+
260+
# Now we have the document, we can iterate over the scene to find the light with the given uuid.
261+
light: c4d.BaseObject | None = None
262+
for obj in mxutils.IterateTree(doc.GetFirstObject(), True):
263+
if hash(obj) == lightUuid:
264+
return obj
265+
266+
print (f"Could not find a light with the given uuid {lightUuid} in the scene with uuid {sceneUuid}.")
267+
return None
268+
269+
270+
class LightToolCommand (c4d.plugins.CommandData):
271+
"""Realizes the command for the light tool dialog.
272+
"""
273+
# The dialog hosted by the plugin.
274+
REF_DIALOG: LightToolDialog | None = None
275+
276+
@property
277+
def Dialog(self) -> LightToolDialog:
278+
"""Returns the class bound dialog instance.
279+
"""
280+
if self.REF_DIALOG is None:
281+
self.REF_DIALOG = LightToolDialog()
282+
283+
return self.REF_DIALOG
284+
285+
def Execute(self, doc: c4d.documents.BaseDocument) -> bool:
286+
"""Folds or unfolds the dialog.
287+
"""
288+
if self.Dialog.IsOpen() and not self.Dialog.GetFolding():
289+
self.Dialog.SetFolding(True)
290+
else:
291+
self.Dialog.Open(c4d.DLG_TYPE_ASYNC, self.ID_PLUGIN, defaultw=300, defaulth=300)
292+
293+
return True
294+
295+
def RestoreLayout(self, secret: any) -> bool:
296+
"""Restores the dialog on layout changes.
297+
"""
298+
return self.Dialog.Restore(self.ID_PLUGIN, secret)
299+
300+
def GetState(self, doc: c4d.documents.BaseDocument) -> int:
301+
"""Sets the command icon state of the plugin.
302+
"""
303+
result: int = c4d.CMD_ENABLED
304+
if self.Dialog.IsOpen() and not self.Dialog.GetFolding():
305+
result |= c4d.CMD_VALUE
306+
307+
return result
308+
309+
# The unique ID of the plugin, it must be obtained from developers.maxon.net.
310+
ID_PLUGIN: int = 1067514
311+
312+
# The name and help text of the plugin.
313+
STR_NAME: str = LIGHT_TOOL_TITLE
314+
STR_HELP: str = ("Opens a dialog to make rolling changes to the selected SDS tags, based on a"
315+
"a spline GUI, which can then be finalized or discarded.")
316+
317+
@classmethod
318+
def Register(cls: typing.Type, iconId: int) -> None:
319+
"""Registers the command plugin.
320+
321+
This is a custom method and not part of the CommandData interface.
322+
"""
323+
bitmap: c4d.bitmaps.BaseBitmap = c4d.bitmaps.InitResourceBitmap(iconId)
324+
c4d.plugins.RegisterCommandPlugin(
325+
id=cls.ID_PLUGIN, str=cls.STR_NAME, info=0, icon=bitmap, help=cls.STR_HELP, dat=cls())
326+
327+
328+
# Called by Cinema 4D when this plugin module is loaded.
329+
if __name__ == '__main__':
330+
LightToolCommand.Register(iconId=c4d.ID_MODELING_EDGESMOOTH_TOOL)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Py - Tool Finalization Example
2+
3+
Demonstrates a tool finalization workflow at the example of editing SDS weights.
4+
5+
Tool finalization workflow describes the practice lock onto a snapshot of scene data and then let
6+
the user make rolling changes to the scene with viewport feedback. Each change will be based on the
7+
snapshot data and not the data which is shown in the viewport (and in the scene), so that each
8+
edit does not build on the previous edits but on the initial data. Unless the user finalizes the
9+
changes, e.g., by clicking an "Apply" button, changes are not actually applied to the scene, and
10+
rolled back to the initial state when the user aborts the tool directly or indirectly. This is a
11+
common workflow for tools that can also be applied to other cases such as modelling.
12+
13+
![](preview.gif)
14+
15+
*Fig.I - Shows the user editing the weights with viewport feedback. When the user changes the active
16+
tag selection without finalizing the changes, the tool rolls back the unfinalized changes before
17+
locking onto the new selection.*
18+
19+
Open this dialog example by running the command "Py - SDS Weighting Tool (Finalization Logic)" in the
20+
Commander (Shift + C). The tool tracks the selection of SDS tags in the scene and lets the user
21+
modify their weights with a spline custom GUI. Editing the spline will always use the base data,
22+
although we apply and see the final result in the scene. Only when the user clicks the "Apply"
23+
button, we finalize the changes and update our base data.
24+
25+
#### Subjects
26+
27+
- Tool finalization workflow with working with a snapshot of scene data and rolling changes
28+
for edits or when the user aborts the tool, including undo handling.
29+
- Tracking scene state changes via core messages.
811 KB
Loading

0 commit comments

Comments
 (0)