# 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 bpy from bpy.props import * from collections.abc import Iterable from contextlib import contextmanager from pathlib import Path from PyHSPlasma import * from .node_core import * from .node_deprecated import PlasmaDeprecatedNode, PlasmaVersionedNode from .. import idprops from ..plasma_attributes import get_attributes_from_str _single_user_attribs = { "ptAttribBoolean", "ptAttribInt", "ptAttribFloat", "ptAttribString", "ptAttribDropDownList", "ptAttribSceneobject", "ptAttribDynamicMap", "ptAttribGUIDialog", "ptAttribExcludeRegion", "ptAttribWaveSet", "ptAttribSwimCurrent", "ptAttribAnimation", "ptAttribBehavior", "ptAttribMaterial", "ptAttribMaterialAnimation", "ptAttribGUIPopUpMenu", "ptAttribGUISkin", "ptAttribGrassShader", } _attrib2param = { "ptAttribInt": plPythonParameter.kInt, "ptAttribFloat": plPythonParameter.kFloat, "ptAttribBoolean": plPythonParameter.kBoolean, "ptAttribString": plPythonParameter.kString, "ptAttribDropDownList": plPythonParameter.kString, "ptAttribSceneobject": plPythonParameter.kSceneObject, "ptAttribSceneobjectList": plPythonParameter.kSceneObjectList, "ptAttribActivator": plPythonParameter.kActivator, "ptAttribActivatorList": plPythonParameter.kActivator, "ptAttribNamedActivator": plPythonParameter.kActivator, "ptAttribResponder": plPythonParameter.kResponder, "ptAttribResponderList": plPythonParameter.kResponder, "ptAttribNamedResponder": plPythonParameter.kResponder, "ptAttribDynamicMap": plPythonParameter.kDynamicText, "ptAttribGUIDialog": plPythonParameter.kGUIDialog, "ptAttribExcludeRegion": plPythonParameter.kExcludeRegion, "ptAttribAnimation": plPythonParameter.kAnimation, "ptAttribBehavior": plPythonParameter.kBehavior, "ptAttribMaterial": plPythonParameter.kMaterial, "ptAttribMaterialList": plPythonParameter.kMaterial, "ptAttribGUIPopUpMenu": plPythonParameter.kGUIPopUpMenu, "ptAttribGUISkin": plPythonParameter.kGUISkin, "ptAttribWaveSet": plPythonParameter.kWaterComponent, "ptAttribSwimCurrent": plPythonParameter.kSwimCurrentInterface, "ptAttribClusterList": plPythonParameter.kClusterComponent, "ptAttribMaterialAnimation": plPythonParameter.kMaterialAnimation, "ptAttribGrassShader": plPythonParameter.kGrassShaderComponent, } _attrib_key_types = { "ptAttribSceneobject": plFactory.ClassIndex("plSceneObject"), "ptAttribSceneobjectList": plFactory.ClassIndex("plSceneObject"), "ptAttribActivator": plFactory.ClassIndex("plLogicModifier"), "ptAttribActivatorList": plFactory.ClassIndex("plLogicModifier"), "ptAttribNamedActivator": plFactory.ClassIndex("plLogicModifier"), "ptAttribResponder": plFactory.ClassIndex("plResponderModifier"), "ptAttribResponderList": plFactory.ClassIndex("plResponderModifier"), "ptAttribNamedResponder": plFactory.ClassIndex("plResponderModifier"), "ptAttribDynamicMap": plFactory.ClassIndex("plDynamicTextMap"), "ptAttribGUIDialog": plFactory.ClassIndex("pfGUIDialogMod"), "ptAttribExcludeRegion": plFactory.ClassIndex("plExcludeRegionMod"), "ptAttribAnimation": (plFactory.ClassIndex("plAGMasterMod"), plFactory.ClassIndex("plMsgForwarder")), "ptAttribBehavior": plFactory.ClassIndex("plMultistageBehMod"), "ptAttribMaterial": plFactory.ClassIndex("plLayer"), "ptAttribMaterialList": plFactory.ClassIndex("plLayer"), "ptAttribGUIPopUpMenu": plFactory.ClassIndex("pfGUIPopUpMenu"), "ptAttribGUISkin": plFactory.ClassIndex("pfGUISkin"), "ptAttribWaveSet": plFactory.ClassIndex("plWaveSet7"), "ptAttribSwimCurrent": (plFactory.ClassIndex("plSwimRegionInterface"), plFactory.ClassIndex("plSwimCircularCurrentRegion"), plFactory.ClassIndex("plSwimStraightCurrentRegion")), "ptAttribClusterList": plFactory.ClassIndex("plClusterGroup"), "ptAttribMaterialAnimation": plFactory.ClassIndex("plLayerAnimation"), "ptAttribGrassShader": plFactory.ClassIndex("plGrassShaderMod"), } class StringVectorProperty(bpy.types.PropertyGroup): value = StringProperty() class PlasmaAttributeArguments(bpy.types.PropertyGroup): byObject = BoolProperty() default = StringProperty() options = CollectionProperty(type=StringVectorProperty) range_values = FloatVectorProperty(size=2) netForce = BoolProperty() netPropagate = BoolProperty() stateList = CollectionProperty(type=StringVectorProperty) visListId = IntProperty() visListStates = CollectionProperty(type=StringVectorProperty) def set_arguments(self, args): for name in args: if name == "byObject": self.byObject = bool(args[name]) elif name == "default": self.default = str(args[name]) elif name == "options": for option in args[name]: item = self.options.add() item.value = str(option) elif name in ("range", "rang"): self.range_values = args[name] elif name == "netForce": self.netForce = bool(args[name]) elif name in ("netPropagate", "netProp"): self.netPropagate = bool(args[name]) elif name == "stateList": for state in args[name]: item = self.stateList.add() item.value = str(state) elif name == "vislistid": self.visListId = int(args[name]) elif name == "visliststates": for state in args[name]: item = self.visListStates.add() item.value = str(state) else: print("Unknown argument '{}' with value '{}'!".format(name, args[name])) class PlasmaAttribute(bpy.types.PropertyGroup): # This is thy lookup helper type_LUT = { bool: "ptAttribBoolean", float: "ptAttribFloat", int: "ptAttribInt", str: "ptAttribString", } attribute_id = IntProperty() attribute_type = StringProperty() attribute_name = StringProperty() attribute_description = StringProperty() # These shall be default values value_string = StringProperty() value_int = IntProperty() value_float = FloatProperty() value_bool = BoolProperty() # Special Arguments attribute_arguments = PointerProperty(type=PlasmaAttributeArguments) _simple_attrs = { "ptAttribString": "value_string", "ptAttribDropDownList": "value_string", "ptAttribInt": "value_int", "ptAttribFloat": "value_float", "ptAttribBoolean": "value_bool", } @property def is_simple_value(self): return self.attribute_type in self._simple_attrs def _get_simple_value(self): return getattr(self, self._simple_attrs[self.attribute_type]) def _set_simple_value(self, value): setattr(self, self._simple_attrs[self.attribute_type], value) simple_value = property(_get_simple_value, _set_simple_value) class PlasmaPythonFileNode(PlasmaVersionedNode, bpy.types.Node): bl_category = "PYTHON" bl_idname = "PlasmaPythonFileNode" bl_label = "Python File" bl_width_default = 290 def _poll_pytext(self, value): return value.name.endswith(".py") def _update_pyfile(self, context): if self.no_update: return text_id = bpy.data.texts.get(self.filename, None) if text_id: self.text_id = text_id def _update_pytext(self, context): if self.no_update: return with self.NoUpdate(): self.filename = self.text_id.name if self.text_id is not None else "" self.attributes.clear() self.inputs.clear() if self.text_id is not None: bpy.ops.node.plasma_attributes_to_node(node_path=self.node_path, text_path=self.text_id.name) filename = StringProperty(name="File Name", description="Python Filename", update=_update_pyfile) filepath = StringProperty(options={"HIDDEN"}) text_id = PointerProperty(name="Script File", description="Script file datablock", type=bpy.types.Text, poll=_poll_pytext, update=_update_pytext) # This property exists for UI purposes ONLY package = BoolProperty(options={"HIDDEN", "SKIP_SAVE"}) attributes = CollectionProperty(type=PlasmaAttribute, options={"HIDDEN"}) no_update = BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"}) @property def attribute_map(self): return { i.attribute_id: i for i in self.attributes } def draw_buttons(self, context, layout): main_row = layout.row(align=True) row = main_row.row(align=True) row.alert = self.text_id is None and bool(self.filename) row.prop(self, "text_id", text="Script") # open operator sel_text = "Load Script" if self.text_id is None else "" operator = main_row.operator("file.plasma_file_picker", icon="FILESEL", text=sel_text) operator.filter_glob = "*.py" operator.data_path = self.node_path operator.filename_property = "filename" if self.text_id is not None: # package button row = main_row.row(align=True) if self.text_id is not None: row.enabled = True icon = "PACKAGE" if self.text_id.plasma_text.package else "UGLYPACKAGE" row.prop(self.text_id.plasma_text, "package", icon=icon, text="") else: row.enabled = False row.prop(self, "package", text="", icon="UGLYPACKAGE") # rescan operator row = main_row.row(align=True) row.enabled = self.text_id is not None operator = row.operator("node.plasma_attributes_to_node", icon="FILE_REFRESH", text="") if self.text_id is not None: operator.text_path = self.text_id.name operator.node_path = self.node_path # This could happen on an upgrade if self.text_id is None and self.filename: layout.label(text="Script '{}' is not loaded in Blender".format(self.filename), icon="ERROR") def get_key(self, exporter, so): return self._find_create_key(plPythonFileMod, exporter, so=so) def export(self, exporter, bo, so): pfm = self.get_key(exporter, so).object # Special PFM-SO handling ahoy - be sure to do it for all objects this PFM is attached to. # Otherwise, you get non-determinant behavior. self._export_ancillary_sceneobject(exporter, bo, so) # No need to continue if the PFM was already generated. if pfm.filename: return py_name = Path(self.filename).stem pfm.filename = py_name # Check to see if we should pack this file if exporter.output.want_py_text(self.text_id): exporter.report.msg("Including Python '{}' for package", self.filename) exporter.output.add_python_mod(self.filename, text_id=self.text_id) # PFMs can have their own SDL... sdl_text = bpy.data.texts.get("{}.sdl".format(py_name), None) if sdl_text is not None: exporter.report.msg("Including corresponding SDL '{}'", sdl_text.name) exporter.output.add_sdl(sdl_text.name, text_id=sdl_text) # Handle exporting the Python Parameters attrib_sockets = (i for i in self.inputs if i.is_linked) for socket in attrib_sockets: from_node = socket.links[0].from_node value = from_node.value if socket.is_simple_value else from_node.get_key(exporter, so) if isinstance(value, str) or not isinstance(value, Iterable): value = (value,) for i in value: param = plPythonParameter() param.id = socket.attribute_id param.valueType = _attrib2param[socket.attribute_type] param.value = i if not socket.is_simple_value: self._export_key_attrib(exporter, bo, so, i, socket) pfm.addParameter(param) def _export_ancillary_sceneobject(self, exporter, bo, so: plSceneObject) -> None: # Danger: Special case evil ahoy... # If the key is an object that represents a lamp, we have to assume that the reason it's # being passed to Python is so it can be turned on/off at will. That means it's technically # an animated lamp. if not bool(bo.users_group): for light in exporter.mgr.find_interfaces(plLightInfo, so): exporter.report.msg(f"Marking RT light '{so.key.name}' as animated due to usage in a Python File node", so.key.name) light.setProperty(plLightInfo.kLPMovable, True) def _export_key_attrib(self, exporter, bo, so : plSceneObject, key : plKey, socket) -> None: if key is None: exporter.report.warn("Attribute '{}' didn't return a key and therefore will be unavailable to Python", self.id_data.name, socket.links[0].name) return key_type = _attrib_key_types[socket.attribute_type] if isinstance(key_type, tuple): good_key = key.type in key_type else: good_key = key.type == key_type if not good_key: exporter.report.warn("'{}' Node '{}' returned an unexpected key type '{}'", self.id_data.name, socket.links[0].from_node.name, plFactory.ClassName(key.type)) if isinstance(key.object, plSceneObject): self._export_ancillary_sceneobject(exporter, bo, key.object) def _get_attrib_sockets(self, idx): for i in self.inputs: if i.attribute_id == idx: yield i def generate_valid_links_for(self, context, socket, is_output): # Python nodes have no outputs... assert is_output is False attrib_type = socket.attribute_type for i in bpy.types.Node.__subclasses__(): node_attrib_types = getattr(i, "pl_attrib", None) if node_attrib_types is None or issubclass(i, PlasmaDeprecatedNode): continue if attrib_type in node_attrib_types: if issubclass(i, PlasmaAttribNodeBase): yield { "node_idname": i.bl_idname, "node_text": i.bl_label, "socket_name": "pfm", "socket_text": "Python File" } else: for socket_name, socket_def in i.output_sockets.items(): if socket_def.get("hidden") is True: continue if socket_def.get("can_link") is False: continue valid_link_nodes = socket_def.get("valid_link_nodes") valid_link_sockets = socket_def.get("valid_link_sockets") if valid_link_nodes is not None and self.bl_idname not in valid_link_nodes: continue if valid_link_sockets is not None and "PlasmaPythonFileNodeSocket" not in valid_link_sockets: continue yield { "node_idname": i.bl_idname, "node_text": i.bl_label, "socket_name": socket_name, "socket_text": socket_def["text"] } @classmethod def generate_valid_links_to(cls, context, socket, is_output): # This is only useful for nodes wanting to connect to our inputs (ptAttributes) if not is_output: return if isinstance(socket, PlasmaPythonAttribNodeSocket): pl_attrib = socket.node.pl_attrib else: pl_attrib = getattr(socket.node, "pl_attrib", set()) if not pl_attrib: return # Fetch the output definition for the requested socket and make sure it can connect to us. socket_def = getattr(socket.node, "output_sockets", {}).get(socket.alias) if socket_def is None: return valid_link_sockets = socket_def.get("valid_link_sockets") valid_link_nodes = socket_def.get("valid_link_nodes") if valid_link_sockets is not None and "PlasmaPythonFileNodeSocket" not in valid_link_sockets: return if valid_link_nodes is not None and "PlasmaPythonFileNode" not in valid_link_nodes: return # Ok, apparently this thing can connect as a ptAttribute. The only problem with that is # that we have no freaking where... The sockets are spawned by Python files... So, we # need to look at all the Python files we know about... for text_id in bpy.data.texts: if not text_id.name.endswith(".py"): continue attribs = get_attributes_from_str(text_id.as_string()) if not attribs: continue for _, attrib in attribs.items(): if not attrib["type"] in pl_attrib: continue # *gulp* yield { "node_idname": "PlasmaPythonFileNode", "node_text": text_id.name, "node_settings": { "filename": text_id.name }, "socket_name": attrib["name"], "socket_text": attrib["name"] } def harvest_actors(self): for i in self.inputs: if not i.is_linked or i.attribute_type not in {"ptAttribSceneobject", "ptAttribSceneobjectList"}: continue node = i.links[0].from_node if node.target_object is not None: yield node.target_object.name def _make_attrib_socket(self, attrib, is_init=False): new_pos = len(self.inputs) if not is_init: for i, socket in enumerate(self.inputs): if attrib.attribute_id < socket.attribute_id: new_pos = i break old_pos = len(self.inputs) socket = self.inputs.new("PlasmaPythonFileNodeSocket", attrib.attribute_name) socket.attribute_id = attrib.attribute_id if not is_init and new_pos != old_pos: self.inputs.move(old_pos, new_pos) @property def requires_actor(self): return True @contextmanager def NoUpdate(self): self.no_update = True try: yield self finally: self.no_update = False def update(self): if self.no_update: return with self.NoUpdate(): # First, we really want to make sure our junk matches up. Yes, this does dupe what # happens in PlasmaAttribNodeBase, but we can link much more than those node types... toasty_sockets = [] input_nodes = (i for i in self.inputs if i.is_linked and i.links) for i in input_nodes: link = i.links[0] allowed_attribs = getattr(link.from_node, "pl_attrib", set()) if i.attribute_type not in allowed_attribs: self.id_data.links.remove(link) # Bad news, old chap... Even though we're doing this before we figure out # how many socket we need, the changes won't be committed to the socket's links # until later. damn. We'll have to track it manually toasty_sockets.append(i) attribs = self.attribute_map empty = not self.inputs for idx in sorted(attribs): attrib = attribs[idx] # Delete any attribute sockets whose type changed for i in self._get_attrib_sockets(idx): if i.attribute_type != attrib.attribute_type: self.inputs.remove(i) # Fetch the list of sockets again because we may have nuked some inputs = list(self._get_attrib_sockets(idx)) if not inputs: self._make_attrib_socket(attrib, empty) elif attrib.attribute_type not in _single_user_attribs: unconnected = [socket for socket in inputs if not socket.is_linked or socket in toasty_sockets] if not unconnected: self._make_attrib_socket(attrib, empty) while len(unconnected) > 1: self.inputs.remove(unconnected.pop()) @property def latest_version(self): return 2 def upgrade(self): # In version 1 of this node, Python scripts were referenced by their filename in the # python package and by their path on the local machine. This created an undue dependency # on the artist's environment. In version 2, we will use Blender's text data blocks to back # Python scripts. It is still legal to export Python File nodes that are not backed by a script. if self.version == 1: text_id = bpy.data.texts.get(self.filename, None) if text_id is None: path = Path(self.filepath) try: if path.exists(): text_id = bpy.data.texts.load(self.filepath) except OSError: pass with self.NoUpdate(): self.text_id = text_id self.property_unset("filepath") self.version = 2 class PlasmaPythonFileNodeSocket(PlasmaNodeSocketBase, bpy.types.NodeSocket): attribute_id = IntProperty(options={"HIDDEN"}) @property def attribute_description(self): return self.node.attribute_map[self.attribute_id].attribute_description @property def attribute_name(self): return self.node.attribute_map[self.attribute_id].attribute_name @property def attribute_type(self): return self.node.attribute_map[self.attribute_id].attribute_type def draw(self, context, layout, node, text): self.draw_add_operator(context, layout, node) layout.label("ID: {}".format(self.attribute_id)) layout.label(self.attribute_description) def draw_color(self, context, node): return _attrib_colors.get(self.attribute_type, (0.0, 0.0, 0.0, 1.0)) @property def is_simple_value(self): return self.node.attribute_map[self.attribute_id].is_simple_value @property def simple_value(self): return self.node.attribute_map[self.attribute_id].simple_value @property def attribute_arguments(self): return self.node.attribute_map[self.attribute_id].attribute_arguments class PlasmaPythonAttribNodeSocket(PlasmaNodeSocketBase, bpy.types.NodeSocket): def draw_content(self, context, layout, node, text): attrib = node.to_socket if attrib is None: layout.label(text) else: layout.label("ID: {}".format(attrib.attribute_id)) def draw_color(self, context, node): return _attrib_colors.get(node.pl_attrib, (0.0, 0.0, 0.0, 1.0)) class PlasmaPythonReferenceNodeSocket(PlasmaNodeSocketBase, bpy.types.NodeSocket): bl_color = (0.031, 0.110, 0.290, 1.0) class PlasmaAttribNodeBase(PlasmaNodeBase): def init(self, context): self.outputs.new("PlasmaPythonAttribNodeSocket", "Python File", "pfm") @property def attribute_name(self): attr = self.to_socket return "Value" if attr is None else attr.attribute_name @property def to_socket(self): """Returns the socket linked to IF only one link has been made""" socket = self.outputs[0] if len(socket.links) == 1: return socket.links[0].to_socket return None @classmethod def register(cls): pl_attrib = cls.pl_attrib if isinstance(pl_attrib, tuple): color = _attrib_colors.get(pl_attrib, None) if color is not None: for i in pl_attrib: _attrib_colors[i] = color def update(self): pl_id = self.pl_attrib socket = self.outputs[0] for link in socket.links: if link.to_node.bl_idname != "PlasmaPythonFileNode": self.id_data.links.remove(link) if isinstance(pl_id, tuple): if link.to_socket.attribute_type not in pl_id: self.id_data.links.remove(link) else: if pl_id != link.to_socket.attribute_type: self.id_data.links.remove(link) class PlasmaAttribBoolNode(PlasmaAttribNodeBase, bpy.types.Node): bl_category = "PYTHON" bl_idname = "PlasmaAttribBoolNode" bl_label = "Boolean Attribute" def _on_update(self, context): self.inited = True pl_attrib = "ptAttribBoolean" pl_label_attrib = "value" value = BoolProperty() inited = BoolProperty(options={"HIDDEN"}) def draw_buttons(self, context, layout): layout.prop(self, "value", text=self.attribute_name) def update(self): super().update() attrib = self.to_socket if attrib is not None and not self.inited: self.value = attrib.simple_value self.inited = True class PlasmaAttribDropDownListNode(PlasmaAttribNodeBase, bpy.types.Node): bl_category = "PYTHON" bl_idname = "PlasmaAttribDropDownListNode" bl_label = "Drop Down List Attribute" pl_attrib = "ptAttribDropDownList" pl_label_attrib = "value" def _list_items(self, context): attrib = self.to_socket if attrib is not None: return [(option.value, option.value, "") for option in attrib.attribute_arguments.options] else: return [] value = EnumProperty(items=_list_items) def draw_buttons(self, context, layout): layout.prop(self, "value", text=self.attribute_name) def update(self): super().update() attrib = self.to_socket if attrib is not None and not self.value: self.value = attrib.simple_value class PlasmaAttribIntNode(PlasmaAttribNodeBase, bpy.types.Node): bl_category = "PYTHON" bl_idname = "PlasmaAttribIntNode" bl_label = "Numeric Attribute" def _get_int(self): return round(self.value_float) def _set_int(self, value): self.value_float = float(value) def _on_update_float(self, context): self.inited = True pl_attrib = ("ptAttribFloat", "ptAttribInt") pl_label_attrib = "value" value_int = IntProperty(get=_get_int, set=_set_int, options={"HIDDEN"}) value_float = FloatProperty(update=_on_update_float, options={"HIDDEN"}) inited = BoolProperty(options={"HIDDEN"}) def init(self, context): super().init(context) # because we're trying to be for both int and float... self.outputs[0].link_limit = 1 def draw_buttons(self, context, layout): attrib = self.to_socket if attrib is None: layout.prop(self, "value_int", text="Value") elif attrib.attribute_type == "ptAttribFloat": self._range_label(layout) layout.alert = self._out_of_range(self.value_float) layout.prop(self, "value_float", text=attrib.name) elif attrib.attribute_type == "ptAttribInt": self._range_label(layout) layout.alert = self._out_of_range(self.value_int) layout.prop(self, "value_int", text=attrib.name) else: raise RuntimeError() def update(self): super().update() attrib = self.to_socket if attrib is not None and not self.inited: self.value = attrib.simple_value self.inited = True def _get_value(self): attrib = self.to_socket if attrib is None or attrib.attribute_type == "ptAttribInt": return self.value_int else: return self.value_float def _set_value(self, value): self.value_float = value value = property(_get_value, _set_value) def _range_label(self, layout): attrib = self.to_socket layout.label(text="Range: [{}, {}]".format(attrib.attribute_arguments.range_values[0], attrib.attribute_arguments.range_values[1])) def _out_of_range(self, value): attrib = self.to_socket if attrib.attribute_arguments.range_values[0] == attrib.attribute_arguments.range_values[1]: # Ignore degenerate intervals return False if attrib.attribute_arguments.range_values[0] <= value <= attrib.attribute_arguments.range_values[1]: return False return True class PlasmaAttribObjectNode(idprops.IDPropObjectMixin, PlasmaAttribNodeBase, bpy.types.Node): bl_category = "PYTHON" bl_idname = "PlasmaAttribObjectNode" bl_label = "Object Attribute" pl_attrib = ("ptAttribSceneobject", "ptAttribSceneobjectList", "ptAttribAnimation", "ptAttribSwimCurrent", "ptAttribWaveSet", "ptAttribGrassShader") target_object = PointerProperty(name="Object", description="Object containing the required data", type=bpy.types.Object) def init(self, context): super().init(context) # keep the code simple self.outputs[0].link_limit = 1 def draw_buttons(self, context, layout): layout.prop(self, "target_object", text=self.attribute_name) def get_key(self, exporter, so): attrib = self.to_socket if attrib is None: self.raise_error("must be connected to a Python File node!") attrib = attrib.attribute_type bo = self.target_object if bo is None: self.raise_error("Target object must be specified") ref_so_key = exporter.mgr.find_create_key(plSceneObject, bl=bo) ref_so = ref_so_key.object # Add your attribute type handling here... if attrib in {"ptAttribSceneobject", "ptAttribSceneobjectList"}: return ref_so_key elif attrib == "ptAttribAnimation": return exporter.animation.get_animation_key(bo, ref_so) elif attrib == "ptAttribSwimCurrent": swimregion = bo.plasma_modifiers.swimregion return swimregion.get_key(exporter, ref_so) elif attrib == "ptAttribWaveSet": waveset = bo.plasma_modifiers.water_basic if not waveset.enabled: self.raise_error("water modifier not enabled on '{}'".format(self.object_name)) return exporter.mgr.find_create_key(plWaveSet7, so=ref_so, bl=bo) elif attrib == "ptAttribGrassShader": grass_shader = bo.plasma_modifiers.grass_shader if not grass_shader.enabled: self.raise_error("grass shader modifier not enabled on '{}'".format(self.object_name)) if exporter.mgr.getVer() <= pvPots: return None return [exporter.mgr.find_create_key(plGrassShaderMod, so=ref_so, name=i.name) for i in exporter.mesh.material.get_materials(bo)] @classmethod def _idprop_mapping(cls): return {"target_object": "object_name"} class PlasmaAttribStringNode(PlasmaAttribNodeBase, bpy.types.Node): bl_category = "PYTHON" bl_idname = "PlasmaAttribStringNode" bl_label = "String Attribute" pl_attrib = "ptAttribString" pl_label_attrib = "value" value = StringProperty() def draw_buttons(self, context, layout): layout.prop(self, "value", text=self.attribute_name) def update(self): super().update() attrib = self.to_socket if attrib is not None and not self.value: self.value = attrib.simple_value class PlasmaAttribTextureNode(idprops.IDPropMixin, PlasmaAttribNodeBase, bpy.types.Node): bl_category = "PYTHON" bl_idname = "PlasmaAttribTextureNode" bl_label = "Texture Attribute" bl_width_default = 175 pl_attrib = ("ptAttribMaterial", "ptAttribMaterialList", "ptAttribDynamicMap", "ptAttribMaterialAnimation") def _poll_material(self, value: bpy.types.Material) -> bool: # Don't filter materials by texture - this would (potentially) result in surprising UX # in that you would have to clear the texture selection before being able to select # certain materials. if self.target_object is not None: object_materials = (slot.material for slot in self.target_object.material_slots if slot and slot.material) return value in object_materials return True def _poll_texture(self, value: bpy.types.Texture) -> bool: # is this the type of dealio that we're looking for? attrib = self.to_socket if attrib is not None: attrib = attrib.attribute_type if attrib == "ptAttribDynamicMap": if not self._is_dyntext(value): return False elif attrib == "ptAttribMaterialAnimation": if not self._is_animated(self.material, value): return False # must be a legal option... but is it a member of this material... or, if no material, # any of the materials attached to the object? if self.material is not None: return value.name in self.material.texture_slots elif self.target_object is not None: for i in (slot.material for slot in self.target_object.material_slots if slot and slot.material): if value in (slot.texture for slot in i.texture_slots if slot and slot.texture): return True return False else: return True target_object = PointerProperty(name="Object", description="", type=bpy.types.Object, poll=idprops.poll_drawable_objects) material = PointerProperty(name="Material", description="Material the texture is attached to", type=bpy.types.Material, poll=_poll_material) texture = PointerProperty(name="Texture", description="Texture to expose to Python", type=bpy.types.Texture, poll=_poll_texture) # Blender memory workaround _ENTIRE_ANIMATION = "(Entire Animation)" def _get_anim_names(self, context): if self.texture is not None: items = [(anim.animation_name, anim.animation_name, "") for anim in self.texture.plasma_layer.subanimations] elif self.material is not None or self.target_object is not None: if self.material is None: materials = (i.material for i in self.target_object.material_slots if i and i.material) else: materials = (self.material,) layer_props = (i.texture.plasma_layer for mat in materials for i in mat.texture_slots if i and i.texture) all_anims = frozenset((anim.animation_name for i in layer_props for anim in i.subanimations)) items = [(i, i, "") for i in all_anims] else: items = [(PlasmaAttribTextureNode._ENTIRE_ANIMATION, PlasmaAttribTextureNode._ENTIRE_ANIMATION, "")] # We always want "(Entire Animation)", if it exists, to be the first item. entire = items.index((PlasmaAttribTextureNode._ENTIRE_ANIMATION, PlasmaAttribTextureNode._ENTIRE_ANIMATION, "")) if entire not in (-1, 0): items.pop(entire) items.insert(0, (PlasmaAttribTextureNode._ENTIRE_ANIMATION, PlasmaAttribTextureNode._ENTIRE_ANIMATION, "")) return items anim_name = EnumProperty(name="Animation", description="Name of the animation to control", items=_get_anim_names, options=set()) def init(self, context): super().init(context) # keep the code simple self.outputs[0].link_limit = 1 def draw_buttons(self, context, layout): if self.target_object is not None: iter_materials = lambda: (i.material for i in self.target_object.material_slots if i and i.material) if self.material is not None: if self.material not in iter_materials(): layout.label("The selected material is not linked to the target object.", icon="ERROR") layout.alert = True if self.texture is not None: if not frozenset(self.texture.users_material) & frozenset(iter_materials()): layout.label("The selected texture is not on a material linked to the target object.", icon="ERROR") layout.alert = True layout.alert = not any((self.target_object, self.material, self.texture)) layout.prop(self, "target_object") layout.prop(self, "material") layout.prop(self, "texture") wants_anim = bool(self.to_socket and self.to_socket.attribute_type == "ptAttribMaterialAnimation") col = layout.column() col.alert = False col.active = wants_anim col.prop(self, "anim_name") def get_key(self, exporter, so): if not any((self.target_object, self.material, self.texture)): self.raise_error("At least one of: target object, material, or texture must be specified.") attrib = self.to_socket if attrib is None: self.raise_error("must be connected to a Python File node!") attrib = attrib.attribute_type layer_generator = exporter.mesh.material.get_layers(self.target_object, self.material, self.texture) bottom_layers = (i.object.bottomOfStack for i in layer_generator) if attrib == "ptAttribDynamicMap": yield from filter(lambda x: x and isinstance(x.object, plDynamicTextMap), (i.object.texture for i in layer_generator)) elif attrib == "ptAttribMaterialAnimation": anim_generator = exporter.mesh.material.get_texture_animation_key(self.target_object, self.material, self.texture, self.anim_name) yield from filter(lambda x: not isinstance(x.object, (plAgeGlobalAnim, plLayerSDLAnimation)), anim_generator) elif attrib == "ptAttribMaterialList": yield from filter(lambda x: x and not isinstance(x.object, plLayerAnimationBase), bottom_layers) elif attrib == "ptAttribMaterial": # Only return the first key; warn about others. result_gen = filter(lambda x: x and not isinstance(x.object, plLayerAnimationBase), bottom_layers) result = next(result_gen, None) remainder = sum((1 for i in result)) if remainder > 1: exporter.report.warn("'{}.{}': Expected a single layer, but mapped to {}. Make the settings more specific.", self.id_data.name, self.path_from_id(), remainder + 1) if result is not None: yield result else: raise RuntimeError(attrib) @classmethod def _idprop_mapping(cls): return {"material": "material_name", "texture": "texture_name"} def _idprop_sources(self): return {"material_name": bpy.data.materials, "texture_name": bpy.data.textures} def _is_animated(self, material, texture): return ((material.animation_data is not None and material.animation_data.action is not None) or (texture.animation_data is not None and texture.animation_data.action is not None)) def _is_dyntext(self, texture): return texture.type == "IMAGE" and texture.image is None _attrib_colors = { "ptAttribActivator": (0.188, 0.086, 0.349, 1.0), "ptAttribActivatorList": (0.188, 0.086, 0.349, 1.0), "ptAttribBehavior": (0.348, 0.186, 0.349, 1.0), "ptAttribBoolean": (0.71, 0.706, 0.655, 1.0), "ptAttribExcludeRegion": (0.031, 0.110, 0.290, 1.0), "ptAttribDropDownList": (0.475, 0.459, 0.494, 1.0), "ptAttribNamedActivator": (0.188, 0.086, 0.349, 1.0), "ptAttribNamedResponder": (0.031, 0.110, 0.290, 1.0), "ptAttribResponder": (0.031, 0.110, 0.290, 1.0), "ptAttribResponderList": (0.031, 0.110, 0.290, 1.0), "ptAttribString": (0.675, 0.659, 0.494, 1.0), PlasmaAttribIntNode.pl_attrib: (0.443, 0.439, 0.392, 1.0), PlasmaAttribObjectNode.pl_attrib: (0.565, 0.267, 0.0, 1.0), PlasmaAttribTextureNode.pl_attrib: (0.035, 0.353, 0.0, 1.0), }