From 809581ed6084b6875656c7a6776f89975699228d Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Mon, 16 Aug 2021 04:28:22 -0400 Subject: [PATCH] Fix non-determinism in Python material attributes. The dumb string lookup probably worked most of the time, but with recent changes that can cause layers and materials to be renamed to things not matching the pattern exactly, it's better to explicitly lookup the keys. This will prevent Dynamic Text Maps from seemingly "breaking" for no reason just because the lighting strategy changes. --- korman/exporter/material.py | 66 +++++++++++++++--- korman/nodes/node_python.py | 108 ++++++++++++++++++++---------- korman/properties/prop_texture.py | 9 +++ korman/ui/ui_texture.py | 19 ++++-- 4 files changed, 152 insertions(+), 50 deletions(-) diff --git a/korman/exporter/material.py b/korman/exporter/material.py index c9815bd..b96d228 100644 --- a/korman/exporter/material.py +++ b/korman/exporter/material.py @@ -14,14 +14,18 @@ # along with Korman. If not, see . import bpy +import mathutils + +from collections import defaultdict import functools +import itertools import math -import mathutils from pathlib import Path -from PyHSPlasma import * -from typing import Sequence, Union +from typing import Iterator, Optional, Union import weakref +from PyHSPlasma import * + from .explosions import * from .. import helpers from ..korlib import * @@ -133,7 +137,8 @@ class _Texture: class MaterialConverter: def __init__(self, exporter): - self._obj2mat = {} + self._obj2mat = defaultdict(dict) + self._obj2layer = defaultdict(lambda: defaultdict(lambda: defaultdict(list))) self._bump_mats = {} self._exporter = weakref.ref(exporter) self._pending = {} @@ -258,13 +263,13 @@ class MaterialConverter: # material had no Textures, we will need to initialize a default layer if not hsgmat.layers: layer = self._mgr.find_create_object(plLayer, name="{}_AutoLayer".format(mat_name), bl=bo) + self._obj2layer[bo][bm][None].append(layer.key) self._propagate_material_settings(bo, bm, layer) layer = self._export_layer_animations(bo, bm, None, 0, layer) hsgmat.addLayer(layer.key) # Cache this material for later - mat_list = self._obj2mat.setdefault(bo, []) - mat_list.append(hsgmat.key) + self._obj2mat[bo][bm] = hsgmat.key # Looks like we're done... return hsgmat.key @@ -496,6 +501,10 @@ class MaterialConverter: # NOTE: animated stencils and bumpmaps are nonsense. if not slot.use_stencil and not wantBumpmap: layer = self._export_layer_animations(bo, bm, slot, idx, layer) + + # Stash the top of the stack for later in the export + if bm is not None: + self._obj2layer[bo][bm][texture].append(layer.key) return layer def _export_layer_animations(self, bo, bm, tex_slot, idx, base_layer) -> plLayer: @@ -791,8 +800,8 @@ class MaterialConverter: if texture.image is None: dtm = self._mgr.find_create_object(plDynamicTextMap, name="{}_DynText".format(layer.key.name), bl=bo) dtm.hasAlpha = texture.use_alpha - # if you have a better idea, let's hear it... - dtm.visWidth, dtm.visHeight = 1024, 1024 + dtm.visWidth = int(layer_props.dynatext_resolution) + dtm.visHeight = int(layer_props.dynatext_resolution) layer.texture = dtm.key else: detail_blend = TEX_DETAIL_ALPHA @@ -1183,8 +1192,45 @@ class MaterialConverter: data[i] = level_data return numLevels, eWidth, eHeight, [data,] - def get_materials(self, bo) -> Sequence[plKey]: - return self._obj2mat.get(bo, []) + def get_materials(self, bo: bpy.types.Object, bm: Optional[bpy.types.Material] = None) -> Iterator[plKey]: + material_dict = self._obj2mat.get(bo, {}) + if bm is None: + return material_dict.values() + else: + return material_dict.get(bm, []) + + def get_layers(self, bo: Optional[bpy.types.Object] = None, + bm: Optional[bpy.types.Material] = None, + tex: Optional[bpy.types.Texture] = None) -> Iterator[plKey]: + + # All three? Simple. + if bo is not None and bm is not None and tex is not None: + yield from filter(None, self._obj2layer[bo][bm][tex]) + return + if bo is None and bm is None and tex is None: + self._exporter().report.warn("Asking for all the layers we've ever exported, eh? You like living dangerously.", indent=2) + + # What we want to do is filter _obj2layers: + # bo if set, or all objects + # bm if set, or tex.users_materials if set, or all materials... ON THE OBJECT(s) + # tex if set, or all layers... ON THE OBJECT(s) MATERIAL(s) + object_iter = lambda: (bo,) if bo is not None else self._obj2layer.keys() + if bm is not None: + material_seq = (bm,) + elif tex is not None: + material_seq = tex.users_material + else: + _iter = filter(lambda x: x and x.material, itertools.chain.from_iterable((i.material_slots for i in object_iter()))) + # Performance turd: this could, in the worst case, block on creating a list of every material + # attached to every single Plasma Object in the current scene. This whole algorithm sucks, + # though, so whatever. + material_seq = [slot.material for slot in _iter] + + for filtered_obj in object_iter(): + for filtered_mat in material_seq: + all_texes = self._obj2layer[filtered_obj][filtered_mat] + filtered_texes = all_texes[tex] if tex is not None else itertools.chain.from_iterable(all_texes.values()) + yield from filter(None, filtered_texes) def get_base_layer(self, hsgmat): try: diff --git a/korman/nodes/node_python.py b/korman/nodes/node_python.py index 9ece289..817d651 100644 --- a/korman/nodes/node_python.py +++ b/korman/nodes/node_python.py @@ -15,6 +15,8 @@ import bpy from bpy.props import * + +from collections.abc import Iterable from contextlib import contextmanager from pathlib import Path from PyHSPlasma import * @@ -283,7 +285,7 @@ class PlasmaPythonFileNode(PlasmaVersionedNode, bpy.types.Node): from_node = socket.links[0].from_node value = from_node.value if socket.is_simple_value else from_node.get_key(exporter, so) - if not isinstance(value, (tuple, list)): + if isinstance(value, str) or not isinstance(value, Iterable): value = (value,) for i in value: param = plPythonParameter() @@ -808,26 +810,47 @@ class PlasmaAttribTextureNode(idprops.IDPropMixin, PlasmaAttribNodeBase, bpy.typ pl_attrib = ("ptAttribMaterial", "ptAttribMaterialList", "ptAttribDynamicMap", "ptAttribMaterialAnimation") - def _poll_texture(self, value): + 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: - # 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? return value.name in self.material.texture_slots - return False + 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) + type=bpy.types.Material, + poll=_poll_material) texture = PointerProperty(name="Texture", description="Texture to expose to Python", type=bpy.types.Texture, @@ -839,35 +862,52 @@ class PlasmaAttribTextureNode(idprops.IDPropMixin, PlasmaAttribNodeBase, bpy.typ 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.prop(self, "target_object") layout.prop(self, "material") - if self.material is not None: - layout.prop(self, "texture") + layout.prop(self, "texture") def get_key(self, exporter, so): - if self.material is None: - self.raise_error("Material must be specified") - if self.texture is None: - self.raise_error("Texture must be specified") + 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 - material = self.material - texture = self.texture - # Your attribute stuff here... + 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": - if not self._is_dyntext(texture): - self.raise_error("Texture '{}' is not a Dynamic Text Map".format(self.texture_name)) - name = "{}_{}_DynText".format(material.name, texture.name) - return exporter.mgr.find_create_key(plDynamicTextMap, name=name, so=so) - elif self._is_animated(material, texture): - name = "{}_{}_LayerAnim".format(material_name, texture.name) - return exporter.mgr.find_create_key(plLayerAnimation, name=name, so=so) + yield from filter(lambda x: x and isinstance(x.object, plDynamicTextMap), + (i.object.texture for i in layer_generator)) + elif attrib == "ptAttribMaterialAnimation": + yield from filter(lambda x: x and isinstance(x.object, plLayerAnimationBase), layer_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, indent=2) + if result is not None: + yield result else: - name = "{}_{}".format(material.name, texture.name) - return exporter.mgr.find_create_key(plLayer, name=name, so=so) + raise RuntimeError(attrib) @classmethod def _idprop_mapping(cls): diff --git a/korman/properties/prop_texture.py b/korman/properties/prop_texture.py index aaa22a3..7ae8312 100644 --- a/korman/properties/prop_texture.py +++ b/korman/properties/prop_texture.py @@ -99,3 +99,12 @@ class PlasmaLayer(bpy.types.PropertyGroup): description="Don't save the depth information, allowing rendering of layers behind this one", default=False, options=set()) + + dynatext_resolution = EnumProperty(name="Dynamic Text Map Resolution", + description="Size of the Dynamic Text Map's underlying image", + items=[("128", "128x128", ""), + ("256", "256x256", ""), + ("512", "512x512", ""), + ("1024", "1024x1024", "")], + default="1024", + options=set()) diff --git a/korman/ui/ui_texture.py b/korman/ui/ui_texture.py index 8955e73..3bcd387 100644 --- a/korman/ui/ui_texture.py +++ b/korman/ui/ui_texture.py @@ -70,13 +70,20 @@ class PlasmaLayerPanel(TextureButtonsPanel, bpy.types.Panel): split = layout.split() col = split.column() - col.label("Animation:") - col.active = self._has_animation_data(context) and not use_stencil - col.prop(layer_props, "anim_auto_start") - col.prop(layer_props, "anim_loop") + sub = col.column() + sub.label("Animation:") + sub.active = self._has_animation_data(context) and not use_stencil + sub.prop(layer_props, "anim_auto_start") + sub.prop(layer_props, "anim_loop") + sub.separator() + sub.label("SDL Animation:") + sub.prop(layer_props, "anim_sdl_var", text="") + # Yes, two separator. col.separator() - col.label("SDL Animation:") - col.prop(layer_props, "anim_sdl_var", text="") + col.separator() + sub = col.column() + sub.active = texture.type == "IMAGE" and texture.image is None + sub.prop_menu_enum(layer_props, "dynatext_resolution", text="Dynamic Text Size") col = split.column() col.label("Miscellaneous:")