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..388e4e8 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 Any, Dict, 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,42 @@ 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 + + def _create_python_file_node(self, tree, filename: str, attributes: Dict[str, Any]) -> bpy.types.Node: + pfm_node = tree.nodes.new("PlasmaPythonFileNode") + with pfm_node.NoUpdate(): + pfm_node.filename = filename + for attr in attributes: + new_attr = pfm_node.attributes.add() + new_attr.attribute_id = attr["id"] + new_attr.attribute_type = attr["type"] + new_attr.attribute_name = attr["name"] + pfm_node.update() + return pfm_node @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..164405e 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,57 +215,32 @@ 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) - - def logicwiz(self, bo, tree, age_name, version): - nodes = tree.nodes + yield self.convert_logic(bo, age_name=exporter.age_name, rgn_obj=rgn_obj, version=version) + def logicwiz(self, bo, tree, age_name, rgn_obj, version): # Assign journal script based on target version journal_pfm = journal_pfms[version] - journalnode = nodes.new("PlasmaPythonFileNode") - with journalnode.NoUpdate(): - journalnode.filename = journal_pfm["filename"] - - # Manually add required attributes to the PFM - journal_attribs = journal_pfm["attribs"] - for attr in journal_attribs: - new_attr = journalnode.attributes.add() - new_attr.attribute_id = attr["id"] - new_attr.attribute_type = attr["type"] - new_attr.attribute_name = attr["name"] - journalnode.update() - + journalnode = self._create_python_file_node(tree, journal_pfm["filename"], journal_pfm["attribs"]) if version <= pvPots: - self._create_pots_nodes(bo, nodes, journalnode, age_name) + self._create_pots_nodes(bo, tree.nodes, journalnode, age_name, rgn_obj) else: - self._create_moul_nodes(bo, nodes, journalnode, age_name) + self._create_moul_nodes(bo, tree.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 +268,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 +429,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,46 +465,18 @@ 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 - # Assign linking book script based on target version linking_pfm = linking_pfms[version] - linkingnode = nodes.new("PlasmaPythonFileNode") - with linkingnode.NoUpdate(): - linkingnode.filename = linking_pfm["filename"] - - # Manually add required attributes to the PFM - linking_attribs = linking_pfm["attribs"] - for attr in linking_attribs: - new_attr = linkingnode.attributes.add() - new_attr.attribute_id = attr["id"] - new_attr.attribute_type = attr["type"] - new_attr.attribute_name = attr["name"] - linkingnode.update() - + linkingnode = self._create_python_file_node(tree, linking_pfm["filename"], linking_pfm["attribs"]) if version <= pvPots: - self._create_pots_nodes(bo, nodes, linkingnode, age_name, region) + self._create_pots_nodes(bo, tree.nodes, linkingnode, age_name, region) else: - self._create_moul_nodes(bo, nodes, linkingnode, age_name, region) + self._create_moul_nodes(bo, tree.nodes, linkingnode, age_name, region) def _create_pots_nodes(self, clickable_object, nodes, linkingnode, age_name, clk_region): # Clickable @@ -655,6 +611,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