From 5b2c16cfb2e5690a415ce638ec8ead41b1130677 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sat, 22 Jun 2024 11:45:01 -0400 Subject: [PATCH 1/3] Make bounds enum properties less verbose. Every time we need to make a place to store a region's bounds, we have to write quite a bit of boilerplate code to stash the bounds type into the collision modifier. This removes the need to do that by making a helper function that generates those helper functions for us. Generally, we would use `functools.partial` to do this, but Blender requires function objects for the `EnumProperty`'s getter and setter. Partial objects raise a `TypeError`. --- korman/enum_props.py | 62 ++++++++++++++++++++++++++ korman/nodes/node_conditions.py | 31 ++++++++----- korman/nodes/node_logic.py | 26 +++++------ korman/properties/modifiers/logic.py | 17 ++----- korman/properties/modifiers/physics.py | 18 +------- korman/properties/modifiers/region.py | 22 +++++---- 6 files changed, 109 insertions(+), 67 deletions(-) create mode 100644 korman/enum_props.py diff --git a/korman/enum_props.py b/korman/enum_props.py new file mode 100644 index 0000000..d4718db --- /dev/null +++ b/korman/enum_props.py @@ -0,0 +1,62 @@ +# 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 . + +from __future__ import annotations + +from bpy.props import * + +from typing import * + +# These are the kinds of physical bounds Plasma can work with. +# This sequence is acceptable in any EnumProperty +_bounds_types = ( + ("box", "Bounding Box", "Use a perfect bounding box"), + ("sphere", "Bounding Sphere", "Use a perfect bounding sphere"), + ("hull", "Convex Hull", "Use a convex set encompasing all vertices"), + ("trimesh", "Triangle Mesh", "Use the exact triangle mesh (SLOW!)") +) + +def _bounds_type_index(key: str) -> int: + return list(zip(*_bounds_types))[0].index(key) + +def _bounds_type_str(idx: int) -> str: + return _bounds_types[idx][0] + +def _get_bounds(physics_attr: Optional[str]) -> Callable[[Any], int]: + def getter(self) -> int: + physics_object = getattr(self, physics_attr) if physics_attr is not None else self.id_data + if physics_object is not None: + return _bounds_type_index(physics_object.plasma_modifiers.collision.bounds) + return _bounds_type_index("hull") + return getter + +def _set_bounds(physics_attr: Optional[str]) -> Callable[[Any, int], None]: + def setter(self, value: int): + physics_object = getattr(self, physics_attr) if physics_attr is not None else self.id_data + if physics_object is not None: + physics_object.plasma_modifiers.collision.bounds = _bounds_type_str(value) + return setter + +def bounds(physics_attr: Optional[str] = None, store_on_collider: bool = True, **kwargs) -> str: + assert not {"items", "get", "set"} & kwargs.keys(), "You cannot use the `items`, `get`, or `set` keyword arguments" + if store_on_collider: + kwargs["get"] = _get_bounds(physics_attr) + kwargs["set"] = _set_bounds(physics_attr) + if "default" not in kwargs: + kwargs["default"] = "hull" + return EnumProperty( + items=_bounds_types, + **kwargs + ) diff --git a/korman/nodes/node_conditions.py b/korman/nodes/node_conditions.py index a083d44..e24f798 100644 --- a/korman/nodes/node_conditions.py +++ b/korman/nodes/node_conditions.py @@ -21,8 +21,8 @@ import math from PyHSPlasma import * from typing import * +from .. import enum_props from .node_core import * -from ..properties.modifiers.physics import bounds_types from .. import idprops class PlasmaClickableNode(idprops.IDPropObjectMixin, PlasmaNodeBase, bpy.types.Node): @@ -38,10 +38,13 @@ class PlasmaClickableNode(idprops.IDPropObjectMixin, PlasmaNodeBase, bpy.types.N description="Mesh object that is clickable", type=bpy.types.Object, poll=idprops.poll_mesh_objects) - bounds = EnumProperty(name="Bounds", - description="Clickable's bounds (NOTE: only used if your clickable is not a collider)", - items=bounds_types, - default="hull") + + bounds = enum_props.bounds( + "clickable_object", store_on_collider=False, + name="Bounds", + description="Clickable's bounds (NOTE: only used if your clickable is not a collider)", + default="hull" + ) input_sockets: dict[str, Any] = { "region": { @@ -162,10 +165,12 @@ class PlasmaClickableRegionNode(idprops.IDPropObjectMixin, PlasmaNodeBase, bpy.t description="Object that defines the region mesh", type=bpy.types.Object, poll=idprops.poll_mesh_objects) - bounds = EnumProperty(name="Bounds", - description="Physical object's bounds (NOTE: only used if your clickable is not a collider)", - items=bounds_types, - default="hull") + bounds = enum_props.bounds( + "region_object", store_on_collider=False, + name="Bounds", + description="Physical object's bounds (NOTE: only used if your clickable is not a collider)", + default="hull" + ) output_sockets = { "satisfies": { @@ -419,9 +424,11 @@ class PlasmaVolumeSensorNode(idprops.IDPropObjectMixin, PlasmaNodeBase, bpy.type description="Object that defines the region mesh", type=bpy.types.Object, poll=idprops.poll_mesh_objects) - bounds = EnumProperty(name="Bounds", - description="Physical object's bounds", - items=bounds_types) + bounds = enum_props.bounds( + "region_object", store_on_collider=False, + name="Bounds", + description="Physical object's bounds" + ) # Detector Properties report_on = EnumProperty(name="Triggerers", diff --git a/korman/nodes/node_logic.py b/korman/nodes/node_logic.py index 7db40c3..9f7f618 100644 --- a/korman/nodes/node_logic.py +++ b/korman/nodes/node_logic.py @@ -20,8 +20,8 @@ from bpy.props import * from typing import * from PyHSPlasma import * +from .. import enum_props from .node_core import * -from ..properties.modifiers.physics import bounds_types, bounds_type_index, bounds_type_str from .. import idprops class PlasmaExcludeRegionNode(idprops.IDPropObjectMixin, PlasmaNodeBase, bpy.types.Node): @@ -33,25 +33,19 @@ class PlasmaExcludeRegionNode(idprops.IDPropObjectMixin, PlasmaNodeBase, bpy.typ # ohey, this can be a Python attribute pl_attrib = {"ptAttribExcludeRegion"} - def _get_bounds(self): - if self.region_object is not None: - return bounds_type_index(self.region_object.plasma_modifiers.collision.bounds) - return bounds_type_index("hull") - def _set_bounds(self, value): - if self.region_object is not None: - self.region_object.plasma_modifiers.collision.bounds = bounds_type_str(value) - region_object = PointerProperty(name="Region", description="Region object's name", type=bpy.types.Object, poll=idprops.poll_mesh_objects) - bounds = EnumProperty(name="Bounds", - description="Region bounds", - items=bounds_types, - get=_get_bounds, - set=_set_bounds) - block_cameras = BoolProperty(name="Block Cameras", - description="The region blocks cameras when it has been cleared") + bounds = enum_props.bounds( + "region_object", + name="Bounds", + description="Region bounds" + ) + block_cameras = BoolProperty( + name="Block Cameras", + description="The region blocks cameras when it has been cleared" + ) input_sockets:dict[str, dict[str, Any]] = { "safe_point": { diff --git a/korman/properties/modifiers/logic.py b/korman/properties/modifiers/logic.py index 4cb4fd9..dfd9e68 100644 --- a/korman/properties/modifiers/logic.py +++ b/korman/properties/modifiers/logic.py @@ -30,9 +30,9 @@ if TYPE_CHECKING: from ...addon_prefs import game_versions from .base import PlasmaModifierProperties, PlasmaModifierLogicWiz +from ... import enum_props from ...exporter import ExportError, utils from ... import idprops -from .physics import bounds_type_index, bounds_type_str, bounds_types entry_cam_pfm = { "filename": "xEntryCam.py", @@ -103,15 +103,6 @@ class PlasmaSpawnPoint(PlasmaModifierProperties, PlasmaModifierLogicWiz): bl_description = "Point at which avatars link into the Age" bl_object_types = {"EMPTY"} - def _get_bounds(self) -> int: - if self.exit_region is not None: - return bounds_type_index(self.exit_region.plasma_modifiers.collision.bounds_type) - return bounds_type_index("hull") - - def _set_bounds(self, value: int) -> None: - if self.exit_region is not None: - self.exit_region.plasma_modifiers.collision.bounds_type = bounds_type_str(value) - entry_camera = PointerProperty( name="Entry Camera", description="Camera to use when the player spawns at this location", @@ -126,12 +117,10 @@ class PlasmaSpawnPoint(PlasmaModifierProperties, PlasmaModifierLogicWiz): poll=idprops.poll_mesh_objects ) - bounds_type = EnumProperty( + bounds_type = enum_props.bounds( + "exit_region", name="Bounds", description="", - items=bounds_types, - get=_get_bounds, - set=_set_bounds, default="hull" ) diff --git a/korman/properties/modifiers/physics.py b/korman/properties/modifiers/physics.py index eb30e6a..2c6bda8 100644 --- a/korman/properties/modifiers/physics.py +++ b/korman/properties/modifiers/physics.py @@ -18,18 +18,10 @@ from bpy.props import * from PyHSPlasma import * from .base import PlasmaModifierProperties +from ...enum_props import _bounds_types from ...exporter import ExportError from ... import idprops -# These are the kinds of physical bounds Plasma can work with. -# This sequence is acceptable in any EnumProperty -bounds_types = ( - ("box", "Bounding Box", "Use a perfect bounding box"), - ("sphere", "Bounding Sphere", "Use a perfect bounding sphere"), - ("hull", "Convex Hull", "Use a convex set encompasing all vertices"), - ("trimesh", "Triangle Mesh", "Use the exact triangle mesh (SLOW!)") -) - # These are the collision sound surface types surface_types = ( # Danger: do not reorder this one. @@ -55,12 +47,6 @@ subworld_types = ( ("subworld", "Separate World", "Causes all objects to be placed in a separate physics simulation"), ) -def bounds_type_index(key): - return list(zip(*bounds_types))[0].index(key) - -def bounds_type_str(idx): - return bounds_types[idx][0] - def _set_phys_prop(prop, sim, phys, value=True): """Sets properties on plGenericPhysical and plSimulationInterface (seeing as how they are duped)""" sim.setProperty(prop, value) @@ -78,7 +64,7 @@ class PlasmaCollider(PlasmaModifierProperties): bounds = EnumProperty(name="Bounds Type", description="", - items=bounds_types, + items=_bounds_types, default="hull") avatar_blocker = BoolProperty(name="Blocks Avatars", diff --git a/korman/properties/modifiers/region.py b/korman/properties/modifiers/region.py index 2f08872..3fa3376 100644 --- a/korman/properties/modifiers/region.py +++ b/korman/properties/modifiers/region.py @@ -23,8 +23,8 @@ from ...helpers import bmesh_from_object from ... import idprops from .base import PlasmaModifierProperties, PlasmaModifierLogicWiz +from ... import enum_props from ..prop_camera import PlasmaCameraProperties -from .physics import bounds_types footstep_surface_ids = { "dirt": 0, @@ -136,14 +136,18 @@ class PlasmaFootstepRegion(PlasmaModifierProperties, PlasmaModifierLogicWiz): bl_description = "Footstep Region" bl_object_types = {"MESH"} - surface = EnumProperty(name="Surface", - description="What kind of surface are we walking on?", - items=footstep_surfaces, - default="stone") - bounds = EnumProperty(name="Region Bounds", - description="Physical object's bounds", - items=bounds_types, - default="hull") + surface = EnumProperty( + name="Surface", + description="What kind of surface are we walking on?", + items=footstep_surfaces, + default="stone" + ) + bounds = enum_props.bounds( + store_on_collider=False, + name="Region Bounds", + description="Physical object's bounds", + default="hull" + ) def logicwiz(self, bo, tree): nodes = tree.nodes From 0d84b78762c918b54c1a6c90c066c67c9d866837 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Tue, 25 Jun 2024 19:23:18 -0400 Subject: [PATCH 2/3] Migrate duplicated bounds properties. This migrates duplicated bounds properties (i.e. those *NOT* found on the collision modifier) to be stored on the collision modifier. This can potentially be a lossy process if there are multiple conflicting bounds property values for a single object. This may result in some Ages having "new" bugs appear, but, really, they were only working by sheer dumb luck before. It's better this way. --- korman/enum_props.py | 45 +++++++++++++++++++++++ korman/nodes/node_conditions.py | 53 +++++++++++++++++++++++---- korman/properties/modifiers/base.py | 4 +- korman/properties/modifiers/region.py | 15 ++++++-- 4 files changed, 104 insertions(+), 13 deletions(-) diff --git a/korman/enum_props.py b/korman/enum_props.py index d4718db..7dcc2cf 100644 --- a/korman/enum_props.py +++ b/korman/enum_props.py @@ -18,6 +18,7 @@ from __future__ import annotations from bpy.props import * from typing import * +import warnings # These are the kinds of physical bounds Plasma can work with. # This sequence is acceptable in any EnumProperty @@ -54,9 +55,53 @@ def bounds(physics_attr: Optional[str] = None, store_on_collider: bool = True, * if store_on_collider: kwargs["get"] = _get_bounds(physics_attr) kwargs["set"] = _set_bounds(physics_attr) + else: + warnings.warn("Storing bounds properties outside of the collision modifier is deprecated.", category=DeprecationWarning) if "default" not in kwargs: kwargs["default"] = "hull" return EnumProperty( items=_bounds_types, **kwargs ) + +def upgrade_bounds(bl, bounds_attr: str) -> None: + # Only perform this process if the property has a value. Otherwise, we'll + # wind up blowing away the collision modifier's settings with nonsense. + if not bl.is_property_set(bounds_attr): + return + + # Before we unregister anything, grab a copy of what the collision modifier currently thinks. + bounds_value_curr = getattr(bl, bounds_attr) + + # So, here's the deal. If someone has been playing with nodes and changed the bounds type, + # Blender will think the property has been set, even if they wound up with the property + # at the default value. I don't know that we can really trust the default in the property + # definition to be the same as the old default (they shouldn't be different, but let's be safe). + # So, let's apply rough justice. If the destination property thinks it's a triangle mesh, we + # don't need to blow that away - it's a very specific non default setting. + if bounds_value_curr == "trimesh": + return + + # Unregister the new/correct proxy bounds property (with getter/setter) and re-register + # the property without the proxy functions to get the old value. Reregister the new property + # again and set it. + cls = bl.__class__ + prop_func, prop_def = getattr(cls, bounds_attr) + RemoveProperty(cls, attr=bounds_attr) + del prop_def["attr"] + + # Remove the things we don't want in a copy to prevent hosing the new property. + old_prop_def = dict(prop_def) + del old_prop_def["get"] + del old_prop_def["set"] + setattr(cls, bounds_attr, prop_func(**old_prop_def)) + bounds_value_new = getattr(bl, bounds_attr) + + # Re-register new property. + RemoveProperty(cls, attr=bounds_attr) + setattr(cls, bounds_attr, prop_func(**prop_def)) + + # Only set the property if the value different to avoid thrashing and log spam. + if bounds_value_curr != bounds_value_new: + print(f"Stashing bounds property: [{bl.name}] ({cls.__name__}) {bounds_value_curr} -> {bounds_value_new}") # TEMP + setattr(bl, bounds_attr, bounds_value_new) diff --git a/korman/nodes/node_conditions.py b/korman/nodes/node_conditions.py index e24f798..7d37d6d 100644 --- a/korman/nodes/node_conditions.py +++ b/korman/nodes/node_conditions.py @@ -23,9 +23,10 @@ from typing import * from .. import enum_props from .node_core import * +from .node_deprecated import PlasmaVersionedNode from .. import idprops -class PlasmaClickableNode(idprops.IDPropObjectMixin, PlasmaNodeBase, bpy.types.Node): +class PlasmaClickableNode(idprops.IDPropObjectMixin, PlasmaVersionedNode, bpy.types.Node): bl_category = "CONDITIONS" bl_idname = "PlasmaClickableNode" bl_label = "Clickable" @@ -40,9 +41,9 @@ class PlasmaClickableNode(idprops.IDPropObjectMixin, PlasmaNodeBase, bpy.types.N poll=idprops.poll_mesh_objects) bounds = enum_props.bounds( - "clickable_object", store_on_collider=False, + "clickable_object", name="Bounds", - description="Clickable's bounds (NOTE: only used if your clickable is not a collider)", + description="Clickable's bounds", default="hull" ) @@ -154,8 +155,20 @@ class PlasmaClickableNode(idprops.IDPropObjectMixin, PlasmaNodeBase, bpy.types.N def _idprop_mapping(cls): return {"clickable_object": "clickable"} + @property + def latest_version(self): + return 2 + + def upgrade(self): + # In version 1 of this node, the bounds type was stored on this node. This could + # be overridden by whatever was in the collision modifier. Version 2 changes the + # bounds property to proxy to the collision modifier's bounds settings. + if self.version == 2: + enum_props.upgrade_bounds(self, "bounds") + self.version = 2 -class PlasmaClickableRegionNode(idprops.IDPropObjectMixin, PlasmaNodeBase, bpy.types.Node): + +class PlasmaClickableRegionNode(idprops.IDPropObjectMixin, PlasmaVersionedNode, bpy.types.Node): bl_category = "CONDITIONS" bl_idname = "PlasmaClickableRegionNode" bl_label = "Clickable Region Settings" @@ -166,9 +179,9 @@ class PlasmaClickableRegionNode(idprops.IDPropObjectMixin, PlasmaNodeBase, bpy.t type=bpy.types.Object, poll=idprops.poll_mesh_objects) bounds = enum_props.bounds( - "region_object", store_on_collider=False, + "region_object", name="Bounds", - description="Physical object's bounds (NOTE: only used if your clickable is not a collider)", + description="Physical object's bounds", default="hull" ) @@ -220,6 +233,18 @@ class PlasmaClickableRegionNode(idprops.IDPropObjectMixin, PlasmaNodeBase, bpy.t def _idprop_mapping(cls): return {"region_object": "region"} + @property + def latest_version(self): + return 2 + + def upgrade(self): + # In version 1 of this node, the bounds type was stored on this node. This could + # be overridden by whatever was in the collision modifier. Version 2 changes the + # bounds property to proxy to the collision modifier's bounds settings. + if self.version == 1: + enum_props.upgrade_bounds(self, "bounds") + self.version = 2 + class PlasmaClickableRegionSocket(PlasmaNodeSocketBase, bpy.types.NodeSocket): bl_color = (0.412, 0.0, 0.055, 1.0) @@ -400,7 +425,7 @@ class PlasmaVolumeReportNode(PlasmaNodeBase, bpy.types.Node): row.prop(self, "threshold", text="") -class PlasmaVolumeSensorNode(idprops.IDPropObjectMixin, PlasmaNodeBase, bpy.types.Node): +class PlasmaVolumeSensorNode(idprops.IDPropObjectMixin, PlasmaVersionedNode, bpy.types.Node): bl_category = "CONDITIONS" bl_idname = "PlasmaVolumeSensorNode" bl_label = "Region Sensor" @@ -425,7 +450,7 @@ class PlasmaVolumeSensorNode(idprops.IDPropObjectMixin, PlasmaNodeBase, bpy.type type=bpy.types.Object, poll=idprops.poll_mesh_objects) bounds = enum_props.bounds( - "region_object", store_on_collider=False, + "region_object", name="Bounds", description="Physical object's bounds" ) @@ -593,6 +618,18 @@ class PlasmaVolumeSensorNode(idprops.IDPropObjectMixin, PlasmaNodeBase, bpy.type return (self.find_input_socket("exit").allow or self.find_input("exit", "PlasmaVolumeReportNode") is not None) + @property + def latest_version(self): + return 2 + + def upgrade(self): + # In version 1 of this node, the bounds type was stored on this node. This could + # be overridden by whatever was in the collision modifier. Version 2 changes the + # bounds property to proxy to the collision modifier's bounds settings. + if self.version == 1: + enum_props.upgrade_bounds(self, "bounds") + self.version = 2 + class PlasmaVolumeSettingsSocket(PlasmaNodeSocketBase): bl_color = (43.1, 24.7, 0.0, 1.0) diff --git a/korman/properties/modifiers/base.py b/korman/properties/modifiers/base.py index 2a984a2..977c069 100644 --- a/korman/properties/modifiers/base.py +++ b/korman/properties/modifiers/base.py @@ -263,7 +263,7 @@ def _restore_properties(dummy): # again and BOOM--there are no deprecated properties available. Therefore, # we reregister them here. for mod_cls in PlasmaModifierUpgradable.__subclasses__(): - for prop_name in mod_cls.deprecated_properties: + for prop_name in getattr(mod_cls, "deprecated_properties", []): # Unregistered propertes are a sequence of (property function, # property keyword arguments). Interesting design decision :) prop_cb, prop_kwargs = getattr(mod_cls, prop_name) @@ -283,6 +283,6 @@ def _upgrade_modifiers(dummy): # Now that everything is upgraded, forcibly remove all properties # from the modifiers to prevent sneaky zombie-data type export bugs for mod_cls in PlasmaModifierUpgradable.__subclasses__(): - for prop in mod_cls.deprecated_properties: + for prop in getattr(mod_cls, "deprecated_properties", []): RemoveProperty(mod_cls, attr=prop) bpy.app.handlers.load_post.append(_upgrade_modifiers) diff --git a/korman/properties/modifiers/region.py b/korman/properties/modifiers/region.py index 3fa3376..5dc903c 100644 --- a/korman/properties/modifiers/region.py +++ b/korman/properties/modifiers/region.py @@ -22,7 +22,7 @@ from ...exporter import ExportError, ExportAssertionError from ...helpers import bmesh_from_object from ... import idprops -from .base import PlasmaModifierProperties, PlasmaModifierLogicWiz +from .base import PlasmaModifierProperties, PlasmaModifierUpgradable, PlasmaModifierLogicWiz from ... import enum_props from ..prop_camera import PlasmaCameraProperties @@ -128,7 +128,7 @@ class PlasmaCameraRegion(PlasmaModifierProperties): return self.camera_type == "auto_follow" -class PlasmaFootstepRegion(PlasmaModifierProperties, PlasmaModifierLogicWiz): +class PlasmaFootstepRegion(PlasmaModifierProperties, PlasmaModifierUpgradable, PlasmaModifierLogicWiz): pl_id = "footstep" bl_category = "Region" @@ -143,7 +143,6 @@ class PlasmaFootstepRegion(PlasmaModifierProperties, PlasmaModifierLogicWiz): default="stone" ) bounds = enum_props.bounds( - store_on_collider=False, name="Region Bounds", description="Physical object's bounds", default="hull" @@ -176,6 +175,16 @@ class PlasmaFootstepRegion(PlasmaModifierProperties, PlasmaModifierLogicWiz): def key_name(self): return "{}_FootRgn".format(self.id_data.name) + @property + def latest_version(self): + return 2 + + def upgrade(self): + # Version 2 converts the bounds type to a proxy to the collision modifier. + if self.current_version < 2: + enum_props.upgrade_bounds(self, "bounds") + self.current_version = 2 + class PlasmaPanicLinkRegion(PlasmaModifierProperties): pl_id = "paniclink" From adb8b232849299ee04752be74a40974d4aa84ec6 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sat, 22 Jun 2024 18:31:22 -0400 Subject: [PATCH 3/3] Unify animation naming and polling. --- korman/enum_props.py | 60 ++++++++++++++ korman/idprops.py | 80 +++++++++++++++++++ korman/nodes/node_messages.py | 81 ++++++------------- korman/nodes/node_python.py | 101 +++++++----------------- korman/properties/modifiers/game_gui.py | 34 ++------ 5 files changed, 201 insertions(+), 155 deletions(-) diff --git a/korman/enum_props.py b/korman/enum_props.py index 7dcc2cf..bbda865 100644 --- a/korman/enum_props.py +++ b/korman/enum_props.py @@ -20,6 +20,32 @@ from bpy.props import * from typing import * import warnings +# Workaround for Blender memory management limitation, +# don't change this to a literal in the code! +_ENTIRE_ANIMATION = "(Entire Animation)" + +def _get_object_animation_names(self, object_attr: str) -> Sequence[Tuple[str, str, str]]: + target_object = getattr(self, object_attr) + if target_object is not None: + items = [(anim.animation_name, anim.animation_name, "") + for anim in target_object.plasma_modifiers.animation.subanimations] + else: + items = [(_ENTIRE_ANIMATION, _ENTIRE_ANIMATION, "")] + + # We always want "(Entire Animation)", if it exists, to be the first item. + entire = items.index((_ENTIRE_ANIMATION, _ENTIRE_ANIMATION, "")) + if entire not in (-1, 0): + items.pop(entire) + items.insert(0, (_ENTIRE_ANIMATION, _ENTIRE_ANIMATION, "")) + + return items + +def animation(object_attr: str, **kwargs) -> str: + def get_items(self, context): + return _get_object_animation_names(self, object_attr) + + return EnumProperty(items=get_items, **kwargs) + # These are the kinds of physical bounds Plasma can work with. # This sequence is acceptable in any EnumProperty _bounds_types = ( @@ -105,3 +131,37 @@ def upgrade_bounds(bl, bounds_attr: str) -> None: if bounds_value_curr != bounds_value_new: print(f"Stashing bounds property: [{bl.name}] ({cls.__name__}) {bounds_value_curr} -> {bounds_value_new}") # TEMP setattr(bl, bounds_attr, bounds_value_new) + +def _get_texture_animation_names(self, object_attr: str, material_attr: str, texture_attr: str) -> Sequence[Tuple[str, str, str]]: + target_object = getattr(self, object_attr) + material = getattr(self, material_attr) + texture = getattr(self, texture_attr) + + if texture is not None: + items = [(anim.animation_name, anim.animation_name, "") + for anim in texture.plasma_layer.subanimations] + elif material is not None or target_object is not None: + if material is None: + materials = (i.material for i in target_object.material_slots if i and i.material) + else: + materials = (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 = [(_ENTIRE_ANIMATION, _ENTIRE_ANIMATION, "")] + + # We always want "(Entire Animation)", if it exists, to be the first item. + entire = items.index((_ENTIRE_ANIMATION, _ENTIRE_ANIMATION, "")) + if entire not in (-1, 0): + items.pop(entire) + items.insert(0, (_ENTIRE_ANIMATION, _ENTIRE_ANIMATION, "")) + + return items + +def triprop_animation(object_attr: str, material_attr: str, texture_attr: str, **kwargs) -> str: + def get_items(self, context): + return _get_texture_animation_names(self, object_attr, material_attr, texture_attr) + + assert not {"items", "get", "set"} & kwargs.keys(), "You cannot use the `items`, `get`, or `set` keyword arguments" + return EnumProperty(items=get_items, **kwargs) diff --git a/korman/idprops.py b/korman/idprops.py index 40cc474..d593c03 100644 --- a/korman/idprops.py +++ b/korman/idprops.py @@ -145,6 +145,86 @@ def poll_visregion_objects(self, value): def poll_envmap_textures(self, value): return isinstance(value, bpy.types.EnvironmentMapTexture) +def triprop_material(object_attr: str, material_attr: str, texture_attr: str, **kwargs) -> bpy.types.Material: + user_poll = kwargs.pop("poll", None) + + def poll_proc(self, value: bpy.types.Material) -> bool: + target_object = getattr(self, object_attr) + if target_object is None: + target_object = getattr(self, "id_data", None) + + # 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 target_object is not None: + object_materials = (slot.material for slot in target_object.material_slots if slot and slot.material) + result = value in object_materials + else: + result = True + + # Downstream processing, if any. + if result and user_poll is not None: + result = user_poll(self, value) + return result + + assert not "type" in kwargs + return PointerProperty( + type=bpy.types.Material, + poll=poll_proc, + **kwargs + ) + +def triprop_object(object_attr: str, material_attr: str, texture_attr: str, **kwargs) -> bpy.types.Texture: + assert not "type" in kwargs + if not "poll" in kwargs: + kwargs["poll"] = poll_drawable_objects + return PointerProperty( + type=bpy.types.Object, + **kwargs + ) + +def triprop_texture(object_attr: str, material_attr: str, texture_attr: str, **kwargs) -> bpy.types.Object: + user_poll = kwargs.pop("poll", None) + + def poll_proc(self, value: bpy.types.Texture) -> bool: + target_material = getattr(self, material_attr) + target_object = getattr(self, object_attr) + if target_object is None: + target_object = getattr(self, "id_data", None) + + # 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 target_material is not None: + result = value.name in target_material.texture_slots + elif target_object is not None: + for i in (slot.material for slot in 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): + result = True + break + else: + result = False + else: + result = False + + # Is it animated? + if result and target_material is not None: + result = ( + (target_material.animation_data is not None and target_material.animation_data.action is not None) + or (value.animation_data is not None and value.animation_data.action is not None) + ) + + # Downstream processing, if any. + if result and user_poll: + result = user_poll(self, value) + return result + + assert not "type" in kwargs + return PointerProperty( + type=bpy.types.Texture, + poll=poll_proc, + **kwargs + ) + @bpy.app.handlers.persistent def _upgrade_node_trees(dummy): """ diff --git a/korman/nodes/node_messages.py b/korman/nodes/node_messages.py index af1b5e1..ac1e582 100644 --- a/korman/nodes/node_messages.py +++ b/korman/nodes/node_messages.py @@ -21,6 +21,7 @@ from PyHSPlasma import * from typing import * +from .. import enum_props from .node_core import * from ..properties.modifiers.physics import subworld_types from ..properties.modifiers.region import footstep_surfaces, footstep_surface_ids @@ -113,39 +114,30 @@ class PlasmaAnimCmdMsgNode(idprops.IDPropMixin, PlasmaMessageWithCallbacksNode, ("TEXTURE", "Texture", "Texture Action")], default="OBJECT") - def _poll_texture(self, value): - # 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.target_material is not None: - return value.name in self.target_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 + def _poll_target_object(self, value: bpy.types.Object) -> bool: + if self.anim_type == "TEXTURE": + return idprops.poll_drawable_objects(self, value) + elif self.anim_type == "MESH": + return idprops.poll_animated_objects(self, value) else: - return True - - def _poll_material(self, value): - # 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 + raise RuntimeError() - target_object = PointerProperty(name="Object", - description="Target object", - type=bpy.types.Object) - target_material = PointerProperty(name="Material", - description="Target material", - type=bpy.types.Material, - poll=_poll_material) - target_texture = PointerProperty(name="Texture", - description="Target texture", - type=bpy.types.Texture, - poll=_poll_texture) + target_object = idprops.triprop_object( + "target_object", "target_material", "target_texture", + name="Object", + description="Target object", + poll=_poll_target_object + ) + target_material = idprops.triprop_material( + "target_object", "target_material", "target_texture", + name="Material", + description="Target material" + ) + target_texture = idprops.triprop_texture( + "target_object", "target_material", "target_texture", + name="Texture", + description="Target texture" + ) go_to = EnumProperty(name="Go To", description="Where should the animation start?", @@ -205,37 +197,14 @@ class PlasmaAnimCmdMsgNode(idprops.IDPropMixin, PlasmaMessageWithCallbacksNode, ("kStop", "Stop", "When the action is stopped by a message")], default="kEnd") - # Blender memory workaround - _ENTIRE_ANIMATION = "(Entire Animation)" def _get_anim_names(self, context): if self.anim_type == "OBJECT": - items = [(anim.animation_name, anim.animation_name, "") - for anim in self.target_object.plasma_modifiers.animation.subanimations] + return enum_props._get_object_animation_names(self, "target_object") elif self.anim_type == "TEXTURE": - if self.target_texture is not None: - items = [(anim.animation_name, anim.animation_name, "") - for anim in self.target_texture.plasma_layer.subanimations] - elif self.target_material is not None or self.target_object is not None: - if self.target_material is None: - materials = (i.material for i in self.target_object.material_slots if i and i.material) - else: - materials = (self.target_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 = [(PlasmaAnimCmdMsgNode._ENTIRE_ANIMATION, PlasmaAnimCmdMsgNode._ENTIRE_ANIMATION, "")] + return enum_props._get_texture_animation_names(self, "target_object", "target_material", "target_texture") else: raise RuntimeError() - # We always want "(Entire Animation)", if it exists, to be the first item. - entire = items.index((PlasmaAnimCmdMsgNode._ENTIRE_ANIMATION, PlasmaAnimCmdMsgNode._ENTIRE_ANIMATION, "")) - if entire not in (-1, 0): - items.pop(entire) - items.insert(0, (PlasmaAnimCmdMsgNode._ENTIRE_ANIMATION, PlasmaAnimCmdMsgNode._ENTIRE_ANIMATION, "")) - - return items - anim_name = EnumProperty(name="Animation", description="Name of the animation to control", items=_get_anim_names, diff --git a/korman/nodes/node_python.py b/korman/nodes/node_python.py index a86d992..432a02a 100644 --- a/korman/nodes/node_python.py +++ b/korman/nodes/node_python.py @@ -21,6 +21,7 @@ from contextlib import contextmanager from pathlib import Path from PyHSPlasma import * +from .. import enum_props from .node_core import * from .node_deprecated import PlasmaDeprecatedNode, PlasmaVersionedNode from .. import idprops @@ -823,81 +824,43 @@ class PlasmaAttribTextureNode(idprops.IDPropMixin, PlasmaAttribNodeBase, bpy.typ 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 + if attrib == "ptAttribDynamicMap" and self._is_dyntext(value): + return True + elif attrib == "ptAttribMaterialAnimation" and not self._is_dyntext: + 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 + # We're not hooked up to a PFM node yet, so let anything slide. + return True - anim_name = EnumProperty(name="Animation", - description="Name of the animation to control", - items=_get_anim_names, - options=set()) + target_object = idprops.triprop_object( + "target_object", "material", "texture", + name="Object", + description="Target object" + ) + material = idprops.triprop_material( + "target_object", "material", "texture", + name="Material", + description="Material the texture is attached to" + ) + texture = idprops.triprop_texture( + "target_object", "material", "texture", + name="Texture", + description="Texture to expose to Python", + poll=_poll_texture + ) + + anim_name = enum_props.triprop_animation( + "target_object", "material", "texture", + name="Animation", + description="Name of the animation to control", + options=set() + ) def init(self, context): super().init(context) @@ -967,10 +930,6 @@ class PlasmaAttribTextureNode(idprops.IDPropMixin, PlasmaAttribNodeBase, bpy.typ 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 diff --git a/korman/properties/modifiers/game_gui.py b/korman/properties/modifiers/game_gui.py index d930264..fa3f2cf 100644 --- a/korman/properties/modifiers/game_gui.py +++ b/korman/properties/modifiers/game_gui.py @@ -203,26 +203,6 @@ class GameGuiAnimation(bpy.types.PropertyGroup): else: return idprops.poll_drawable_objects(self, value) - def _poll_texture(self, value): - # 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.target_material is not None: - return value.name in self.target_material.texture_slots - else: - target_object = self.target_object if self.target_object is not None else self.id_data - for i in (slot.material for slot in 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 - - def _poll_material(self, value): - # 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. - target_object = self.target_object if self.target_object is not None else self.id_data - object_materials = (slot.material for slot in target_object.material_slots if slot and slot.material) - return value in object_materials - anim_type: str = EnumProperty( name="Type", description="Animation type to affect", @@ -233,23 +213,21 @@ class GameGuiAnimation(bpy.types.PropertyGroup): default="OBJECT", options=set() ) - target_object: bpy.types.Object = PointerProperty( + target_object: bpy.types.Object = idprops.triprop_object( + "target_object", "target_material", "target_texure", name="Object", description="Target object", poll=_poll_target_object, - type=bpy.types.Object ) - target_material: bpy.types.Material = PointerProperty( + target_material: bpy.types.Material = idprops.triprop_material( + "target_object", "target_material", "target_texure", name="Material", description="Target material", - type=bpy.types.Material, - poll=_poll_material ) - target_texture: bpy.types.Texture = PointerProperty( + target_texture: bpy.types.Texture = idprops.triprop_texture( + "target_object", "target_material", "target_texure", name="Texture", description="Target texture", - type=bpy.types.Texture, - poll=_poll_texture )