Hello @ferdinand,
first of all thank you for your endless effort and the detailed explanations and solutions you always come up with.
I heard what you said regarding c4d.EVMSG_CHANGE
being a possible but rather broad notification about changes in a scene. In theory I understand. In practice - well that's another story.
The problem with calling self.InitValues()
on every change in a document is that I can not select items in the Treeview any longer because that in itself is a change which calls c4d.EVMSG_CHANGE
. Which in turn invokes self.InitValues()
again. I guess this is what you meant when you were talking about screwing up selection states.
I actually read the posts you mentioned about c4d.MAXON_CREATOR_ID
prior to this thread and also about your fantastic example about "permanently" storing a collection of materials. I stole that part and implemented it in the code below to distinguish between changes.
My idea was to compare a stored collection with a newly created one. If they differ in length materials must have been created or removed. In addition to that I compare the dirty states of the collections. If they differ something in the data of a material must have changed which means a user changed some values. In both cases I update the Treeview. It works but to be honest neither do I know if this a viable solution nor if it is "the" right way to do it.
The problem is that every time the Treeview is updated the folding of items isn't respected. It makes sense because self.InitValues()
calls the code with the initial state of the node. Which in my case is True
. I struggle to find a solution as how to store the new state of the folding so that a potential update of the Treeview respects it.
@ferdinand I don't expect you or anyone to write complete solutions for me, but I'm more than thankful if someone can tell if my idea of comparing two collections is a good or maybe "the" way to go. If someone can also shed some light on the part folding part that would be awesome.
Thanks for reading this rather long babbling of mine.
Cheers,
Sebastian
#More information http://www.plugincafe.com/forum/forum_posts.asp?TID=14102&PID=56287#56287
import c4d
import weakref
# Be sure to use a unique ID obtained from http://www.plugincafe.com/.
PLUGIN_ID = 1000010 # TEST ID ONLY
# TreeView Column IDs.
ID_CHECKBOX = 1
ID_TYPE = 2
ID_NAME = 3
class MaterialCollection(object):
"""Handles a collection of materials from a singular document for long-term referencing.
Will reestablish material references when possible and also handles a document reference.
"""
def __init__(self, doc, collection):
"""Initializes the collection with a list of materials.
"""
if not isinstance(collection, list):
raise TypeError("{collection = }".format(collection=collection))
if not isinstance(doc, c4d.documents.BaseDocument):
raise TypeError("{doc = }".format(doc=doc))
if not doc.IsAlive():
raise RuntimeError("{doc = } is not alive.".format(doc=doc))
# The list of materials, the list of markers for them, their associated document, and its
# marker.
self._materialCollection = []
self._materialMarkerCollection = []
self._materialDirtyCount = []
self._doc = doc
self._docMarker = MaterialCollection.GetUUID(doc)
# Validate all materials and get their markers, we must do that before the reference is dead.
for material in collection:
if not isinstance(material, c4d.BaseMaterial) or not material.IsAlive():
raise RuntimeError("{material} is not alive or not a BaseMaterial.".format(material=material))
if material.GetDocument() is None:
raise RuntimeError("Dangling material cannot be managed.")
if material.GetDocument() != doc:
raise RuntimeError("Material is not part of passed reference document.")
# Append the material and its UUID marker to the internal lists.
self._materialCollection.append(material)
self._materialMarkerCollection.append(MaterialCollection.GetUUID(material))
self._materialDirtyCount.append(material.GetDirty(c4d.DIRTYFLAGS_DATA))
def __repr__(self):
return "{}({})".format(self.__class__.__name__, len(self))
def __len__(self):
"""Returns the number of managed materials.
"""
return len(self._materialCollection)
def __getitem__(self, index):
"""Returns the material at #index.
"""
if len(self._materialCollection) - 1 < index:
raise IndexError("The index '{index}' is out of bounds.".format(index=index))
# The item is still alive, we can return it directly.
item = self._materialCollection[index]
if item.IsAlive():
return item
# --- The item is not alive anymore, try to find its new instance. -------------------------
# Get the marker identifying the material at #index.
materialMarker = self._materialMarkerCollection[index]
# Iterate over all materials in #_doc to find one with a matching marker. We now call here
# the property #Document instead of _doc, to also allow for reestablishing the document
# reference.
for material in self.Document.GetMaterials():
otherMarker = MaterialCollection.GetUUID(material)
# We found a match, update the internal data, and return the material.
if materialMarker == otherMarker:
self._materialCollection[index] = material
return material
# There is no material with this marker anymore.
raise RuntimeError(
"The material at {index} is not valid anymore and cannot be re-established.".format(inde=index))
def __iter__(self):
"""Iterates over all materials in the collection.
"""
for index in range(len(self._materialCollection)):
yield self[index]
@property
def Document(self):
"""Returns the managed document reference.
Will try to reestablish a document reference within the open documents when necessary.
"""
# Return the still valid reference.
if self._doc.IsAlive():
return self._doc
# Search all open documents for one with a UUID that matches self._docMarker.
doc = c4d.documents.GetFirstDocument()
while doc:
docMarker = MaterialCollection.GetUUID(doc)
if docMarker == self._docMarker:
self._doc = doc
return doc
doc = doc.GetNext()
# We could also search and load documents from #c4d.documents.GetRecentDocumentsList() here
# but I did not do that, as it seems unlikely that you want to do that.
raise RuntimeError(
"A reference to the document UUID '{self._docMarker}' cannot be reestablished. "
"The document has probably been closed.".format(self._docMarker))
@property
def Materials(self):
"""Returns an immutable collection of the internally managed materials.
Might contain dangling material references, i.e., "dead" materials.
"""
return tuple(self._materialCollection)
@property
def Markers(self):
"""Returns an immutable collection of the internally managed material markers.
"""
return tuple(self._materialMarkerCollection)
@property
def Dirty(self):
"""Returns an immutable collection of the internally managed material dirty state.
"""
return tuple(self._materialDirtyCount)
@staticmethod
def GetUUID(atom):
"""Retrieves the UUID of an atom as a bytes object.
Args:
item (c4d.C4DAtom): The atom to get the UUID hash for.
Raises:
RuntimeError: On type assertion failure.
Returns:
bytes: The UUID of #atom.
"""
if not isinstance(atom, c4d.C4DAtom):
raise TypeError(atom)
# Get the MAXON_CREATOR_ID marker uniquely identifying the atom.
uuid = atom.FindUniqueID(c4d.MAXON_CREATOR_ID)
#if not isinstance(uuid, memoryview):
# raise RuntimeError("Illegal non-marked atom: {atom}".format(atom=atom))
return bytes(uuid)
def iter_shaders(node):
"""Yields all descendants of ``node`` in a truly iterative fashion.
The passed node itself is yielded as the first node and the node graph is
being traversed in depth first fashion.
This will not fail even on the most complex scenes due to truly
hierarchical iteration. The lookup table to do this, is here solved with
a dictionary which yields favorable look-up times in especially larger
scenes but results in a more convoluted code. The look-up could
also be solved with a list and then searching in the form ``if node in
lookupTable`` in it, resulting in cleaner code but worse runtime metrics
due to the difference in lookup times between list and dict collections.
"""
if not node:
return
# The lookup dictionary and a terminal node which is required due to the
# fact that this is truly iterative, and we otherwise would leak into the
# ancestors and siblings of the input node. The terminal node could be
# set to a different node, for example ``node.GetUp()`` to also include
# siblings of the passed in node.
visisted = {}
terminator = node
while node:
#if isinstance(node, c4d.Material) and not node.GetFirstShader():
# break
if isinstance(node, c4d.Material) and node.GetFirstShader():
node = node.GetFirstShader()
# C4DAtom is not natively hashable, i.e., cannot be stored as a key
# in a dict, so we have to hash them by their unique id.
node_uuid = node.FindUniqueID(c4d.MAXON_CREATOR_ID)
if not node_uuid:
raise RuntimeError("Could not retrieve UUID for {}.".format(node))
# Yield the node when it has not been encountered before.
if not visisted.get(bytes(node_uuid)):
yield node
visisted[bytes(node_uuid)] = True
# Attempt to get the first child of the node and hash it.
child = node.GetDown()
if child:
child_uuid = child.FindUniqueID(c4d.MAXON_CREATOR_ID)
if not child_uuid:
raise RuntimeError("Could not retrieve UUID for {}.".format(child))
# Walk the graph in a depth first fashion.
if child and not visisted.get(bytes(child_uuid)):
node = child
elif node == terminator:
break
elif node.GetNext():
node = node.GetNext()
else:
node = node.GetUp()
def walk_shadertree(mat):
for shader in iter_shaders(mat):
parent = shader.GetUp() if shader.GetUp() else mat
pred = shader.GetPred() if shader.GetPred() else None
nxt = shader.GetNext() if shader.GetNext() else None
children = shader.GetChildren() if shader.GetDown() else []
yield {
"node": shader.GetName(),
"parent": parent.GetName() if parent else parent,
"pred": pred.GetName() if pred else pred,
"nxt": nxt.GetName() if nxt else nxt,
"children": [child.GetName() for child in children]
}
def NodeIterator(lst):
for parent in lst:
yield parent
for child in NodeIterator(parent.GetChildren()):
yield child
class Node(object):
"""Class which represent a an item in our Tree."""
def __init__(self, obj):
self.obj = obj
self.children = []
self._selected = False
self._open = True
self._parent = None
def __repr__(self):
return str(self)
def __str__(self):
return self.obj.GetName()
@property
def IsSelected(self):
return self._selected
def Select(self):
self._selected = True
self.obj.SetBit(c4d.BIT_ACTIVE)
def Deselect(self):
self._selected = False
self.obj.DelBit(c4d.BIT_ACTIVE)
@property
def IsOpen(self):
return self._open
def Open(self):
self._open = True
self.obj.DelBit(c4d.BIT_OFOLD)
def Close(self):
self._open = False
self.obj.SetBit(c4d.BIT_OFOLD)
def AddChild(self, obj):
obj._parent = weakref.ref(self)
self.children.append(obj)
def GetChildren(self):
return self.children
def GetParent(self):
if self._parent:
return self._parent()
return None
def GetName(self):
return self.obj.GetName()
def GetTypeName(self):
return self.obj.GetTypeName()
def SetName(self, name):
self.obj.SetName(name)
def recurse_shader(shader, obj):
# Not really happy with this algorithm.
# It works but the recursion is bothering me.
if shader.GetDown():
shader = shader.GetDown()
while shader:
child = Node(shader)
obj.AddChild(child)
if shader.GetDown():
recurse_shader(shader, child)
shader = shader.GetNext()
class ShaderBrowser(c4d.gui.TreeViewFunctions):
def __init__(self, dlg):
self._dlg = weakref.ref(dlg)
def Load(self):
doc = c4d.documents.GetActiveDocument()
self.nodes = []
self.collection = MaterialCollection(doc, doc.GetMaterials())
for material in self.collection:
shader = material.GetFirstShader()
root = Node(material)
while shader:
child = Node(shader)
root.AddChild(child)
recurse_shader(shader, child)
shader = shader.GetNext()
self.nodes.append(root)
def IsResizeColAllowed(self, root, userdata, lColID):
if lColID == ID_NAME:
return True
return False
def IsTristate(self, root, userdata):
return False
def GetColumnWidth(self, root, userdata, obj, col, area):
"""Measures the width of cells.
Although this function is called #GetColumnWidth and has a #col, it is
not only executed by column but by cell. So, when there is a column
with items requiring the width 5, 10, and 15, then there is no need
for evaluating all items. Each item can return its ideal width and
Cinema 4D will then pick the largest value.
Args:
root (any): The root node of the tree view.
userdata (any): The user data of the tree view.
obj (any): The item for the current cell.
col (int): The index of the column #obj is contained in.
area (GeUserArea): An already initialized GeUserArea to measure
the width of strings.
Returns:
TYPE: Description
"""
# The default width of a column is 80 units.
width = 80
# Replace the width with the text width. area is a prepopulated
# user area which has already setup all the font stuff, we can
# measure right away.
if col == ID_TYPE:
ICON_SIZE = 16
TEXT_SPACER = 6
return area.DrawGetTextWidth(obj.GetTypeName()) + ICON_SIZE + TEXT_SPACER + 5
if col == ID_NAME:
return area.DrawGetTextWidth(obj.GetName()) + 5
return width
def IsMoveColAllowed(self, root, userdata, lColID):
# The user is allowed to move all columns.
# TREEVIEW_MOVE_COLUMN must be set in the container of AddCustomGui.
return True
def GetFirst(self, root, userdata):
"""
Return the first element in the hierarchy, or None if there is no element.
"""
return None if not self.nodes else self.nodes[0]
def GetDown(self, root, userdata, obj):
"""
Return a child of a node, since we only want a list, we return None everytime
"""
children = obj.GetChildren()
if children:
return children[0]
return None
def GetNext(self, root, userdata, obj):
"""
Returns the next Object to display after arg:'obj'
"""
parent = obj.GetParent()
nodes = parent.GetChildren() if parent is not None else self.nodes
indx = nodes.index(obj)
nxt = indx + 1
return nodes[nxt] if nxt < len(nodes) else None
def GetPred(self, root, userdata, obj):
"""
Returns the previous Object to display before arg:'obj'
"""
parent = obj.GetParent()
nodes = parent.GetChildren() if parent is not None else self.nodes
indx = nodes.index(obj)
prev = indx - 1
return nodes[prev] if 0 <= prev < len(nodes) else None
def GetId(self, root, userdata, obj):
"""
Return a unique ID for the element in the TreeView.
"""
return hash(obj)
def Select(self, root, userdata, obj, mode):
"""
Called when the user selects an element.
"""
doc = c4d.documents.GetActiveDocument()
doc.StartUndo()
if mode == c4d.SELECTION_NEW:
for node in NodeIterator(self.nodes):
node.Deselect()
obj.Select()
elif mode == c4d.SELECTION_ADD:
obj.Select()
elif mode == c4d.SELECTION_SUB:
obj.Deselect()
doc.EndUndo()
c4d.EventAdd()
def IsSelected(self, root, userdata, obj):
"""
Returns: True if *obj* is selected, False if not.
"""
return obj.IsSelected
def SetCheck(self, root, userdata, obj, column, checked, msg):
"""
Called when the user clicks on a checkbox for an object in a
`c4d.LV_CHECKBOX` column.
"""
if checked:
obj.Select()
else:
obj.Deselect()
def IsChecked(self, root, userdata, obj, column):
"""
Returns: (int): Status of the checkbox in the specified *column* for *obj*.
"""
if obj.IsSelected:
return c4d.LV_CHECKBOX_CHECKED | c4d.LV_CHECKBOX_ENABLED
else:
return c4d.LV_CHECKBOX_ENABLED
def IsOpened(self, root, userdata, obj):
"""
Returns: (bool): Status If it's opened = True (folded) or closed = False.
"""
return obj.IsOpen
def Open(self, root, userdata, obj, onoff):
"""
Called when the user clicks on a folding state of an object to display/hide its children
"""
doc = obj.obj.GetDocument()
doc.StartUndo()
doc.AddUndo(c4d.UNDOTYPE_CHANGE_SMALL, obj.obj)
if onoff:
obj.Open()
else:
obj.Close()
doc.EndUndo()
def GetName(self, root, userdata, obj):
"""
Returns the name to display for arg:'obj', only called for column of type LV_TREE
"""
return str(obj)
def DrawCell(self, root, userdata, obj, col, drawinfo, bgColor):
"""
Draw into a Cell, only called for column of type LV_USER
"""
if col == ID_TYPE:
ICON_SIZE = drawinfo["height"]
TEXT_SPACER = 6
icon = obj.obj.GetIcon()
drawinfo["frame"].DrawBitmap(
icon["bmp"], drawinfo["xpos"], drawinfo["ypos"],
16, 16, icon["x"], icon["y"], icon["w"], icon["h"], c4d.BMP_ALLOWALPHA)
name = obj.GetTypeName()
geUserArea = drawinfo["frame"]
w = geUserArea.DrawGetTextWidth(name)
h = geUserArea.DrawGetFontHeight()
xpos = drawinfo["xpos"] + ICON_SIZE + TEXT_SPACER
ypos = drawinfo["ypos"] + drawinfo["height"]
drawinfo["frame"].DrawText(name, int(xpos), int(ypos - h * 1.1))
def DoubleClick(self, root, userdata, obj, col, mouseinfo):
"""
Called when the user double-clicks on an entry in the TreeView.
Returns:
(bool): True if the double-click was handled, False if the
default action should kick in. The default action will invoke
the rename procedure for the object, causing `SetName()` to be
called.
"""
if col == ID_NAME:
return False
if col == ID_TYPE:
mode = (c4d.ACTIVEOBJECTMODE_SHADER
if isinstance(obj.obj, c4d.BaseShader) else c4d.ACTIVEOBJECTMODE_MATERIAL
)
c4d.gui.ActiveObjectManager_SetObject(
id=mode,
op=obj.obj,
flags=c4d.ACTIVEOBJECTMANAGER_SETOBJECTS_OPEN,
activepage=c4d.DescID()
)
return True
return True
def SetName(self, root, userdata, obj, name):
"""
Called when the user renames the element. `DoubleClick()` must return
False for this to work.
"""
doc = c4d.documents.GetActiveDocument()
doc.StartUndo()
doc.AddUndo(c4d.UNDOTYPE_CHANGE_SMALL, obj.obj)
obj.SetName(name)
doc.EndUndo()
c4d.EventAdd()
def DeletePressed(self, root, userdata):
"Called when a delete event is received."
doc = c4d.documents.GetActiveDocument()
doc.StartUndo()
for node in reversed(list(NodeIterator(self.nodes))):
if not node.IsSelected:
continue
parent = node.GetParent()
nodes = parent.GetChildren() if parent is not None else self.nodes
nodes.remove(node)
doc.AddUndo(c4d.UNDOTYPE_DELETE, node.obj)
node.obj.Remove()
doc.EndUndo()
c4d.EventAdd()
class ShaderBrowserDialog(c4d.gui.GeDialog):
def CreateLayout(self):
self.SetTitle("Shader Browser")
customgui = c4d.BaseContainer()
customgui.SetBool(c4d.TREEVIEW_BORDER, c4d.BORDER_THIN_IN)
customgui.SetBool(c4d.TREEVIEW_HAS_HEADER, True) # True if the tree view may have a header line.
customgui.SetBool(c4d.TREEVIEW_HIDE_LINES, False) # True if no lines should be drawn.
customgui.SetBool(c4d.TREEVIEW_MOVE_COLUMN, False) # True if the user can move the columns.
customgui.SetBool(c4d.TREEVIEW_RESIZE_HEADER, True) # True if the column width can be changed by the user.
customgui.SetBool(c4d.TREEVIEW_FIXED_LAYOUT, True) # True if all lines have the same height.
customgui.SetBool(c4d.TREEVIEW_ALTERNATE_BG, True) # Alternate background per line.
customgui.SetBool(c4d.TREEVIEW_CURSORKEYS, True) # True if cursor keys should be processed.
customgui.SetBool(c4d.TREEVIEW_NOENTERRENAME, False) # Suppresses the rename popup when the user presses enter.
self._treegui = self.AddCustomGui(1000, c4d.CUSTOMGUI_TREEVIEW, "", c4d.BFH_SCALEFIT | c4d.BFV_SCALEFIT, 300, 150, customgui)
return True
def InitValues(self):
layout = c4d.BaseContainer()
layout.SetLong(ID_CHECKBOX, c4d.LV_CHECKBOX)
layout.SetLong(ID_NAME, c4d.LV_TREE)
layout.SetLong(ID_TYPE, c4d.LV_USER)
self._treegui.SetLayout(3, layout)
self._treegui.SetHeaderText(ID_CHECKBOX, "Check")
self._treegui.SetHeaderText(ID_TYPE, "Type")
self._treegui.SetHeaderText(ID_NAME, "Name")
self._browser = ShaderBrowser(self)
self._browser.Load()
self._treegui.SetRoot(self._treegui, self._browser, None)
self._treegui.Refresh()
return True
def CoreMessage(self, id, msg):
if id == c4d.EVMSG_CHANGE:
doc = c4d.documents.GetActiveDocument()
materials = doc.GetMaterials()
collection = MaterialCollection(doc, materials)
nodes = self._browser.nodes[:]
node_uuids = {bytes(node.obj.FindUniqueID(c4d.MAXON_CREATOR_ID)): node for node in nodes}
# Compare the stored length of Material Collection to a newly allocated.
# If they dont match a material must have been added or removed.
# Better update the Treeview.
if len(self._browser.collection.Markers) != len(collection.Markers):
print("Material must have been added or removed.")
self.InitValues()
self._treegui.Refresh()
# Compare the dirty states of the stored Material Collection with a newly allocated.
# If the dirts states dont match something in the container of at least one material
# must have been changed. Better update the Treeview.
olddirty = self._browser.collection.Dirty
for dirty in collection.Dirty:
if dirty not in olddirty:
print("something must have changed")
self.InitValues()
# self._browser.Load()
self._treegui.Refresh()
break
return c4d.gui.GeDialog.CoreMessage(self, id, msg)
def main():
global dialog
dialog = ShaderBrowserDialog()
dialog.Open(c4d.DLG_TYPE_ASYNC, defaulth=600, defaultw=600)
if __name__ == "__main__":
main()