# This file is part of Korman. # # Korman is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # Korman is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Korman. If not, see . import abc import bpy from bpy.props import * from PyHSPlasma import * import time from ..exporter import ExportError class PlasmaNodeBase: def generate_notify_msg(self, exporter, so, socket_id, idname=None): notify = plNotifyMsg() notify.BCastFlags = (plMessage.kNetPropagate | plMessage.kLocalPropagate) for i in self.find_outputs(socket_id, idname): key = i.get_key(exporter, so) if key is None: exporter.report.warn(" '{}' Node '{}' doesn't expose a key. It won't be triggered by '{}'!".format(i.bl_idname, i.name, self.name), indent=3) elif isinstance(key, tuple): for i in key: notify.addReceiver(key) else: notify.addReceiver(key) return notify def get_key(self, exporter, so): return None def get_key_name(self, single, suffix=None, bl=None, so=None): assert bl or so if single: name = bl.name if bl is not None else so.key.name if suffix: working_name = "{}_{}_{}_{}".format(name, self.id_data.name, self.name, suffix) else: working_name = "{}_{}_{}".format(name, self.id_data.name, self.name) else: if suffix: working_name = "{}_{}_{}".format(self.id_data.name, self.name, suffix) else: working_name = "{}_{}".format(self.id_data.name, self.name) return working_name def draw_label(self): if hasattr(self, "pl_label_attr") and self.hide: return str(getattr(self, self.pl_label_attrib, self.bl_label)) return self.bl_label def export(self, exporter, bo, so): pass @property def export_once(self): """This node can only be exported once because it is a targeted plSingleModifier""" return False def _find_create_object(self, pClass, exporter, **kwargs): """Finds or creates an hsKeyedObject specific to this node.""" assert "name" not in kwargs kwargs["name"] = self.get_key_name(issubclass(pClass, (plObjInterface, plSingleModifier)), kwargs.pop("suffix", ""), kwargs.get("bl"), kwargs.get("so")) return exporter.mgr.find_create_object(pClass, **kwargs) def _find_create_key(self, pClass, exporter, **kwargs): """Finds or creates a plKey specific to this node.""" assert "name" not in kwargs kwargs["name"] = self.get_key_name(issubclass(pClass, (plObjInterface, plSingleModifier)), kwargs.pop("suffix", ""), kwargs.get("bl"), kwargs.get("so")) return exporter.mgr.find_create_key(pClass, **kwargs) def find_input(self, key, idname=None): for i in self.inputs: if i.alias == key: if i.links: node = i.links[0].from_node if idname is not None and idname != node.bl_idname: return None return node else: return None raise KeyError(key) def find_inputs(self, key, idname=None): for i in self.inputs: if i.alias == key: if i.links: node = i.links[0].from_node if idname is None or idname == node.bl_idname: yield node def find_input_socket(self, key, spawn_empty=False): # In the case that this socket will be used to make new input linkage, # we might want to allow the spawning of a new input socket... :) # This will only be done if the node's socket definitions allow it. options = self._socket_defs[0].get(key, {}) spawn_empty = spawn_empty and options.get("spawn_empty", False) for i in self.inputs: if i.alias == key: if spawn_empty and i.is_linked: continue return i if spawn_empty: return self._spawn_socket(key, options, self.inputs) else: raise KeyError(key) def find_input_sockets(self, key, idname=None): for i in self.inputs: if i.alias == key: if idname is None: yield i elif i.links: node = i.links[0].from_node if idname == node.bl_idname: yield i def find_output(self, key, idname=None): for i in self.outputs: if i.alias == key: if i.links: node = i.links[0].to_node if idname is not None and idname != node.bl_idname: return None return node else: return None raise KeyError(key) def find_outputs(self, key, idname=None): for i in self.outputs: if i.alias == key: for j in i.links: node = j.to_node if idname is not None and idname != node.bl_idname: continue yield node def find_output_socket(self, key, spawn_empty=False): # In the case that this socket will be used to make new output linkage, # we might want to allow the spawning of a new output socket... :) # This will only be done if the node's socket definitions allow it. options = self._socket_defs[1].get(key, {}) spawn_empty = spawn_empty and options.get("spawn_empty", False) for i in self.outputs: if i.alias == key: if spawn_empty and i.is_linked: continue return i if spawn_empty: return self._spawn_socket(key, options, self.outputs) raise KeyError(key) def find_output_sockets(self, key, idname=None): for i in self.outputs: if i.alias == key: if idname is None: yield i elif i.links: node = i.links[0].from_node if idname == node.bl_idname: yield i def generate_valid_links_for(self, context, socket, is_output): """Generates valid node sockets that can be linked to a specific socket on this node.""" from .node_deprecated import PlasmaDeprecatedNode source_socket_props = getattr(self.__class__, "output_sockets", {}) if is_output else \ getattr(self.__class__, "input_sockets", {}) source_socket_def = source_socket_props.get(socket.alias, {}) valid_dest_sockets = source_socket_def.get("valid_link_sockets") valid_dest_nodes = source_socket_def.get("valid_link_nodes") for dest_node_cls in bpy.types.Node.__subclasses__(): if not issubclass(dest_node_cls, PlasmaNodeBase) or issubclass(dest_node_cls, PlasmaDeprecatedNode): continue # Korman standard node socket definitions socket_defs = getattr(dest_node_cls, "input_sockets", {}) if is_output else \ getattr(dest_node_cls, "output_sockets", {}) for socket_name, socket_def in socket_defs.items(): if socket_def.get("can_link") is False: continue if socket_def.get("hidden") is True: continue # Can this socket link to the socket_def on the destination node? if valid_dest_nodes is not None and dest_node_cls.bl_idname not in valid_dest_nodes: continue if valid_dest_sockets is not None and socket_def["type"] not in valid_dest_sockets: continue # Can the socket_def on the destination node link to this socket? valid_source_nodes = socket_def.get("valid_link_nodes") valid_source_sockets = socket_def.get("valid_link_sockets") if valid_source_nodes is not None and self.bl_idname not in valid_source_nodes: continue if valid_source_sockets is not None and socket.bl_idname not in valid_source_sockets: continue if valid_source_sockets is None and valid_source_nodes is None: if socket.bl_idname != socket_def["type"]: continue # Can we even add the node? poll_add = getattr(dest_node_cls, "poll_add", None) if poll_add is not None and not poll_add(context): continue yield { "node_idname": dest_node_cls.bl_idname, "node_text": dest_node_cls.bl_label, "socket_name": socket_name, "socket_text": socket_def["text"] } # Some node types (eg Python) may auto-generate their own sockets, so we ask them now. for i in dest_node_cls.generate_valid_links_to(context, socket, is_output): yield i @classmethod def generate_valid_links_to(cls, context, socket, is_output): """Generates valid sockets on this node type that can be linked to a specific node's socket.""" return [] def harvest_actors(self): return set() def link_input(self, node, out_key, in_key): """Links a given Node's output socket to a given input socket on this Node""" if isinstance(in_key, str): in_socket = self.find_input_socket(in_key, spawn_empty=True) else: in_socket = in_key if isinstance(out_key, str): out_socket = node.find_output_socket(out_key, spawn_empty=True) else: out_socket = out_key link = self.id_data.links.new(in_socket, out_socket) def link_output(self, node, out_key, in_key): """Links a given Node's input socket to a given output socket on this Node""" if isinstance(in_key, str): in_socket = node.find_input_socket(in_key, spawn_empty=True) else: in_socket = in_key if isinstance(out_key, str): out_socket = self.find_output_socket(out_key, spawn_empty=True) else: out_socket = out_key link = self.id_data.links.new(in_socket, out_socket) @property def node_path(self): """Returns an absolute path to this Node. Needed because repr() uses an elipsis...""" return "{}.{}".format(repr(self.id_data), self.path_from_id()) def previously_exported(self, exporter): return self.name in exporter.exported_nodes[self.id_data.name] @classmethod def poll(cls, context): return (context.bl_idname == "PlasmaNodeTree") def raise_error(self, message): final = "Plasma Node Tree '{}' Node '{}': {}".format(self.id_data.name, self.name, message) raise ExportError(final) @property def requires_actor(self): return False @property def _socket_defs(self): return (getattr(self.__class__, "input_sockets", {}), getattr(self.__class__, "output_sockets", {})) def _spawn_socket(self, key, options, sockets): socket = sockets.new(options["type"], options["text"], key) link_limit = options.get("link_limit", None) if link_limit is not None: socket.link_limit = link_limit socket.hide = options.get("hidden", False) socket.hide_value = options.get("hidden", False) return socket def _tattle(self, socket, link, reason): direction = "->" if socket.is_output else "<-" print("Removing {} {} {} {}".format(link.from_node.name, direction, link.to_node.name, reason)) def unlink_outputs(self, alias, reason=None): links = self.id_data.links from_socket = next((i for i in self.outputs if i.alias == alias)) i = 0 while i < len(from_socket.links): link = from_socket.links[i] self._tattle(from_socket, link, reason if reason else "socket unlinked") links.remove(link) def update(self): """Ensures that sockets are linked appropriately and there are enough inputs""" input_defs, output_defs = self._socket_defs for defs, sockets in ((input_defs, self.inputs), (output_defs, self.outputs)): self._update_extant_sockets(defs, sockets) self._update_init_sockets(defs, sockets) def _update_init_sockets(self, defs, sockets): # Create any missing sockets and spawn any required empties. for alias, options in defs.items(): working_sockets = [(i, socket) for i, socket in enumerate(sockets) if socket.alias == alias] if not working_sockets: self._spawn_socket(alias, options, sockets) elif options.get("spawn_empty", False): last_socket_id = next(reversed(working_sockets))[0] for working_id, working_socket in working_sockets: if working_id == last_socket_id and working_socket.is_linked: new_socket_id = len(sockets) new_socket = self._spawn_socket(alias, options, sockets) desired_id = last_socket_id + 1 if new_socket_id != desired_id: sockets.move(new_socket_id, desired_id) elif working_id < last_socket_id and not working_socket.is_linked: # Indices do not update until after the update() function finishes, so # no need to decrement last_socket_id sockets.remove(working_socket) def _update_extant_sockets(self, defs, sockets): # Manually enumerate the sockets that are present for their presence and for the # validity of their links. Can't use a for because we will overrun and crash Blender. i = 0 while i < len(sockets): socket = sockets[i] node = socket.node options = defs.get(socket.alias, None) if options is None or socket.bl_idname != options["type"]: sockets.remove(socket) continue # Make sure the socket info is up to date socket.name = options["text"] link_limit = options.get("link_limit", None) if link_limit is not None: socket.link_limit = link_limit socket.hide = options.get("hidden", False) socket.hide_value = options.get("hidden", False) # Make sure the link is good allowed_sockets = options.get("valid_link_sockets", None) allowed_nodes = options.get("valid_link_nodes", None) # The socket may decide it doesn't want anyone linked to it. can_link_attr = options.get("can_link", None) if can_link_attr is not None: can_link = getattr(node, can_link_attr) socket.enabled = can_link if not can_link: for link in socket.links: try: self._tattle(socket, link, "(socket refused link)") self.id_data.links.remove(link) except RuntimeError: # was already removed by someone else pass # Helpful default... If neither are set, require the link to be to the same socket type if allowed_nodes is None and allowed_sockets is None: allowed_sockets = frozenset((options["type"],)) if allowed_sockets or allowed_nodes: for link in socket.links: if allowed_nodes: to_from_node = link.to_node if socket.is_output else link.from_node if to_from_node.bl_idname not in allowed_nodes: try: self._tattle(socket, link, "(bad node)") self.id_data.links.remove(link) except RuntimeError: # was already removed by someone else pass continue if allowed_sockets: to_from_socket = link.to_socket if socket.is_output else link.from_socket if to_from_socket is None or to_from_socket.bl_idname not in allowed_sockets: try: self._tattle(socket, link, "(bad socket)") self.id_data.links.remove(link) except RuntimeError: # was already removed by someone else pass continue i += 1 def _whine(self, msg, *args): if args: msg = msg.format(*args) print("'{}' Node '{}': Whinging about {}".format(self.bl_idname, self.name, msg)) class PlasmaTreeOutputNodeBase(PlasmaNodeBase): """Represents the final output of a node tree""" @classmethod def poll_add(cls, context): # There can only be one of these nodes per tree, so we will only allow this to be # added if no other output nodes are found. return not any((isinstance(node, cls) for node in context.space_data.node_tree.nodes)) class PlasmaNodeSocketBase: @property def alias(self): """Blender appends .000 stuff if it's a dupe. We don't care about dupe identifiers...""" ident = self.identifier if ident.find('.') == -1: return ident return ident.rsplit('.', 1)[0] def draw(self, context, layout, node, text): if not self.is_output: self.draw_add_operator(context, layout, node) self.draw_content(context, layout, node, text) if self.is_output: self.draw_add_operator(context, layout, node) def draw_add_operator(self, context, layout, node): row = layout.row() row.enabled = self.has_possible_links row.operator_context = "INVOKE_DEFAULT" add_op = row.operator("node.plasma_create_link_node", text="", icon="ZOOMIN") add_op.node_name = node.name add_op.sock_ident = self.identifier add_op.is_output = self.is_output def draw_color(self, context, node): # It's so tempting to just do RGB sometimes... Let's be nice. if len(self.bl_color) == 3: return tuple(self.bl_color[0], self.bl_color[1], self.bl_color[2], 1.0) return self.bl_color def draw_content(self, context, layout, node, text): layout.label(text) def _has_possible_links(self): tval = time.monotonic() if (tval - self.possible_links_update_time) > 2: # Danger: hax! # We don't want to unleash errbody at exactly the same time. The good news is that # ***CURRENTLY*** the only way for the result to change is for a new PY file to be # loaded. So, only check in that case. hval = str(hash((i for i in bpy.data.texts))) if hval != self.possible_links_texts_hash: self.has_possible_links_value = any(self.node.generate_valid_links_for(bpy.context, self, self.is_output)) self.possible_links_texts_hash = hval self.possible_links_update_time = tval return self.has_possible_links_value @property def is_used(self): return bool(self.links) @classmethod def register(cls): cls.has_possible_links = BoolProperty(options={"HIDDEN", "SKIP_SAVE"}, get=cls._has_possible_links) cls.has_possible_links_value = BoolProperty(options={"HIDDEN", "SKIP_SAVE"}) cls.possible_links_update_time = FloatProperty(options={"HIDDEN", "SKIP_SAVE"}) cls.possible_links_texts_hash = StringProperty(options={"HIDDEN", "SKIP_SAVE"}) class PlasmaNodeSocketInputGeneral(PlasmaNodeSocketBase, bpy.types.NodeSocket): """A general input socket that will steal the output's color""" def draw_color(self, context, node): if self.is_linked: return self.links[0].from_socket.draw_color(context, node) else: return (0.0, 0.0, 0.0, 0.0) class PlasmaNodeTree(bpy.types.NodeTree): bl_idname = "PlasmaNodeTree" bl_label = "Plasma" bl_icon = "NODETREE" def export(self, exporter, bo, so): exported_nodes = exporter.exported_nodes.setdefault(self.name, set()) for node in self.nodes: if not (node.export_once and node.previously_exported(exporter)): node.export(exporter, bo, so) exported_nodes.add(node.name) def find_output(self, idname): for node in self.nodes: if node.bl_idname == idname: return node return None def harvest_actors(self): actors = set() for node in self.nodes: harvest_method = getattr(node, "harvest_actors", None) if harvest_method is not None: actors.update(harvest_method()) elif not isinstance(node, PlasmaNodeBase): raise ExportError("Plasma Node Tree '{}' Node '{}': is not a valid node for this tree".format(self.id_data.name, node.name)) return actors @classmethod def poll(cls, context): return (context.scene.render.engine == "PLASMA_GAME") @property def requires_actor(self): return any((node.requires_actor for node in self.nodes)) # Welcome to HAXland! # Blender 2.79 is great in that it allows us to have ID Datablock pointer properties everywhere. # However, there is an error in the way user refcounts are handled in node trees. When a node is freed, # it always decrements the user count. Good. But, the node tree decrements that same count again, resulting # in a use-after-free (or double-free?) crash in Blender. I modelled and submitted a fix (see: Blender D4196) # but a workaround is to just remove the nodes from all Plasma node trees before the data is unloaded. :) @bpy.app.handlers.persistent def _nuke_plasma_nodes(dummy): for i in bpy.data.node_groups: if isinstance(i, PlasmaNodeTree): i.nodes.clear() bpy.app.handlers.load_pre.append(_nuke_plasma_nodes)