From 3c5e2d61d52719318412badf3f46dfc08a96e6ea Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sun, 18 Feb 2024 18:11:17 -0500 Subject: [PATCH 1/2] Debounce GUI object duplication from the Note Popup modifier. The modifier was originally written with the assumption that exactly one modifier controlled exactly one page. Of course, artists began using multiple Note Popup modifiers to show the same GUI page in multiple places. So, be sure that we only export the GUI objects once. --- korman/exporter/gui.py | 84 ++++++++++++++++++++++++++++++ korman/properties/modifiers/gui.py | 78 +-------------------------- 2 files changed, 86 insertions(+), 76 deletions(-) diff --git a/korman/exporter/gui.py b/korman/exporter/gui.py index 0a0a117..10c9842 100644 --- a/korman/exporter/gui.py +++ b/korman/exporter/gui.py @@ -32,6 +32,7 @@ from . import utils if TYPE_CHECKING: from .convert import Exporter from .logger import _ExportLogger as ExportLogger + from ..properties.modifiers.game_gui import * class Clipping(NamedTuple): @@ -48,9 +49,11 @@ class GuiConverter: if TYPE_CHECKING: _parent: weakref.ref[Exporter] = ... + _mods_exported: Set[str] = ... def __init__(self, parent: Optional[Exporter] = None): self._parent = weakref.ref(parent) if parent is not None else None + self._mods_exported = set() # Go ahead and prepare the GUI transparent material for future use. if parent is not None: @@ -203,6 +206,87 @@ class GuiConverter: w2c[2, i] *= -1.0 return PostEffectModMatrices(c2w, w2c) + def create_note_gui(self, gui_page: str, gui_camera: bpy.types.Object): + if not gui_page in self._mods_exported: + guidialog_object = utils.create_empty_object(f"{gui_page}_NoteDialog") + guidialog_object.plasma_object.enabled = True + guidialog_object.plasma_object.page = gui_page + yield guidialog_object + + guidialog_mod: PlasmaGameGuiDialogModifier = guidialog_object.plasma_modifiers.gui_dialog + guidialog_mod.enabled = True + guidialog_mod.is_modal = True + if gui_camera is not None: + guidialog_mod.camera_object = gui_camera + else: + # Abuse the GUI Dialog's lookat caLculation to make us a camera that looks at everything + # the artist has placed into the GUI page. We want to do this NOW because we will very + # soon be adding more objects into the GUI page. + camera_object = yield utils.create_camera_object(f"{gui_page}_GUICamera") + camera_object.data.angle = math.radians(45.0) + camera_object.data.lens_unit = "FOV" + + visible_objects = [ + i for i in self._parent().get_objects(gui_page) + if i.type == "MESH" and i.data.materials + ] + camera_object.matrix_world = self.calc_camera_matrix( + bpy.context.scene, + visible_objects, + camera_object.data.angle + ) + clipping = self.calc_clipping( + camera_object.matrix_world, + bpy.context.scene, + visible_objects, + camera_object.data.angle + ) + camera_object.data.clip_start = clipping.hither + camera_object.data.clip_end = clipping.yonder + guidialog_mod.camera_object = camera_object + + # Begin creating the object for the clickoff plane. We want to yield it immediately + # to the exporter in case something goes wrong during the export, allowing the stale + # object to be cleaned up. + click_plane_object = utils.BMeshObject(f"{gui_page}_Exit") + click_plane_object.matrix_world = guidialog_mod.camera_object.matrix_world + click_plane_object.plasma_object.enabled = True + click_plane_object.plasma_object.page = gui_page + yield click_plane_object + + # We have a camera on guidialog_mod.camera_object. We will now use it to generate the + # points for the click-off plane button. + # TODO: Allow this to be configurable to 4:3, 16:9, or 21:9? + with ExitStack() as stack: + stack.enter_context(self.generate_camera_render_settings(bpy.context.scene)) + toggle = stack.enter_context(helpers.GoodNeighbor()) + + # Temporarily adjust the clipping plane out to the farthest point we can find to ensure + # that the click-off button ecompasses everything. This is a bit heavy-handed, but if + # you want more refined control, you won't be using this helper. + clipping = max((guidialog_mod.camera_object.data.clip_start, guidialog_mod.camera_object.data.clip_end)) + toggle.track(guidialog_mod.camera_object.data, "clip_start", clipping - 0.1) + view_frame = guidialog_mod.camera_object.data.view_frame(bpy.context.scene) + + click_plane_object.data.materials.append(self.transparent_material) + with click_plane_object as click_plane_mesh: + verts = [click_plane_mesh.verts.new(i) for i in view_frame] + face = click_plane_mesh.faces.new(verts) + # TODO: Ensure the face is pointing toward the camera! + # I feel like we should be fine by assuming that Blender returns the viewframe + # verts in the correct order, but this is Blender... So test that assumption carefully. + # TODO: Apparently not! + face.normal_flip() + + # We've now created the mesh object - handle the GUI Button stuff + click_plane_object.plasma_modifiers.gui_button.enabled = True + + # NOTE: We will be using xDialogToggle.py, so we use a special tag ID instead of the + # close dialog procedure. + click_plane_object.plasma_modifiers.gui_control.tag_id = 99 + + self._mods_exported.add(gui_page) + @contextmanager def generate_camera_render_settings(self, scene: bpy.types.Scene) -> Iterator[None]: # Set the render info to basically TV NTSC 4:3, which will set Blender's camera diff --git a/korman/properties/modifiers/gui.py b/korman/properties/modifiers/gui.py index 3efa136..8a24eee 100644 --- a/korman/properties/modifiers/gui.py +++ b/korman/properties/modifiers/gui.py @@ -730,82 +730,8 @@ class PlasmaNotePopupModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz): raise ExportError(f"Note Popup modifiers should be in a 'room' page, not a '{page_type}' page!") def pre_export(self, exporter: Exporter, bo: bpy.types.Object): - guidialog_object = utils.create_empty_object(f"{self.gui_page}_NoteDialog") - guidialog_object.plasma_object.enabled = True - guidialog_object.plasma_object.page = self.gui_page - yield guidialog_object - - guidialog_mod: PlasmaGameGuiDialogModifier = guidialog_object.plasma_modifiers.gui_dialog - guidialog_mod.enabled = True - guidialog_mod.is_modal = True - if self.gui_camera: - guidialog_mod.camera_object = self.gui_camera - else: - # Abuse the GUI Dialog's lookat caLculation to make us a camera that looks at everything - # the artist has placed into the GUI page. We want to do this NOW because we will very - # soon be adding more objects into the GUI page. - camera_object = yield utils.create_camera_object(f"{self.key_name}_GUICamera") - camera_object.data.angle = math.radians(45.0) - camera_object.data.lens_unit = "FOV" - - visible_objects = [ - i for i in exporter.get_objects(self.gui_page) - if i.type == "MESH" and i.data.materials - ] - camera_object.matrix_world = exporter.gui.calc_camera_matrix( - bpy.context.scene, - visible_objects, - camera_object.data.angle - ) - clipping = exporter.gui.calc_clipping( - camera_object.matrix_world, - bpy.context.scene, - visible_objects, - camera_object.data.angle - ) - camera_object.data.clip_start = clipping.hither - camera_object.data.clip_end = clipping.yonder - guidialog_mod.camera_object = camera_object - - # Begin creating the object for the clickoff plane. We want to yield it immediately - # to the exporter in case something goes wrong during the export, allowing the stale - # object to be cleaned up. - click_plane_object = utils.BMeshObject(f"{self.key_name}_Exit") - click_plane_object.matrix_world = guidialog_mod.camera_object.matrix_world - click_plane_object.plasma_object.enabled = True - click_plane_object.plasma_object.page = self.gui_page - yield click_plane_object - - # We have a camera on guidialog_mod.camera_object. We will now use it to generate the - # points for the click-off plane button. - # TODO: Allow this to be configurable to 4:3, 16:9, or 21:9? - with ExitStack() as stack: - stack.enter_context(exporter.gui.generate_camera_render_settings(bpy.context.scene)) - toggle = stack.enter_context(helpers.GoodNeighbor()) - - # Temporarily adjust the clipping plane out to the farthest point we can find to ensure - # that the click-off button ecompasses everything. This is a bit heavy-handed, but if - # you want more refined control, you won't be using this helper. - clipping = max((guidialog_mod.camera_object.data.clip_start, guidialog_mod.camera_object.data.clip_end)) - toggle.track(guidialog_mod.camera_object.data, "clip_start", clipping - 0.1) - view_frame = guidialog_mod.camera_object.data.view_frame(bpy.context.scene) - - click_plane_object.data.materials.append(exporter.gui.transparent_material) - with click_plane_object as click_plane_mesh: - verts = [click_plane_mesh.verts.new(i) for i in view_frame] - face = click_plane_mesh.faces.new(verts) - # TODO: Ensure the face is pointing toward the camera! - # I feel like we should be fine by assuming that Blender returns the viewframe - # verts in the correct order, but this is Blender... So test that assumption carefully. - # TODO: Apparently not! - face.normal_flip() - - # We've now created the mesh object - handle the GUI Button stuff - click_plane_object.plasma_modifiers.gui_button.enabled = True - - # NOTE: We will be using xDialogToggle.py, so we use a special tag ID instead of the - # close dialog procedure. - click_plane_object.plasma_modifiers.gui_control.tag_id = 99 + # The GUI converter will debounce duplicate GUI dialogs. + yield from exporter.gui.create_note_gui(self.gui_page, self.gui_camera) # Auto-generate a six-foot cube region around the clickable if none was provided. if self.clickable_region is None: From 20ccfa87f9ae4bc3ead2863066a53a6acdce30e9 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sun, 18 Feb 2024 18:12:53 -0500 Subject: [PATCH 2/2] Ensure note popups don't clobber each other. Fail fast if someone sets up multiple note popups with conflicting settings. This will allow us to obey the principle of least surprise. --- korman/exporter/convert.py | 6 ++++-- korman/exporter/gui.py | 8 ++++++++ korman/properties/modifiers/anim.py | 2 +- korman/properties/modifiers/avatar.py | 2 +- korman/properties/modifiers/game_gui.py | 2 +- korman/properties/modifiers/gui.py | 8 ++++++-- korman/properties/modifiers/logic.py | 2 +- korman/properties/modifiers/render.py | 4 ++-- korman/properties/modifiers/sound.py | 2 +- 9 files changed, 25 insertions(+), 11 deletions(-) diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index 95173c3..bace2de 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -229,7 +229,7 @@ class Exporter: for mod in bl_obj.plasma_modifiers.modifiers: fn = getattr(mod, "sanity_check", None) if fn is not None: - fn() + fn(self) inc_progress() self.report.msg("... Age is grinning and holding a spatula. Must be OK, then.") @@ -502,7 +502,9 @@ class Exporter: # Wow, recursively generated objects. Aren't you special? with indent(): for mod in temporary.plasma_modifiers.modifiers: - mod.sanity_check() + fn = getattr(mod, "sanity_check", None) + if fn is not None: + fn(self) do_pre_export(temporary) return temporary diff --git a/korman/exporter/gui.py b/korman/exporter/gui.py index 10c9842..bf9b952 100644 --- a/korman/exporter/gui.py +++ b/korman/exporter/gui.py @@ -49,10 +49,12 @@ class GuiConverter: if TYPE_CHECKING: _parent: weakref.ref[Exporter] = ... + _pages: Dict[str, Any] = ... _mods_exported: Set[str] = ... def __init__(self, parent: Optional[Exporter] = None): self._parent = weakref.ref(parent) if parent is not None else None + self._pages = {} self._mods_exported = set() # Go ahead and prepare the GUI transparent material for future use. @@ -206,6 +208,12 @@ class GuiConverter: w2c[2, i] *= -1.0 return PostEffectModMatrices(c2w, w2c) + def check_pre_export(self, name: str, **kwargs): + previous = self._pages.setdefault(name, kwargs) + if previous != kwargs: + diff = set(previous.items()) - set(kwargs.items()) + raise ExportError(f"GUI Page '{name}' has target modifiers with conflicting settings:\n{diff}") + def create_note_gui(self, gui_page: str, gui_camera: bpy.types.Object): if not gui_page in self._mods_exported: guidialog_object = utils.create_empty_object(f"{gui_page}_NoteDialog") diff --git a/korman/properties/modifiers/anim.py b/korman/properties/modifiers/anim.py index 684ea91..8db48fb 100644 --- a/korman/properties/modifiers/anim.py +++ b/korman/properties/modifiers/anim.py @@ -42,7 +42,7 @@ class ActionModifier: return None raise ExportError("'{}': Object has an animation modifier but is not animated".format(bo.name)) - def sanity_check(self) -> None: + def sanity_check(self, exporter) -> None: if not self.id_data.plasma_object.has_animation_data: raise ExportError("'{}': Has an animation modifier but no animation data.", self.id_data.name) diff --git a/korman/properties/modifiers/avatar.py b/korman/properties/modifiers/avatar.py index 5dc4b59..17cc9eb 100644 --- a/korman/properties/modifiers/avatar.py +++ b/korman/properties/modifiers/avatar.py @@ -189,7 +189,7 @@ class PlasmaSittingBehavior(idprops.IDPropObjectMixin, PlasmaModifierProperties, # This should be an empty, really... return True - def sanity_check(self): + def sanity_check(self, exporter): # 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/game_gui.py b/korman/properties/modifiers/game_gui.py index 9f44a18..d930264 100644 --- a/korman/properties/modifiers/game_gui.py +++ b/korman/properties/modifiers/game_gui.py @@ -70,7 +70,7 @@ class _GameGuiMixin: def requires_dyntext(self) -> bool: return False - def sanity_check(self): + def sanity_check(self, exporter): age: PlasmaAge = bpy.context.scene.world.plasma_age # Game GUI modifiers must be attached to objects in a GUI page, ONLY diff --git a/korman/properties/modifiers/gui.py b/korman/properties/modifiers/gui.py index 8a24eee..5b06df2 100644 --- a/korman/properties/modifiers/gui.py +++ b/korman/properties/modifiers/gui.py @@ -660,7 +660,7 @@ class PlasmaLinkingBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz share.link_input(share_anim_stage, "stage", "stage_refs") share.link_output(linkingnode, "hosts", "shareBookSeek") - def sanity_check(self): + def sanity_check(self, exporter): if self.clickable is None: raise ExportError("{}: Linking Book modifier requires a clickable!", self.id_data.name) if self.seek_point is None: @@ -724,11 +724,15 @@ class PlasmaNotePopupModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz): if self.id_data.type == "MESH": return self.id_data - def sanity_check(self): + def sanity_check(self, exporter: Exporter): page_type = helpers.get_page_type(self.id_data.plasma_object.page) if page_type != "room": raise ExportError(f"Note Popup modifiers should be in a 'room' page, not a '{page_type}' page!") + # It's OK if multiple note popups point to the same GUI page, + # they just need to have the same camera. + exporter.gui.check_pre_export(self.gui_page, pl_id="note_popup", camera=self.gui_camera) + def pre_export(self, exporter: Exporter, bo: bpy.types.Object): # The GUI converter will debounce duplicate GUI dialogs. yield from exporter.gui.create_note_gui(self.gui_page, self.gui_camera) diff --git a/korman/properties/modifiers/logic.py b/korman/properties/modifiers/logic.py index 1dec936..675b4fe 100644 --- a/korman/properties/modifiers/logic.py +++ b/korman/properties/modifiers/logic.py @@ -154,7 +154,7 @@ class PlasmaTelescope(PlasmaModifierProperties, PlasmaModifierLogicWiz): type=bpy.types.Object, poll=idprops.poll_camera_objects) - def sanity_check(self): + def sanity_check(self, exporter): if self.camera_object is None: raise ExportError(f"'{self.id_data.name}': Telescopes must specify a camera!") diff --git a/korman/properties/modifiers/render.py b/korman/properties/modifiers/render.py index 44c05c2..ec0645e 100644 --- a/korman/properties/modifiers/render.py +++ b/korman/properties/modifiers/render.py @@ -120,7 +120,7 @@ class PlasmaBlendMod(PlasmaModifierProperties): for i in (j.blend_onto for j in self.dependencies if j.blend_onto is not None and j.enabled): yield i - def sanity_check(self): + def sanity_check(self, exporter): if self.has_circular_dependency: raise ExportError("'{}': Circular Render Dependency detected!".format(self.id_data.name)) @@ -770,7 +770,7 @@ class PlasmaLocalizedTextModifier(PlasmaModifierProperties, PlasmaModifierLogicW def localization_set(self): return "DynaTexts" - def sanity_check(self): + def sanity_check(self, exporter): if self.texture is None: raise ExportError("'{}': Localized Text modifier requires a texture", self.id_data.name) diff --git a/korman/properties/modifiers/sound.py b/korman/properties/modifiers/sound.py index 394afc2..477427e 100644 --- a/korman/properties/modifiers/sound.py +++ b/korman/properties/modifiers/sound.py @@ -533,7 +533,7 @@ class PlasmaSoundEmitter(PlasmaModifierProperties): stereize_left = PointerProperty(type=bpy.types.Object, options={"HIDDEN", "SKIP_SAVE"}) stereize_right = PointerProperty(type=bpy.types.Object, options={"HIDDEN", "SKIP_SAVE"}) - def sanity_check(self): + def sanity_check(self, exporter): modifiers = self.id_data.plasma_modifiers # Sound emitters can potentially export sounds to more than one emitter SceneObject. Currently,