MCP
An MCP server lets an AI assistant operate Cinema 4D directly: you describe in conversation what you want, and the assistant carries it out through Cinema 4D’s API. MCP stands for Model Context Protocol, an open standard for connecting AI assistants to applications. If the term is new to you, What is MCP? explains the idea, what this connection is suited to, and what it deliberately does not do.
The rendering above was created entirely through an interactive conversation with an AI Assistant, using basic objects, generators, splines, ready-made assets from the Asset Browser, and corresponding Redshift materials.
The settings on this page govern how much control the assistant is given — among other things, whether it may use Python. Python is primarily needed for building node graphs, for advanced API commands and for developing plugins, whereas simple object calls can be performed without it.
In general, the MCP Server Integration in Cinema 4D works with AI assistants that support MCP servers — desktop applications, code editors and command-line tools alike, such as Claude Desktop or OpenAI Codex. For some clients, Cinema 4D can write the connection into the client’s configuration directly; for any other, the entry can be copied and pasted by hand. Which clients are offered for direct setup depends on the version of Cinema 4D in use.
In any case, it is important to launch Cinema 4D first in order to activate the MCP Server there. Afterward, the AI assistant can be launched using its MCP Client.
The MCP Client installation needs to be performed only once for the desired AI assistant and is carried out via the settings in the Clients category in this section.
Guidelines for the Development of Node Networks
Working with nodes to extend Cinema 4D’s native features, in particular, can open up whole new worlds. We’ve therefore summarized our experiences here based on several development projects we’ve carried out ourselves. If you’d like to delve deeper into the development of AI-supported node graphs in particular, this information may be helpful. Simply provide this information to your AI assistant once to immediately clear up some common stumbling blocks right at the start of your work.
You can also download this guideline here as an .md file and provide it to your assistant directly.
The following reference covers building and editing Scene Nodes graphs from script: how to reach a graph, create and wire nodes, configure their ports correctly, share the graph with a person working in the Node Editor — and how to verify the result, because most mistakes in this API produce no error at all.
The governing principle
A successful write is not a valid write
The Nodes API accepts almost anything you hand it. Write a mode identifier that does not exist and it is stored. Write a datatype spelling that is not on the port's list and it is stored too. Nothing raises, nothing appears in the console, and the graph keeps evaluating — just not the way you intended.
In the Node Editor an invalid enum value shows up as an extra entry appended below the legitimate ones in the dropdown, checkmarked. From script it is indistinguishable from a correct value unless you compare it against the set the port actually accepts.
Consequence for any script: read back every enum-like value you write, against the port's own list of legal values rather than against your expectations. The mechanism is one call and needs no prior knowledge of any node's vocabulary — it is the first item under Configuring ports.
A related trap is worth naming: do not invent your own validity indicator. A heuristic like "if the node's other ports survive, the value was accepted" can appear to work for a long time while being wrong, and it stops you looking at the one attribute that actually answers the question. If you cannot read the truth directly, treat the state as unknown.
Units
The number you write is not the number the field shows
Cinema 4D stores raw values and converts them for display. A parameter the interface labels in degrees holds radians; one shown as a percentage holds a fraction; a colour shown as 0–255 holds 0–1. Write the number you can read on screen and the call succeeds, the parameter is set, and the result is wrong.
Measured on a Bend deformer:
set radians(90) -> stored 1.570796 -> the interface shows 90°
set 90.0 -> stored 90.0 -> 5156.6°, fourteen full turns
Fourteen turns is not a subtle error, but it is one that becomes visible only in the viewport. Nothing in the call reports it, and the parameter reads back exactly as it was written.
| The interface shows | The parameter holds | Where you meet it |
|---|---|---|
| degrees | radians | a deformer's Strength and Angle, and every object's Rotation |
| percent | 0 … 1 | an effector's Strength: 1.0 is 100 % |
| 0–255, or percent | 0 … 1 per channel | any colour |
| cm | the document's own unit | a default cube's Size is 200 |
The description does not carry the unit
Reading the description first is the right instinct, and here it does not help. get_entity_description returns the Bend's angle as {"name": "Angle", "dtype": "DTYPE_REAL"} — the same shape it returns for a plain number. The unit exists in the API, where DESC_UNIT for that parameter is DESC_UNIT_DEGREE, but it is not passed on. So an agent that reads the description carefully still has nothing to go on.
Until the unit is reported: treat any parameter called Angle, Rotation, Tilt, Twist or Bank as radians, and Strength on a deformer as radians too — it is an angle despite the name. Convert on the way in rather than after the fact.
How to settle it without guessing
Through exec_python the question has a direct answer. A parameter's own description carries its unit and its step, and the step of an angle is one degree expressed in radians:
for bc, pid, gid in op.GetDescription(c4d.DESCFLAGS_DESC_NONE):
if bc[c4d.DESC_IDENT] == "DEFORMOBJECT_ANGLE":
print(bc[c4d.DESC_UNIT], bc[c4d.DESC_STEP])
# DESC_UNIT_DEGREE 0.017453292519943295 = pi/180
Without exec_python, write a known value and read it back against what the interface shows — the same discipline as everywhere else on this page. A value that comes back as it was written proves the write, not the meaning.
Orientation
Where the answers are
Parameter names, object ids and workflows are not worth guessing at: they are all written down, and they move between releases. The portal at help.maxon.net links every product's manual, and each one follows the same shape, help.maxon.net/<product>/<language>/.
| Product | Documentation | Covers |
|---|---|---|
| Cinema 4D | help.maxon.net/c4d/
|
the application itself |
| Maxon Cineware | help.maxon.net/cw/
|
bridge technology for exchanging scenes and data between Cinema 4D and other applications, such as Adobe Illustrator or Unreal Engine |
| Redshift | help.maxon.net/r3d/
|
the renderer |
| Red Giant | help.maxon.net/rg/
|
tools for filmmaking and motion graphics — and the Universe plug-in suite |
| ZBrush | help.maxon.net/zbr/
|
sculpting and painting |
| ZBrush for iPad | help.maxon.net/zbp/en-us/
|
the iPad edition |
| Forger for iOS | help.maxon.net/fgr/
|
mobile sculpting |
| Maxon Autograph | help.maxon.net/ag/
|
motion design and compositing |
| Maxon Studio | help.maxon.net/mxs/
|
template engine and interface for After Effects |
| Maxon App | help.maxon.net/mxa/
|
products and licences |
Universe and Red Giant were consolidated into one documentation set. Universe is sold on its own as well as inside the larger Red Giant package, and the Red Giant documentation carries a page that takes Universe users straight to the parts that belong to their product — so a Universe question has no separate manual to look for.
For writing code rather than operating the application:
| Reference | Documentation | Covers |
|---|---|---|
| Cinema 4D Python SDK | developers.maxon.net/docs/py/
|
versioned; match the running application |
| Cinema 4D C++ SDK | developers.maxon.net/docs/cpp/
|
same concepts and identifiers; the maxon API and the Nodes framework are described in more depth here |
| Developer portal | developers.maxon.net/
|
entry point for both |
Silent · Invented facts
A fetched page is a summary of that page, not the page
An assistant's fetch tool usually hands back a summary written by a smaller model, not the document. Where the page's own text is thin — names set as images, content assembled by script — the summary fills the gap with something plausible, and nothing marks the difference.
Measured on this very documentation set: the product portal carries only the one-line descriptions in its readable text, not the product names. A summary of it returned a product name that appears nowhere on the page, and three further names came back subtly altered — Maxon App Manager for Maxon App, After Effects Integration for Maxon Studio, ZBrush Paint for ZBrush for iPad. All four read perfectly plausibly. The error would have travelled silently into a briefing and misdirected every later question about those products.
The habit: name a product from its own documentation, not from a page that lists it. A page title is evidence; a description next to a link is not. This is the same rule as never invent a validity indicator, applied to reading rather than measuring.
Faster and exact
A local copy beats a web request
Cinema 4D's offline help, where it is installed, matches the running build exactly, costs no round trip, and can be searched with ordinary file tools. Prefer it for version-specific details, and keep the online manuals for products that are not installed.
The SDK documentation is versioned per release — developers.maxon.net/docs/py/2026_3_0/ and so on. Reading whichever version a search engine returned first is how a parameter name that no longer exists ends up in a script.
Looking
Seeing the scene costs more than asking it
A picture is the expensive way to answer a question and the only way to answer some. Spend it in that order. Is this rotated correctly, do these two parts meet, is it standing on the ground are answered exactly by get_parameters, get_transform_at_frames or get_mesh, in one call, in numbers that leave nothing to interpretation. A render answers the same question approximately and takes several calls. Reach for an image when the question is about how something looks — proportion, overlap, material, lighting — or to show the person what you mean.
Misread signal · Wasted turns
A render in progress is not a render that failed
render_preview_image works on its own thread so the application stays usable. The first call starts the render and returns status: rendering; a later call collects the image, or reports pass_progress while it works. That is the protocol doing its job. Read the repetition as an error — and it looks like one, the same call issued three times in a row — and you will either start over or switch to a renderer you did not need, with no failure anywhere in the record.
Measured on a scene of thirty cube objects at 1024 × 768:
| Renderer | Image arrives on call | Elapsed | What it can answer |
|---|---|---|---|
hardware
|
2nd | 0.11 – 0.35 s | the viewport’s OpenGL renderer; shape, placement and overlap |
standard
|
varies | — | the real renderer — the cheapest one that can answer a lighting question |
redshift
|
3rd | 2.45 s | what the document itself renders with; quality: final for the real picture |
The render is not what costs; the polling is. Each collection attempt is a full round trip. Choose the cheapest renderer that can answer the question you actually have — hardware approximates or ignores scene lighting, sky and most shader behaviour, so it settles a question of geometry in a fraction of the time and cannot settle one about light at all.
Frozen application
The two ways of looking are not interchangeable
capture_viewport returns the editor as the person sees it — grid, axes, handles, HUD, and helper objects that render nothing. It evaluates the scene in full and freezes the application while it does, which on a heavy scene means minutes. render_preview_image shows the scene alone and does not block. Use the first when the editor itself is the subject, the second for everything else.
A capture also answers identical_to_previous: true when nothing has changed since the last one. That is an answer, not a failure: changes made through the tools do not necessarily redraw the editor. Capturing again in the hope of a different picture buys another freeze and the same bytes. Force the redraw once instead, then look.
Leftovers
What you add in order to look is still there afterwards
Inspecting a scene invites building a little scaffolding: a camera placed for a good view, a target tag to aim it, a converted copy of a generator to measure, a change of the active camera or the active view. All of it lands in the person’s document and stays there. A review camera left behind in a scene someone then saves is a puzzle for them and a support question for you.
The habit: put the scene back the way you found it, in the same turn, or name what you left and why. And prefer the inspection that leaves nothing at all — reading a matrix changes nothing, converting a generator changes the document.
Reaching a graph
Entry point
One call for the document graph, the same call for an object's graph
import maxon
sg = maxon.GraphDescription.GetGraph(doc)
# the document's Scene Nodes graph
cg = maxon.GraphDescription.GetGraph(obj)
# a capsule's / node object's own graph
root = sg.GetViewRoot()
# iterate its children from here
Every modification must sit inside a transaction, or it is not applied:
tr = sg.BeginTransaction()
# ... create, connect, SetPortValue ...
tr.Commit()
Keep a transaction to one coherent step. Splitting a large edit into several small transactions across several calls costs nothing — the graph persists between calls — and makes a partial failure recoverable.
Structure
Two kinds of children: nodes, and the graph's own interface
Alongside real nodes, a graph or group contains interface nodes displayed as < (inputs) and > (outputs). They raise on GetInputs(), so filter them out before iterating:
def valid(n):
try:
list(n.GetInputs().GetChildren()); return Trueexcept Exception: return False
A group's outward-facing ports are reached from the outside through group.GetInputs() / GetOutputs(). From inside the group the same ports appear as children of that < / > interface node — which is why a port's path can read group@hash<in@hash. Both handles refer to the same port; connecting an inner node to the one you obtained from the outside works.
Finding nodes and ports
Pattern
Node ids carry a hash suffix — build a name → node map
A node you created as SCALE is stored as SCALE@QkOyp-uFNxfl0FrdohzOUe, so path lookups on the plain name fail. Iterating the children and splitting on @ gives you back the names you chose:
NODE = {}
for c in group.GetChildren():
if valid(c): NODE.setdefault(str(c.GetId()).split("@")[0], c)
Rebuild this map at the start of every call rather than caching handles across calls. To locate an unfamiliar group, look for a port only that kind of node exposes — a modifier group carrying a matrix interface, for instance, is the one whose inputs include modifiermatrix.
Lookup
GraphModelHelper does the searching
H = maxon.GraphModelHelper
H.FindNodesByAssetId(graph, "net.maxon.node.compare", True) # True = recurse into groups
H.FindNodesByName(graph, "...", ...)
H.ListAllNodes(graph, matchingData)
# DataDictionary of attribute values to match
H.GetAllPredecessors(node, ...)
# upstream / downstream traversal
H.GetAllSuccessors(node, ...)
Not readable
A node instance does not report its own asset id — probe for it
node.GetValue("net.maxon.node.base.assetid") returns None. FindNodesByAssetId returns an empty list for an id that does not exist and the matching nodes for one that does, which turns identification into a cheap loop over candidates:
for a in ["net.maxon.node.if", "net.maxon.node.condition", "net.maxon.node.switch"]:
print(a, len(H.FindNodesByAssetId(sg, a, True)))
Confirmed ids for frequently used nodes:
| Node | Asset id |
|---|---|
| Arithmetic | net.maxon.node.arithmetic
|
| Compare | net.maxon.node.compare
|
| Trigonometry | net.maxon.node.trigonometry
|
| Transform Vector | net.maxon.node.transformvector
|
| If | net.maxon.node.if
|
| Clamp | net.maxon.node.clamp
|
| Value / constant | net.maxon.node.type
|
| Inverse Matrix | net.maxon.node.inversematrix
|
| Transform Geometry | net.maxon.neutron.geometry.transform_element
|
Careful: Port names do not identify a node type. If and Clamp both expose in1 / in2 / in3, but for If that is condition / true / false and for Clamp it is value / low / high. Confirm the asset id before assuming what a port means.
Easy to miss
Ports have children, and some connection targets sit deep inside a node
GetInputs().GetChildren() returns only the top level. Bundles, variadic slots and the ports of a node's inner structure are nested further down, and a legitimate connection target can live several levels in — a path such as node/</some.bundle/_0/geometry is normal. If you need to find one, walk recursively over GetChildren() looking for the port id:
hits = []
def walk(n, path, depth=0):
if depth > 6: returntry: kids = list(n.GetChildren())
except Exception: returnfor c in kids:
p = path + "/" + str(c.GetId())
if str(c.GetId()) == wanted: hits.append(p)
walk(c, p, depth + 1)
Careful: Record a connection's full endpoint path before you break it. A target you found by following an existing wire may be very hard to locate again from the node's port list alone.
Creating and removing nodes
Basics
AddChild on the graph, Remove on the node
tr = sg.BeginTransaction()
n = sg.AddChild("MYNODE", "net.maxon.node.arithmetic", maxon.DataDictionary())
tr.Commit()
tr = sg.BeginTransaction()
n.Remove()
tr.Commit()
Pass an empty string as the first argument to let a UUID be assigned. Passing your own name is worth it — it is what makes the name → node map above readable.
Technique · Non-obvious
To create a node inside a group, open a second view rooted at that group
A group is not a container you can add to directly. Create a view whose root is the group, and AddChild on that view lands inside it:
gv = sg.CreateView(3, group.GetPath()) # 3 = FILTER.INCLUDE_ALL, as a raw int
tr = gv.BeginTransaction()
n = gv.AddChild("MYNODE", "net.maxon.node.arithmetic", maxon.DataDictionary())
tr.Commit()
# n.GetPath() -> group@hash/MYNODE
Two details. The filter must be a plain int — the enum object maxon.NodeSystemManagerInterface.FILTER.INCLUDE_ALL arrives as a tuple and fails to convert. And removal through the same view works, so this is also how you delete nodes that live inside a group.
Don't: AddChild(maxon.Id(groupId + "/NAME")) on the main view creates a top-level node whose name contains an escaped slash. Every subsequent attempt to connect it fails with "Can't connect ports across node system boundaries", which reads like a port problem and is not one. MoveToGroup is also not a way in — it creates a new group rather than moving into the existing one.
Blocked · Misleading error
A group that has produced an asset will not take new nodes
Once Convert To Asset has been run from a group, AddChild inside that group fails with Illegal state: Condition !self.IsReadOnly(). Connections and port values in the same group keep working normally, which is what makes the restriction so easy to misread: most of your script still succeeds.
Worth knowing what does not lift it. Deleting the capsule instance from the scene does not. Neither does disabling it so that it stops evaluating. The state rides with the group itself, so the practical route is to have a person create the node in the Node Editor and then configure and wire it from script — see Handing work to a person below.
Careful: Do not diagnose this from the error text. "Read-only" invites theories about the scene, the document or an evaluating capsule; the test that actually answers it is whether creation fails while a Connect() in the same group succeeds.
Crash
One node per call, one view per call
Creating several nodes in a loop, and above all calling CreateView more than once inside a single call, was the most reliable way to take Cinema 4D down during this work. Nodes that carry a domain or a container — loop nodes, array nodes — were the most sensitive of all.
Build the view once, create one node, commit, return. It feels wasteful and it is not: the graph persists between calls, so ten calls that each add one node cost ten round trips and nothing else, while one call that adds ten nodes can cost a restart and a reload.
Connecting ports
Doesn't work
@id references in a graph description do not create wires
A description entry like {"Input 2": "@K"} stores the literal string "@K" as a value. Nesting works and explicit Connect() works; the reference syntax does not. Drive every connection from an explicit wire table — it is also far easier to audit and to re-run:
RC = maxon.GraphModelHelper.RemoveConnection
def P(n, side, pid):
ports = n.GetInputs() if side == "in" else n.GetOutputs()
for p in ports.GetChildren():
if str(p.GetId()) == pid: return p
def wire(sn, sp, dn, dp):
# replaces whatever fed the input
d = P(NODE[dn], "in", dp)
for c in list(d.GetConnections(maxon.PORT_DIR.INPUT)): RC(c[0], d)
P(NODE[sn], "out", sp).Connect(d)
An input port takes one source, so clearing it before connecting avoids depending on whatever was there. Connect always runs source → destination.
Reading wires
Connections come back as tuples, and a port can name its owner
for c in port.GetConnections(maxon.PORT_DIR.INPUT): # upstream
other = c[0]
owner = other.GetAncestor(maxon.NODE_KIND.NODE)
print(str(owner.GetId()), str(other.GetId()))
port.GetConnections(maxon.PORT_DIR.OUTPUT) # downstream
GetAncestor(maxon.NODE_KIND.NODE) is what turns a port handle back into something you can name in a log. It is the fastest way to dump a readable edge list of a graph you did not build.
Configuring ports
The check · Start here
An enum port carries its own list of legal values
Ask the port for its fixedtype attribute. When the port is an enum, the datatype's string representation is the documentation — labels and ids together:
dt = port.GetValue("fixedtype")
# enum<net.maxon.datatype.id,"GreaterThan":"gt",
"LessThan":"lt",
# "GreaterOrEqual":"ge","LessOrEqual":"le",
"Equals":"eq","NotEquals":"ne">
Which makes a whole-graph audit possible without knowing any node's vocabulary in advance. Run it before you hand a graph over:
import re
def entries(s): return re.findall(r'"([^"]+)"\s*:\s*"([^"]*)"', s)
for node in group.GetChildren():
if not valid(node): continuefor p in node.GetInputs().GetChildren():
dt = p.GetValue("fixedtype")
if dt is None or not str(dt).startswith("enum<"): continue
allowed = [i for _, i in entries(str(dt))]
cur = p.GetPortValue()
if cur is None: continue# unset is legal — see belowif str(cur) not in allowed:
print(node.GetId(), p.GetId(), str(cur), "-> allowed:", allowed)
Silent · Wrong results
Every node family has its own mode abbreviations
There is no shared convention across nodes, and the display labels are not the ids. Values confirmed by reading the ports:
| Node | Port | Legal values |
|---|---|---|
| Arithmetic | operation
|
add sub mul div |
| Trigonometry | operation
|
sin cos … |
| Transform Vector | operation
|
transformvector
|
| Compare | operation
|
gt lt ge le eq ne |
Compare is the one that catches people. Because add, sub and sin read like plausible spelled-out words, the natural guess for a comparison is lessthan — which is stored, silently, and leaves the node comparing something other than what you wrote. Everything gated by that comparison then behaves unpredictably, which sends you looking at the branches instead of at the operation.
Good default
Leave datatype unset where the node ships unset — but check, because many do not
If is the clearest case: leave the field empty and the type is taken from the connected input ports and carried through to the output. A freshly created If ships with the port unset, and that is a working state, not an incomplete one.
But the type is not inferred in general, and the default is not always unset. Each node family ships with its own value, and two of the most-used ones default to scalar:
arithmetic float <- scalar
scale float <- scalar
dot net.maxon.parametrictype.vec<3,float>
normalize net.maxon.parametrictype.vec<3,float>
cross (no datatype port)
if None (genuinely unset)
Wire a vector into an Arithmetic or a Scale left at float and nothing complains — no red wire, no editor warning, no type error. The node keeps computing and the result keeps flowing; what arrives downstream is a vector rebuilt from a scalar, so its direction is wrong and its magnitude arbitrary. It is hard to catch because the numbers stay plausible: a quantity that should have been the constant 0.600 came out varying between 0.251 and 2.857. Setting the datatype explicitly on the offending nodes turned the same measurement into 0.579 … 0.610.
So read the shipped default with factory_default() on a throwaway node before deciding to leave a port alone, and set the type explicitly on Arithmetic and Scale whenever the data is not scalar.
Two practical consequences:
— Set datatype explicitly only when you must force a type: a constant node that has to emit a vector with nothing connected to it, or a node whose inputs would otherwise resolve to something you do not want. Every datatype you do not write is one fewer chance to write an invalid spelling.
— An audit must treat an unset value as legal. GetPortValue() returns None there, so skip it rather than reporting it as invalid.
Careful: fixedtype reports the port's declared type — the fallback used when nothing is set, commonly float64. It does not change when you connect a differently typed source, so it is the right attribute for reading an enum's allowed set and the wrong one for asking what type is actually flowing through a wire.
Silent
When you do set a datatype, use the canonical spelling
For a three-component vector that is net.maxon.parametrictype.vec<3,float>. The variant net.maxon.parametrictype.vec<3,float64> resolves to the same registered 24-byte type and computes correctly, but it is not on the port's enum list, so it produces the spurious extra dropdown entry described at the top. The friendly-looking string "Vector" is accepted and completely inert.
Note that float means Float64 in maxon; vec<3,float32> is a different, 12-byte type. You can confirm any spelling resolves with maxon.DataType.Get("..."), which takes a str, not a maxon.Id.
Silent · Order matters
Setting datatype and operation together wipes the datatype
The two cannot be applied in one description. Set datatype first and commit, then set operation in a second transaction. The same caution applies to any pair where one port reconfigures the other's meaning.
Silent · Wires lost
Some mode ports replace the port set — and the connections with it
A datatype or operation change usually leaves a node's ports where they were. A few mode ports do not: they reconfigure what the node is, and the inputs are swapped for a different set. A stream-mode toggle on an aggregating node, for instance, replaces a single in with stream plus two domain ports. Anything wired to the port that disappeared is silently gone — no error, and the node looks fine because it still has inputs.
So: set that kind of switch before wiring the node, never after. If you have to change one on a node that is already connected, re-read the port list afterwards and rebuild every wire into it. Two habits make this cheap — keep the wiring in an explicit table you can re-run, and dump the node's port ids before and after the change rather than assuming they matched.
Silent · Dead guards
A constant Value port rounds to three decimals
Measured: 1e-5 → 0, 1e-4 → 0, 1e-3 → 0.001. Any epsilon smaller than 0.001 entered on a Value node is stored as zero, which quietly turns a division-by-zero guard into no guard at all. If you need a smaller threshold, compute it inside the graph rather than typing it into a constant.
Reading values
GetPortValue, and what a port actually stores
GetDefaultValue() is deprecated in favour of GetPortValue(). To see everything a port holds — including which attribute the value is filed under — enumerate its attributes:
F = maxon.GraphAttributeInterface.FLAGS
for k, v in port.GetValues(F.DIRECT):
print(str(k), "=", str(v))
# net.maxon.description.data.base.defaultvalue = lt
<- the stored port value
# fixedtype = enum<...>
<- the type and its legal set
Useful when a port behaves unexpectedly and you want to see the raw state instead of inferring it.
Crash
Never pass None to SetPortValue
It takes Cinema 4D down immediately, at least on matrix ports. Guard the value before writing it — and note that this is a different operation from leaving a port unset, which is done by not writing to it at all.
Silent · Costs an hour, twice
A value written to a connected port is discarded without a word
SetPortValue on a port that already carries a wire does nothing. No error, no warning, no return value to check — the wire keeps winning and the number you wrote is gone.
Obvious stated plainly, invisible in practice, because the two situations that produce it both look like ordinary work:
— Rewiring an If. Swapping which branch is the constant and which is the connection. Setting in2 to a constant while the old wire into in2 is still attached leaves both branches computing the same thing. The node still evaluates, the result is plausible, and the symptom appears far downstream — two points of a pattern landing on top of each other.
— A/B testing a Switch. Writing 0 and then 1 into a Switch’s index to compare two paths yields two identical measurements when that index is driven by a wire. That reads as “the change had no effect”, which is exactly the wrong conclusion to draw.
So: detach first, then set. And when a measurement says a change made no difference, confirm that the knob you turned was connected to anything at all before believing it.
p = port(node, "in", "in2")
for c in list(p.GetConnections(maxon.PORT_DIR.INPUT)):
maxon.GraphModelHelper.RemoveConnection(c[0], p) # first
p.SetPortValue(0.0) # then
Loops, arrays and sampled data
Structure
A loop's domain port is what decides where it runs
Iteration nodes expose a domain input and, on the output side, innerdomain and outerdomain. Leave domain unconnected and the loop is outermost. Connect it to another loop's innerdomain and it runs once per iteration of that loop. Nothing else establishes nesting — canvas position and visual grouping mean nothing here, so a nested loop and a parallel one look identical until you read that one wire.
Which also means nesting is easy to create by accident, and it multiplies. An inner loop over n elements inside an outer loop over the same n elements is n² evaluations per frame; at a few thousand points that is tens of millions and the application simply stops. When you need a per-element value that depends on all the others, precompute a small table in an outer loop of N samples and read from it in the point loop: N × n instead of n².
Check: Before building a nested loop, multiply the two counts out. If the product runs into the millions, the structure is wrong regardless of whether the wiring is correct.
Wrong container
A collection is not an array, and indexing needs an array
The node registered as net.maxon.node.array outputs a collection, not an array — an element-getter cannot index it, and the failure surfaces as an invalid output rather than as an error naming the type. Building something indexable inside a loop takes either an append-style collector fed by the loop, or the documented three-node route: fill an array to the required length as a starting value, carry it around the loop with a loop-carried value, and write into it with an element setter.
Two names that mislead: a "build array" node is usually a manual builder with hand-added element inputs, not a loop collector; and a loop-carried value is a group node whose variable is created by hand in the editor, then threaded previous → next through the nodes inside it.
Silent · Editor-only error
An array port needs an array datatype spelling
net.maxon.parametrictype.array<float> — writing the element type float on an array container port is accepted by the API and produces the badge "Type 'Float64' is not an array or array container type" in the Node Editor, which script cannot see. The element getters reading that array need types consistent with its element type too, or their output stays invalid while every read-back you can perform looks correct.
This is the case where the person at the editor is your only error channel. If an array chain evaluates to nothing and the values all read back as written, ask for a screenshot before rewiring anything.
Not supported
Variadic ports cannot be created from script
Any node whose inputs grow on demand — append-style collectors, loop-carried variables, manual builders — gets those slots from a UI action (right-click on the node, Add Input / Add Port). RemovePorts affects variadic ports only, and there is no counterpart that adds one. From script the slot is simply not there, and a connection to it fails as if you had the port id wrong.
Plan for it: any design that needs a variadic slot needs a person in the editor at that step. Ask for it explicitly, by node and by port, rather than discovering it mid-wiring.
Worth checking first, though: several nodes ship with usable slots already present. Switch arrives with in/_0 and in/_1, and Append, Build From Value and Connect each keep one spare slot that appears once the datatype is set. Dump the node’s nested port list after configuring it and before concluding that a person is needed — the slot you want may already be there.
Circuit design · Visible artefact
An easing curve on an interpolation fraction flattens the result at every sample
When you interpolate between two sampled values, it is tempting to run the fractional part through a smooth-step for a "softer" result. Smooth-step has zero slope at both ends, so the interpolated curve arrives flat at every sample: the profile becomes a staircase with rounded steps, it reads as banding, and raising the sample count makes it worse rather than better, because each step gets its own plateau.
Use the fraction linearly and raise the sample count instead. An easing curve belongs where a genuinely flat approach is wanted — a blend that should start and end at rest — not on an index fraction.
Silent · Whole feature lost
Geometry can arrive carrying its tangents and still be evaluated as a polyline
A classic Bezier spline linked into a capsule through an Object Link delivers its control points — and, it turns out, its tangents. But the curve arrives typed as linear, and every evaluation node believes the type. spline.evaluate, spline.length and Resample all walk the control polygon, and the curvature is silently discarded.
It is hard to spot because nothing looks missing: the result follows the spline’s general course, just cutting every corner. Resampling to a hundred points is a good probe — if the resampled polyline comes back with exactly the control-polygon length, the curve is being read as linear.
The fix is one node, net.maxon.neutron.geometry.spline.type, set to bezier, placed where the geometry enters:
curve type length distance to the true Bezier curve
linear 27.371 1.6050
bezier 30.116 0.0106 # the real curve is 30.103
bspline 27.368 1.6047
Applying it unconditionally is safe. On a polyline that genuinely has no tangents — the output of Assemble Spline, say — retyping it as bezier moved points by at most 0.0094 over a 60-unit curve, because a cubic through collinear control points is still a straight line. There is no need to make it an option: type everything bezier on the way in and let the curves that have curvature use it.
Silent · Dead end
A valid curve type can make every later evaluation return nothing
Assemble Spline carries a splinetypein port. Its registry holds exactly three entries, and anything else raises — unusually well behaved for this API:
net.maxon.registry.geometryabstraction.curve.types.linear # default
net.maxon.registry.geometryabstraction.curve.types.bspline
net.maxon.registry.geometryabstraction.curve.types.bezier
# .cubic / .akima -> ValueError
Setting bspline produces a perfectly good spline — the resulting object reports type 3 and the same control point count. But spline.evaluate and spline.length return nothing for it, so a downstream capsule that samples that curve emits zero points, with no error anywhere. The evaluation nodes appear to handle interpolating curves only.
bezier evaluates, but Assemble Spline is given control points and no tangents, so there a Bezier is a polyline with extra steps. If a smoother curve is needed, smooth the control points before assembly instead — Chaikin corner cutting is exactly the subdivision scheme whose limit is the quadratic B-spline, and it leaves a point list the rest of the pipeline can still evaluate.
Technique · Measured
Two parallel curves sampled at the same parameter do not line up
Evaluating two offset curves at the same normalised parameter, even with uniform = true, does not give two points opposite each other. uniform normalises by arc length, and wherever the pair bends, the outer curve is longer — measured over one closed seam: 8.5 % overall, and up to 26 % per step at a sharp corner. The same parameter therefore lands at different arc positions, and because the mismatch integrates along the curve it accumulates: everything built across the pair leans, more and more, and flips sign after a counter-bend.
The fix needs no extra evaluation and no knowledge of the separation. The measured difference vector already contains the answer plus a longitudinal error term, so one Gram-Schmidt projection removes it:
T̂ = normalize( normalize(T_A) + normalize(T_B) ) # centreline tangent
D⊥ = D - dot(D, T̂) * T̂ # pure cross direction
But the same correction is wrong one mode over. Feed that circuit two independently drawn curves instead of two offsets of one chain and the projection becomes actively harmful: the vector between them is no longer a noisy offset but the genuine connection between two separate curves, and removing its longitudinal part slides the far endpoint off its own curve. Measured: points up to 1.19 away from the curve they were meant to lie on, where the largest legitimate offset was 0.35. Use the raw vector for the position there, and keep the projected one for the frame normal — a normal should be perpendicular to the direction of travel.
The general shape of the mistake is worth carrying away: a correction derived under an assumption gets taken into a mode where the assumption no longer holds, and it keeps producing plausible numbers. When a circuit gains a second input mode, every correction inside it needs re-justifying, not merely re-testing.
Technique · Measured
Measure the constant once outside the loop, then force it
Two problems that look like they need an iterative solver often do not.
A constant you cannot compute where you need it. Deriving the offset between two parallel curves at sample u is circular — you need the offset to find the matching point, and the matching point to measure the offset. But at u = 0 there is no parameter mismatch yet: both curves start at corresponding positions. So the distance between their start points is the offset exactly, it costs one extra evaluation per outer pass, and it is available to the whole inner loop.
A quantity whose correct value you already know. Rather than correcting the sampling, normalise the result and scale it to that known value. Measured on a seam of constructed width 0.600: before, the sampled cross vector ran 0.528 … 0.800 on a sharply turning chain; after, min = max = mean = 0.6000. That is not a fudge when the value really is constant by construction.
The contrast is the lesson. A Newton step was built for the same defect — re-evaluate the second curve at a corrected parameter using the already-computed error — six nodes, correctly wired, and it changed nothing (0.6060 against 0.5984). The projection above had already removed the same first-order error; the two were redundant. Only the measurement revealed that, and the six nodes came straight back out.
Technique · Off by a fraction
Centre a repeating pattern on (j + 0.5)/n — and watch the far end
When a loop places a repeating feature along a curve, the obvious mapping is u = (j + offset)/n with the offsets running from 0. That anchors every feature by its first point: change the feature’s width or angle and it grows forward from there, sweeping along the curve instead of changing in place. Users read that as the parameter being wrong. Centring the offsets on (j + 0.5)/n makes the feature pivot about its own middle instead.
The overshoot that comes with it. The trailing point of the last period then sits at
u = (n - 1 + 1.5 - frac/2) / n = 1 + (0.5 - frac/2) / n
which exceeds 1 for every frac < 1. Evaluation clamps it, so the overshoot is invisible — until something downstream reads that parameter for another purpose. Wrapping it into [0, 1] for a second curve turned the last point of an open spline into a jump back to its start, and it looked like a geometry bug rather than an off-by-a-fraction. Clamp the parameter to [0, 1] before anything else consumes it, and make any wrap trigger above 1 rather than at 1 — otherwise the honest endpoint of an open curve is mapped to its start.
Capsules, user data and resources
Workflow
Build in the Scene Nodes graph, convert to a capsule last
User-data input ports can be added to a Nodes Modifier inside the graph, but not to an existing capsule. Building the capsule first means migrating the whole circuit back into a Nodes Modifier to add a single parameter.
After conversion the circuit exists twice: as the source group in the graph, and as a copy inside the asset. Edits to one do not reach the other, and only a fresh Convert To Asset from the source group makes a change permanent. Treat the source group as the authority and the capsule as a build artifact — and when a scene contains both, be explicit about which one a script is editing.
Silent · Wrong branch
A resource enum delivers its cycle ids, not 0, 1, 2
A dropdown authored in the Resource Editor hands the graph its raw cycle ids, which commonly start at 1000. A three-way selector therefore arrives as 1000 / 1001 / 1002 and every comparison against 0 / 1 / 2 fails the same way, so the circuit looks like it is ignoring the menu. A Modulo 1000 ahead of the comparisons works with both numbering schemes and survives a later renumbering of the resource.
Silent · Feature unreachable
Resource limits clamp a value before the graph sees it
If a sentinel value drives graph logic — "0 means automatic", say — check the parameter's limits first. A MIN of 0.01 means writing 0 silently stores 0.01, and the comparison in the graph is never true. Either widen the limit or put the threshold above it and document that.
for bcd, did, gid in obj.GetDescription(c4d.DESCFLAGS_DESC_0):
if bcd[c4d.DESC_NAME] == "My Parameter":
print(obj[did], bcd[c4d.DESC_MIN], bcd[c4d.DESC_MAX], bcd[c4d.DESC_UNIT])
The same loop is the general way to map a capsule's human-readable parameter names to the ids you need for obj[did], since those ids are long nested tuples that are impractical to write by hand.
Not supported
Two things the API will not do
Ports cannot be deleted from script — RemovePorts only affects variadic ones, so removing a parameter means opening the Resource Editor. And a spline parameter cannot be exposed as a capsule port: the port stays typeless and never appears in the Attribute Manager, so curve-shaped controls have to be approximated with numeric parameters.
Asymmetric API
A scene port cannot be created from script, but it can be removed
Adding a parameter to a capsule stays a manual step in the Node Editor — AddPort on the graph root is refused. Removing one, however, works, and behaves like any other graph edit:
root = graph.GetViewRoot()
p = [x for x in root.GetInputs().GetChildren()
if str(x.GetId()) == "presubdiv"][0]
tr = graph.BeginTransaction()
p.Remove()
tr.Commit()
This matters when a capsule was built by duplicating another. The clone inherits every scene port of its ancestor, including the ones its own circuit never reads; they sit in the Attribute Manager looking like functioning parameters and do nothing. Check before removing — a port with outgoing connections is still in use:
n = len(list(p.GetConnections(maxon.PORT_DIR.OUTPUT))) # 0 = safe to remove
Handing work to a person in the Node Editor
Division of labour · Recommended
Let the person create the nodes; do the configuring and wiring from script
The three things that go wrong on the script side all sit in creation: it is what crashes the application, it is what a group blocked by asset generation refuses, and it is the only way to get a variadic port. Configuration and connection are cheap, safe, repeatable and tedious — exactly what a script is good at, and exactly what is slow and error-prone by hand at 90 nodes.
So the productive split is a table handed over as a request — one row per node, with the name to give it, the node to pick, its category in the browser, and its configuration — and the wiring taken by script afterwards from an explicit source → destination list. Insist on the exact names in that table: they are what keeps your name → node map working, and a renamed node is a lookup that returns None and a wire that silently never happens.
Silent · Invisible name
The name you create a node with is not the name the person sees
When you call AddChild("MYNODE", …), MYNODE becomes the node's id and the net.maxon.node.base.name attribute stays empty — so the Node Editor shows the generic asset title ("Arithmetic") and the person cannot find your node by the name in your notes. It works the other way round too: a node the person names carries a meaningful base.name and an id that is pure hash. A lookup has to cover both:
def nm(node):
try:
v = node.GetValue("net.maxon.node.base.name")
if v: return str(v)
except Exception: passreturn str(node.GetId()).split("@")[0]
Two ways to close the gap. Write base.name yourself on the nodes you create, so your names become visible to the person. And to point at a node in conversation, select it rather than describing it:
H = maxon.GraphModelHelper
gm = graph.GetViewRoot().GetGraph() # not the ref GetGraph(obj) gave you
tr = graph.BeginTransaction()
H.DeselectAll(gm, maxon.NODE_KIND.NODE)
H.SelectNode(node)
tr.Commit()
Silent · Wrong node created
Display titles are not unique — ask by asset id, category and ports
Several distinct nodes carry near-identical titles in the browser: a stream-reducing summation in one category and an aggregating one with an operation port in another; a manual array builder and a loop collector. Ask for "Sum" and you have an even chance of getting the one without the port your design depends on — and the mistake surfaces much later, as a port that is not there, in a part of the graph you are no longer looking at.
Name three things in the request: the asset id, the category to find it under, and the ports it must expose. The last one is the check the person can perform without you.
Wording
Don't give a placeholder the name of a real node
An internal label like STEP in a wiring plan reads as an instruction to create the Step function node, and it will be created. Keep internal names distinguishable from node titles — a prefix, a suffix, anything that cannot be mistaken for something in the browser — and spell out the node to pick separately from the name to give it.
Timing
Never write into a graph someone is editing
A transaction committed while a person is mid-action in the Node Editor — creating a node, adding an input, dragging a wire — can take the application down. There is no lock and no way to ask. The only protection is to agree on turns explicitly: they tell you they are done, you work, you tell them you are done. Say so before starting, because the natural assumption is that a script and a mouse can share a graph.
No API · Plan around it
A script can build a graph but cannot place it
Node positions live in an undocumented attribute called widgetDataBlackBox: a DataDictionary holding another DataDictionary whose first entry is a Pair<vec<2,float32>, float32> — the position, and the node’s display width. Reading it works:
def pos(node):
wd = node.GetValue("widgetDataBlackBox")
if wd is None: return None# never placed
inner = [v for k, v in wd][0]
pair = [v for k, v in inner][0]
v = pair.GetFirst()
return (float(v.x), float(v.y))
Writing it does not. pair.Set() raises TypeError: issubclass() arg 1 must be a class for every argument form, maxon.Pair.Create() reports that it “could not find any Alloc function”, and the pair’s datatype id does not resolve from the registry. Replacing the inner entry with wd.Set(key, inner) appended a second entry instead of overwriting the first and left the node broken; only doc.DoUndo() recovered it. The Graph Framework exposes no layout or position interface at all — placement is a Node Editor concern.
What this means for a plan. Creating, wiring, naming, configuring and verifying are all yours. Arranging is not. Every node a script creates returns None from the reader above and draws at the same default spot, so a freshly built graph is one stack of nodes on a single pixel. Say this at the start of a project and schedule a manual arrangement pass with the person, rather than promising a readable graph.
Freeze · Out of memory
Automatic arrangement is priced by graph complexity, not by node count
The Node Editor’s automatic arrangement is the obvious answer to the previous entry, and on a script-built graph it is the command most likely to take Cinema 4D down. On a small graph it works. On a large one the call appears to freeze the application for several minutes and then ends in an out-of-memory warning; the same family of geometric commands includes MoveToGroup, recorded elsewhere here for the same reason.
Node count is not what predicts the failure. Two capsules from one project, arranged with the same command:
| Measured on the whole capsule | Capsule A | Capsule B |
|---|---|---|
| Nodes, including nested ones | 168 | 393 |
| Wires | 249 | 629 |
| Nodes inside feedback cycles | 0 | 142 |
| Layers, longest-path layering | 68 | 39 |
| Dummy nodes from long wires | 2002 | 1639 |
| Automatic arrangement | completes
|
out of memory
|
The capsule that fails has fewer layers and fewer long wires than the one that succeeds. What it has instead is two and a half times the wires and a 142-node feedback tangle, produced by its Loop Carried Value groups: in the flattened view a layout algorithm builds, those groups feed edges backwards. A layered layout has to break every cycle before it can assign layers at all, and choosing the smallest set of edges to reverse is NP-hard. That is where the memory goes.
The workaround that does work: arrange in blocks. Select twenty to thirty related nodes from script, ask the person to run the arrangement on that selection and park the result, then move to the next block. Blocks of about thirty nodes went through reliably in a 393-node capsule where the whole-graph call did not. Choose the blocks by a name prefix scheme decided before the graph is built — it costs nothing during construction and turns the arrangement pass into a short, mechanical sequence.
Two references · Freeze
Selecting nodes needs the other graph reference, inside a transaction
Two different objects are both called “the graph”. maxon.GraphDescription.GetGraph(obj) returns a NodesGraphModelRef; calling GetGraph() on that graph’s view root returns a GraphModelRef. Read-only helpers accept either, so the difference stays invisible until GraphModelHelper.DeselectAll, which needs the second one. Handed the first, it froze Cinema 4D rather than raising.
Selection is a modification as well: outside a transaction SelectNode raises RuntimeError: No current transaction for modification of NodesGraphModel.
g = maxon.GraphDescription.GetGraph(obj) # NodesGraphModelRef
root = g.GetViewRoot()
gm = root.GetGraph() # GraphModelRef — what DeselectAll needs
H = maxon.GraphModelHelper
tr = g.BeginTransaction()
H.DeselectAll(gm, maxon.NODE_KIND.NODE)
for n in nodes: H.SelectNode(n)
tr.Commit()
c4d.EventAdd()
Selecting is how you point at a node in conversation, and it is the lever for the block-wise arrangement above. To name a node in your message, read effectivename: it returns the name the person gave the node, and the asset title when they have not named one — in both cases exactly what the Node Editor displays. Note that a name you passed to AddChild is the node’s id, not its name, so it will not appear there.
Verifying the result
Wrong path · False negative
Where the deform cache lives depends on the kind of object
An editable polygon object deforms in place: obj.GetDeformCache(). A primitive or generator builds a cache first and the deformed geometry hangs underneath it: obj.GetCache().GetDeformCache(). Take the wrong branch and you get None, which makes a perfectly working deformer look inert — the worst kind of false negative, because it sends you rewriting a correct graph. Cover both:
def deformed(obj):
dc = obj.GetDeformCache()
# editable polygon objectif dc is None:
c = obj.GetCache()
# primitive / generatorif c is not None: dc = c.GetDeformCache() or c
return dc
For before/after per point, read the undeformed source from the same branch — obj.GetAllPoints() for an editable object, obj.GetCache().GetAllPoints() for a primitive. Mixing the branches gives you two point lists of different length or different order, and a difference that means nothing.
Stale data
Set a parameter and read the cache in two separate calls
Changing a value and then reading the deform cache within the same exec_python call can return partly stale geometry — some coordinates updated, others not, which reads exactly like a real bug. Calling ExecutePasses yourself does not reliably fix it. Write the parameter, return, then measure in the next call.
Method
Compare against a closed-form prediction, not against expectations
For anything geometric, compute the expected coordinate in Python and diff it against the measured one across many points. It is faster than reasoning about a long node chain, and it distinguishes "fixed" from "less visible" — a distinction that eyeballing the viewport cannot make. Where a result should have a structural property, test that property directly too: monotonicity along a row of points, a preserved length, a constant thickness, a symmetry.
False alarm · Costly
Validate the metric before you let it declare a regression
A derived measurement carries a bias of its own, and a plausible one hides it well. In this work a metric that summed per-element angles along a surface looked precise and drifted with the very quantity it was measuring; a reading taken at one setting was compared against a baseline taken at another, and the difference was reported as a regression that did not exist. Three rounds of bisection went into chasing it. A second, duller metric — one absolute coordinate against a closed-form prediction — had been correct the whole time.
Two habits prevent it. Run the metric first against a case whose answer you already know: an undeformed object, a parameter at zero, a setting where the closed form is trivial. If it does not return the known answer, it is not yet a metric. And never compare readings taken under different parameter settings — if you need a baseline, re-measure it under the settings in force now.
Prefer: A metric that reads a value directly over one that accumulates differences. Accumulation compounds any per-element bias into a number that grows with the input and therefore looks like a real trend.
Transient
If a parameter stops having an effect, drive the port from a constant before rebuilding
Observed once and worth knowing: a capsule parameter stopped reaching the graph at runtime. Toggling it changed nothing in the measurements, while replacing the same input with a constant inside the graph produced the full effect — so the circuit was right and the binding was not. Reloading the scene restored it.
The general move is to prove where the break is before assuming what it is. Substituting a constant for the parameter separates "the circuit computes the wrong thing" from "the value never arrives", and those two have nothing in common as repairs. Reload before concluding the graph is broken.
Method
Find dead nodes by walking backwards from the output
A breadth-first search from the node feeding the graph's output, following input connections, gives you the reachable set; everything else is dead. After any restructuring this reliably surfaces abandoned chains that still evaluate and still cost time.
One caveat: run it over a group's contents, not over the top-level graph. Context nodes such as context_externaltimeinput and context_notime sit at top level, appear unreachable by this test, and must not be removed.
Method
Look at the Node Editor, or ask whoever can
Cinema 4D displays error badges on misconfigured nodes and shows invalid enum values as extra dropdown entries. Neither reaches the API. When a graph behaves inexplicably and every read-back looks fine, a screenshot of the node in question is often the fastest diagnostic available.
False alarm · Cost a wrong fix
A metric that returns the same number before and after is measuring something else
To test whether a surface normal had flipped, one run compared each point’s distance from the model’s bounding-box centre: the sunk point should sit closer to the centre than the lifted one. It reported 13 of 32 periods inverted. The circuit was changed on the strength of it. It still reported 13 of 32 — at every parameter setting, before and after every edit.
The metric was measuring the shape of the model, not the circuit: on a head, the surface normal under the jaw does not point away from the centre, so those periods fail the test whatever the frame does. A result that stays constant across changes which should have moved it is the tell.
The honest metric compared the dip direction against the mesh’s own area-weighted vertex normal — and it had to compare two points at the same position, one lifted and one sunk, not two points half a period apart. That version separated the cases cleanly (9.5° when aligned, 26.4° when not) and showed the change had made things slightly worse, so it came back out.
Two habits: prove a metric on a case whose answer is already known before trusting it, and treat “no change” as a suspect result rather than as evidence. It is as often a broken measurement as a real null.
The check
Refine and watch: does the error fall to zero, or to a floor?
When a result is slightly wrong in some places and right in others, the useful question is not how large the error is but how it behaves under refinement. Raise the sampling density and measure the same quantity again. An error caused by too-coarse sampling falls towards zero. An error in the underlying geometry converges to a non-zero floor.
Subdivision open chain ring
1 3.879 2.909 # degrees
2 2.628 2.826
3 1.831 2.269
Same circuit, same measurement, two different causes, cleanly separated in one sweep. The open chain nearly halves — there, refinement was the answer. The ring stalls around 2.3°, and that residual sat exactly at four vertices of valence 5.
Which matches the theory: a Catmull-Clark limit surface is C² everywhere except at extraordinary vertices, where it is only C¹. Curvature has no defined value there, so there is nothing for a finer sampling to converge to. Knowing this before spending an afternoon on a smoothing scheme is worth the two minutes the sweep costs.
Working safely through exec_python
Crash
Keep each call small — the UI is frozen until it returns
Cinema 4D is unresponsive for the whole duration of a call, and there is a timeout on the far side. A single script that reads, creates and configures a hundred-plus nodes will hit it; so will a loop that merely reads every node's every port and every connection in a large graph. Split the work into calls that each do one small thing. Graph state, installed packages and variables in the scene all persist between calls, so there is no cost to it — and dump large listings in slices rather than all at once.
Practice
Detach the geometry output before restructuring, reattach to test
With the output disconnected the graph is not evaluated, so a half-rewired intermediate state cannot produce the runaway geometry or evaluation error that takes the application down. Detach, make the change over as many calls as you need, reattach, then look at the result. This single habit removes most of the crash risk from structural edits.
Pair it with saving after each milestone and keeping the previous file version rather than overwriting it, so recovering from a crash costs a reload instead of a rebuild.
Undo
Graph edits are not in the undo stack unless you put them there
exec_python takes an undo flag, but the flag alone records nothing — add_undo_change(node) must be called before each change. Without both, the edit cannot be undone and the next Ctrl-Z reverts whatever the user did before the call. If you make unrecorded changes, say so.
Setup
exec_python has to be re-enabled after each restart
Edit › Preferences › MCP Adapter › Security. Worth knowing before concluding that the connection is broken — and worth mentioning to the user rather than retrying, since nothing else in the toolset can run code.
Distilled
Rules of thumb
-
Read back every value you write. Especially mode and datatype ports. A silent accept is the default behaviour of this API, not an edge case.
-
Get the vocabulary from the port, not from intuition.
GetValue("fixedtype")for an enum port's legal set; a throwaway node's factory default for anything else. -
Never invent a validity indicator. A plausible-looking proxy that happens to be wrong is worse than no check at all, because it stops you looking. If you cannot read the truth, report the state as unknown.
-
Read the shipped default before leaving a type alone. An unset
Data Typeis a working state on the nodes that ship unset. Arithmetic and Scale are not among them — they default tofloatand collapse a vector silently. -
Measure geometry, do not infer it. Predict the coordinate in closed form, compare against the deform cache, and test structural properties directly.
-
Validate the metric before you trust its verdict. Run it against a case whose answer you know, and never compare readings taken at different settings. An unvalidated metric can invent a regression and cost more than the bug would have.
-
One small call at a time, one node per creation, output detached while restructuring. The graph persists between calls; the application does not survive a long one.
-
Let the person create, take the configuring and wiring yourself. Creation is where the crashes, the read-only groups and the variadic ports are. Wiring is where a script beats a mouse. Hand over a table of exact names and keep the turns explicit.
-
Build in the graph, convert to a capsule last, keep the source group. And remember there are then two copies of the circuit that do not track each other.
-
Detach before you set. A value written to a port that already has a wire is discarded without a word — including the switch index you were about to A/B test with.
-
Re-justify every correction when a circuit gains a second mode. A fix derived under one set of assumptions keeps producing plausible numbers in a mode where those assumptions no longer hold.
-
You can build the graph; you cannot arrange it. Positions are not writable from script, and whole-graph arrangement fails on the graphs a script produces. Name your nodes by block from the start and plan a manual pass, one block at a time.
-
Read the page, not a summary of it. A fetched document arrives summarised, and whatever the page does not say in text gets filled in plausibly. Confirm a name, an id or a number against the source that owns it.
-
Ask the scene before you look at it. A coordinate answers exactly what a picture answers approximately, in one call instead of several.
-
“Still working” is not “it failed”. A tool that reports progress is waiting to be collected, not asking to be restarted.
-
Convert on the way in. The API speaks in raw values — radians, fractions, document units — and only the interface speaks in the units printed beside a field.
Every behaviour described here was reproduced in a live Cinema 4D 2027 scene through the MCP server's exec_python and the maxon graph API, rather than taken from documentation. Node ids, port names and enum values are quoted as the API returns them.
