From b36218aa57e99fd62700c7c8c483b68788960c47 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Wed, 11 Aug 2021 20:48:33 -0400 Subject: [PATCH] Update LogicWiz modifiers for deferred logic export. In a previous changeset, the Advanced Logic modifier was changed to defer exporting logic until the very end of the export process. This means that many nodes are designed with the assumption that all non-logic PRP objects are fully exported by the time they are exported. LogicWiz modifiers, however, violated this assumption by exporting during the main export() phase. So, this adds a new `pre_export()` phase for all modifiers that lets them generate logic trees and even entirely new Plasma Objects safely. Futher, old LogicWiz modifiers have been tweaked to not leak junked objects if the export fails in the middle of those modifiers. --- korman/exporter/convert.py | 64 +++++++++++++++- korman/exporter/utils.py | 5 +- korman/operators/op_modifier.py | 2 +- korman/properties/modifiers/avatar.py | 17 ++--- korman/properties/modifiers/base.py | 39 +++++++--- korman/properties/modifiers/gui.py | 106 +++++++++++--------------- korman/properties/modifiers/logic.py | 2 +- korman/properties/modifiers/region.py | 4 - 8 files changed, 149 insertions(+), 90 deletions(-) diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index 811e313..bf126ff 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -15,8 +15,12 @@ import bpy +from collections import defaultdict +from contextlib import ExitStack +import functools from pathlib import Path +from ..helpers import TemporaryObject from ..korlib import ConsoleToggler from PyHSPlasma import * @@ -41,12 +45,12 @@ class Exporter: self._op = op # Blender export operator self._objects = [] self.actors = set() - self.want_node_trees = {} + self.want_node_trees = defaultdict(set) self.exported_nodes = {} def run(self): log = logger.ExportVerboseLogger if self._op.verbose else logger.ExportProgressLogger - with ConsoleToggler(self._op.show_console), log(self._op.filepath) as self.report: + with ConsoleToggler(self._op.show_console), log(self._op.filepath) as self.report, ExitStack() as self.exit_stack: # Step 0: Init export resmgr and stuff self.mgr = manager.ExportManager(self) self.mesh = mesh.MeshConverter(self) @@ -64,6 +68,7 @@ class Exporter: self.mesh.add_progress_presteps(self.report) self.report.progress_add_step("Collecting Objects") self.report.progress_add_step("Verify Competence") + self.report.progress_add_step("Touching the Intangible") self.report.progress_add_step("Harvesting Actors") if self._op.lighting_method != "skip": etlight.LightBaker.add_progress_steps(self.report) @@ -89,6 +94,10 @@ class Exporter: # is no ruddy funny business going on. self._check_sanity() + # Step 2.2: Run through all the objects again and ask them to "pre_export" themselves. + # In other words, generate any ephemeral Blender objects that need to be exported. + self._pre_export_scene_objects() + # Step 2.5: Run through all the objects we collected in Step 2 and see if any relationships # that the artist made requires something to have a CoordinateInterface self._harvest_actors() @@ -381,6 +390,57 @@ class Exporter: proc(self, bl_obj, sceneobject) inc_progress() + def _pre_export_scene_objects(self): + self.report.progress_advance() + self.report.progress_range = len(self._objects) + inc_progress = self.report.progress_increment + self.report.msg("\nGenerating export dependency objects...") + + # New objects may be generate during this process; they will be appended at the end. + new_objects = [] + + @functools.singledispatch + def handle_temporary(temporary, parent): + raise RuntimeError("Temporary object of type '{}' generated by '{}' was unhandled".format(temporary.__class__, parent.name)) + + @handle_temporary.register(bpy.types.Object) + def _(temporary, parent): + self.exit_stack.enter_context(TemporaryObject(temporary, bpy.data.objects.remove)) + self.report.msg("'{}': generated Object '{}' (Plasma Object: {})", parent.name, + temporary.name, temporary.plasma_object.enabled, indent=1) + if temporary.plasma_object.enabled: + new_objects.append(temporary) + + # If the object is marked as a Plasma Object, be sure that we go into the same page + # as the requestor, unless the modifier decided it knows better. + if not temporary.plasma_object.property_set("page"): + temporary.plasma_object.page = parent.plasma_object.page + + # Wow, recursively generated objects. Aren't you special? + for mod in temporary.plasma_modifiers.modifiers: + mod.sanity_check() + do_pre_export(temporary) + + @handle_temporary.register(bpy.types.NodeTree) + def _(temporary, parent): + self.exit_stack.enter_context(TemporaryObject(temporary, bpy.data.node_groups.remove)) + self.report.msg("'{}' generated NodeTree '{}'", parent.name, temporary.name) + if temporary.bl_idname == "PlasmaNodeTree": + parent_so = self.mgr.find_create_object(plSceneObject, bl=parent) + self.want_node_trees[temporary.name].add((parent, parent_so)) + + def do_pre_export(bo): + for mod in bo.plasma_modifiers.modifiers: + for i in filter(None, mod.pre_export(self, bo)): + handle_temporary(i, bo) + + for bl_obj in self._objects: + do_pre_export(bl_obj) + inc_progress() + + self.report.msg("... {} new object(s) were generated!", len(new_objects)) + self._objects += new_objects + def _pack_ancillary_python(self): texts = bpy.data.texts self.report.progress_advance() diff --git a/korman/exporter/utils.py b/korman/exporter/utils.py index 87d894b..cdae5b7 100644 --- a/korman/exporter/utils.py +++ b/korman/exporter/utils.py @@ -15,7 +15,8 @@ import bmesh import bpy -from typing import Callable + +from typing import Callable, Iterator, Tuple from contextlib import contextmanager from PyHSPlasma import * @@ -73,7 +74,7 @@ def bmesh_temporary_object(name : str, factory : Callable, page_name : str=None) bpy.context.scene.objects.unlink(obj) @contextmanager -def bmesh_object(name : str): +def bmesh_object(name: str) -> Iterator[Tuple[bpy.types.Object, bmesh.types.BMesh]]: """Creates an object and mesh that will be removed if the context is exited due to an error""" mesh = bpy.data.meshes.new(name) diff --git a/korman/operators/op_modifier.py b/korman/operators/op_modifier.py index 405ee3f..9fb18cc 100644 --- a/korman/operators/op_modifier.py +++ b/korman/operators/op_modifier.py @@ -346,7 +346,7 @@ class ModifierLogicWizOperator(ModifierOperator, bpy.types.Operator): print("WRN: This modifier is not actually enabled!") start = time.process_time() - mod.logicwiz(obj) + mod.create_logic(obj) end = time.process_time() print("\nLogicWiz finished in {:.2f} seconds".format(end-start)) return {"FINISHED"} diff --git a/korman/properties/modifiers/avatar.py b/korman/properties/modifiers/avatar.py index c5f1ed1..3bc2e80 100644 --- a/korman/properties/modifiers/avatar.py +++ b/korman/properties/modifiers/avatar.py @@ -115,19 +115,9 @@ class PlasmaSittingBehavior(idprops.IDPropObjectMixin, PlasmaModifierProperties, description="How far away we will tolerate the avatar facing the clickable", min=-180, max=180, default=45) - def export(self, exporter, bo, so): - # The user absolutely MUST specify a clickable or this won't export worth crap. - if self.clickable_object is None: - raise ExportError("'{}': Sitting Behavior's clickable object is invalid".format(self.key_name)) - - # Generate the logic nodes now - with self.generate_logic(bo) as tree: - tree.export(exporter, bo, so) - def harvest_actors(self): if self.facing_enabled: - return (self.clickable_object.name,) - return () + yield self.clickable_object.name def logicwiz(self, bo, tree): nodes = tree.nodes @@ -177,3 +167,8 @@ class PlasmaSittingBehavior(idprops.IDPropObjectMixin, PlasmaModifierProperties, def requires_actor(self): # This should be an empty, really... return True + + def sanity_check(self): + # The user absolutely MUST specify a clickable or this won't export worth crap. + if self.clickable_object is None: + raise ExportError("'{}': Sitting Behavior's clickable object is invalid".format(self.key_name)) diff --git a/korman/properties/modifiers/base.py b/korman/properties/modifiers/base.py index 0f52486..35bf255 100644 --- a/korman/properties/modifiers/base.py +++ b/korman/properties/modifiers/base.py @@ -13,10 +13,11 @@ # 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 contextlib import contextmanager + +import abc +from typing import Generator class PlasmaModifierProperties(bpy.types.PropertyGroup): @property @@ -49,6 +50,13 @@ class PlasmaModifierProperties(bpy.types.PropertyGroup): def enabled(self): return self.display_order >= 0 + def export(self, exporter, bo, so): + """This is the main phase of the modifier export where most, if not all, PRP objects should + be generated. No new Blender objects should be created unless their lifespan is constrained + to the duration of this method. + """ + pass + @property def face_sort(self): """Indicates that the geometry's faces should be sorted by the engine""" @@ -72,6 +80,14 @@ class PlasmaModifierProperties(bpy.types.PropertyGroup): Drawables that will render in the same pass""" return False + def pre_export(self, exporter, bo: bpy.types.Object) -> Generator: + """This is the first phase of the modifier export; allowing modifiers to create additonal + objects or logic nodes to be used by the exporter. To do so, overload this method + and yield any Blender ID from your method. That ID will then be exported and deleted + when the export completes. PRP objects should generally not be exported in this phase. + """ + yield + @property def requires_actor(self): """Indicates if this modifier requires the object to be a movable actor""" @@ -96,25 +112,30 @@ class PlasmaModifierProperties(bpy.types.PropertyGroup): class PlasmaModifierLogicWiz: - @contextmanager - def generate_logic(self, bo, **kwargs): + def convert_logic(self, bo, **kwargs): + """Creates, converts, and returns an unmanaged NodeTree for this logic wizard. If the wizard + fails during conversion, the temporary tree is deleted for you. However, on success, you + are responsible for removing the tree from Blender, if applicable.""" name = kwargs.pop("name", self.key_name) assert not "tree" in kwargs tree = bpy.data.node_groups.new(name, "PlasmaNodeTree") kwargs["tree"] = tree try: self.logicwiz(bo, **kwargs) - yield tree - finally: + except: bpy.data.node_groups.remove(tree) + raise + else: + return tree @abc.abstractmethod def logicwiz(self, bo, tree): pass - def export_logic(self, exporter, bo, so, **kwargs): - with self.generate_logic(bo, **kwargs) as tree: - tree.export(exporter, bo, so) + def pre_export(self, exporter, bo): + """Default implementation of the pre_export phase for logic wizards that simply triggers + the logic nodes to be created and for their export to be scheduled.""" + yield self.convert_logic(bo) class PlasmaModifierUpgradable: diff --git a/korman/properties/modifiers/gui.py b/korman/properties/modifiers/gui.py index a9a94da..9c6f93f 100644 --- a/korman/properties/modifiers/gui.py +++ b/korman/properties/modifiers/gui.py @@ -25,8 +25,6 @@ from ...addon_prefs import game_versions from ...exporter import ExportError, utils from .base import PlasmaModifierProperties, PlasmaModifierLogicWiz, PlasmaModifierUpgradable from ... import idprops -from ...helpers import TemporaryObject - journal_pfms = { pvPots : { @@ -198,7 +196,7 @@ class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz get=_get_translation, set=_set_translation, options=set()) - def export(self, exporter, bo, so): + def pre_export(self, exporter, bo): our_versions = (globals()[j] for j in self.versions) version = exporter.mgr.getVer() if version not in our_versions: @@ -217,32 +215,21 @@ class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz exporter.locman.add_journal(self.key_name, i.language, i.text_id, indent=2) if self.clickable_region is None: - # Create a region for the clickable's condition - rgn_mesh = bpy.data.meshes.new("{}_Journal_ClkRgn".format(self.key_name)) - self.temp_rgn = bpy.data.objects.new("{}_Journal_ClkRgn".format(self.key_name), rgn_mesh) - bm = bmesh.new() - bmesh.ops.create_cube(bm, size=(6.0)) - bmesh.ops.transform(bm, matrix=mathutils.Matrix.Translation(bo.location - self.temp_rgn.location), space=self.temp_rgn.matrix_world, verts=bm.verts) - bm.to_mesh(rgn_mesh) - bm.free() - - # No need to enable the object as a Plasma object; we're exported automatically as part of the node tree. - # It does need a page, however, so we'll put it in the same place as the journal object itself. - self.temp_rgn.plasma_object.page = bo.plasma_object.page - bpy.context.scene.objects.link(self.temp_rgn) + with utils.bmesh_object("{}_Journal_ClkRgn".format(self.key_name)) as (rgn_obj, bm): + bmesh.ops.create_cube(bm, size=(6.0)) + bmesh.ops.transform(bm, matrix=mathutils.Matrix.Translation(bo.location - rgn_obj.location), + space=rgn_obj.matrix_world, verts=bm.verts) + rgn_obj.plasma_object.enabled = True + rgn_obj.hide_render = True + yield rgn_obj else: # Use the region provided - self.temp_rgn = self.clickable_region + rgn_obj = self.clickable_region # Generate the logic nodes - with self.generate_logic(bo, age_name=exporter.age_name, version=version) as tree: - tree.export(exporter, bo, so) - - # Get rid of our temporary clickable region - if self.clickable_region is None: - bpy.context.scene.objects.unlink(self.temp_rgn) + yield self.convert_logic(bo, age_name=exporter.age_name, rgn_obj=rgn_obj, version=version) - def logicwiz(self, bo, tree, age_name, version): + def logicwiz(self, bo, tree, age_name, rgn_obj, version): nodes = tree.nodes # Assign journal script based on target version @@ -261,13 +248,13 @@ class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz journalnode.update() if version <= pvPots: - self._create_pots_nodes(bo, nodes, journalnode, age_name) + self._create_pots_nodes(bo, nodes, journalnode, age_name, rgn_obj) else: - self._create_moul_nodes(bo, nodes, journalnode, age_name) + self._create_moul_nodes(bo, nodes, journalnode, age_name, rgn_obj) - def _create_pots_nodes(self, clickable_object, nodes, journalnode, age_name): + def _create_pots_nodes(self, clickable_object, nodes, journalnode, age_name, rgn_obj): clickable_region = nodes.new("PlasmaClickableRegionNode") - clickable_region.region_object = self.temp_rgn + clickable_region.region_object = rgn_obj facing_object = nodes.new("PlasmaFacingTargetNode") facing_object.directional = False @@ -295,9 +282,9 @@ class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz height.link_output(journalnode, "pfm", "BookHeight") height.value_float = self.book_scale_h / 100.0 - def _create_moul_nodes(self, clickable_object, nodes, journalnode, age_name): + def _create_moul_nodes(self, clickable_object, nodes, journalnode, age_name, rgn_obj): clickable_region = nodes.new("PlasmaClickableRegionNode") - clickable_region.region_object = self.temp_rgn + clickable_region.region_object = rgn_obj facing_object = nodes.new("PlasmaFacingTargetNode") facing_object.directional = False @@ -456,22 +443,33 @@ class PlasmaLinkingBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz default=255, subtype="UNSIGNED") - def export(self, exporter, bo, so): - our_versions = (globals()[j] for j in self.versions) - version = exporter.mgr.getVer() - if version not in our_versions: + def _check_version(self, *args) -> bool: + our_versions = frozenset((globals()[j] for j in self.versions)) + return frozenset(args) & our_versions + + def pre_export(self, exporter, bo): + if not self._check_version(exporter.mgr.getVer()): # We aren't needed here exporter.report.port("Object '{}' has a LinkingBookMod not enabled for export to the selected engine. Skipping.", - bo.name, indent=2) + self.id_data.name, indent=2) return - if self.clickable is None: - raise ExportError("{}: Linking Book modifier requires a clickable!", bo.name) + # Auto-generate a six-foot cube region around the clickable if none was provided. + if self.clickable_region is None: + with utils.bmesh_object("{}_LinkingBook_ClkRgn".format(self.key_name)) as (rgn_obj, bm): + bmesh.ops.create_cube(bm, size=(6.0)) + rgn_offset = mathutils.Matrix.Translation(self.clickable.location - bo.location) + bmesh.ops.transform(bm, matrix=rgn_offset, space=bo.matrix_world, verts=bm.verts) + rgn_obj.plasma_object.enabled = True + rgn_obj.hide_render = True + yield rgn_obj + else: + rgn_obj = self.clickable_region - if self.seek_point is None: - raise ExportError("{}: Linking Book modifier requires a seek point!", bo.name) + yield self.convert_logic(bo, age_name=exporter.age_name, version=exporter.mgr.getVer(), region=rgn_obj) - if version <= pvPots: + def export(self, exporter, bo, so): + if self._check_version(pvPrime, pvPots): # Create ImageLibraryMod in which to store the Cover, Linking Panel, and Stamp images ilmod = exporter.mgr.find_create_object(plImageLibMod, so=so, name=self.key_name) @@ -481,23 +479,9 @@ class PlasmaLinkingBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz exporter.mesh.material.export_prepared_image(owner=ilmod, image=image, allowed_formats={"JPG", "PNG"}, extension="hsm") - # Auto-generate a six-foot cube region around the clickable if none was provided. - if self.clickable_region is None: - # Create a region for the clickable's condition - def _make_rgn(bm): - bmesh.ops.create_cube(bm, size=(6.0)) - rgn_offset = mathutils.Matrix.Translation(self.clickable.location - bo.location) - bmesh.ops.transform(bm, matrix=rgn_offset, space=bo.matrix_world, verts=bm.verts) - - with utils.bmesh_temporary_object("{}_LinkingBook_ClkRgn".format(self.key_name), - _make_rgn, self.clickable.plasma_object.page) as temp_rgn: - # Generate the logic nodes - self.export_logic(exporter, bo, so, age_name=exporter.age_name, version=version, - region=temp_rgn) - else: - # Generate the logic nodes - self.export_logic(exporter, bo, so, age_name=exporter.age_name, version=version, - region=self.clickable_region) + def harvest_actors(self): + if self.seek_point is not None: + yield self.seek_point.name def logicwiz(self, bo, tree, age_name, version, region): nodes = tree.nodes @@ -655,6 +639,8 @@ class PlasmaLinkingBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz linking_panel_name.value = self.link_destination if self.link_destination else self.age_name linking_panel_name.link_output(linkingnode, "pfm", "TargetAge") - def harvest_actors(self): - if self.seek_point is not None: - yield self.seek_point.name + def sanity_check(self): + if self.clickable is None: + raise ExportError("{}: Linking Book modifier requires a clickable!", self.id_data.name) + if self.seek_point is None: + raise ExportError("{}: Linking Book modifier requires a seek point!", self.id_data.name) diff --git a/korman/properties/modifiers/logic.py b/korman/properties/modifiers/logic.py index f31ed1a..2fcec98 100644 --- a/korman/properties/modifiers/logic.py +++ b/korman/properties/modifiers/logic.py @@ -60,7 +60,7 @@ class PlasmaAdvancedLogic(PlasmaModifierProperties): raise ExportError("'{}': Advanced Logic is missing a node tree for '{}'".format(bo.name, i.name)) # Defer node tree export until all trees are harvested. - exporter.want_node_trees.setdefault(i.node_tree.name, set()).add((bo, so)) + exporter.want_node_trees[i.node_tree.name].add((bo, so)) def harvest_actors(self): actors = set() diff --git a/korman/properties/modifiers/region.py b/korman/properties/modifiers/region.py index fda1bc4..8c887de 100644 --- a/korman/properties/modifiers/region.py +++ b/korman/properties/modifiers/region.py @@ -143,10 +143,6 @@ class PlasmaFootstepRegion(PlasmaModifierProperties, PlasmaModifierLogicWiz): items=bounds_types, default="hull") - def export(self, exporter, bo, so): - with self.generate_logic(bo) as tree: - tree.export(exporter, bo, so) - def logicwiz(self, bo, tree): nodes = tree.nodes