diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 6b29f83..0000000 --- a/.gitignore +++ /dev/null @@ -1,58 +0,0 @@ -# Windows image file caches -Thumbs.db -ehthumbs.db - -# Folder config file -Desktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msm -*.msp - -# Windows shortcuts -*.lnk - -# ========================= -# Operating System Files -# ========================= - -# OSX -# ========================= - -.DS_Store -.AppleDouble -.LSOverride - -# Thumbnails -._* - -# Files that might appear on external disk -.Spotlight-V100 -.Trashes - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -# python outputs -__pycache__/ -webspynner/__pychache__/ -*.py[cod] -*.pyc - -# Eclipse IDE -.pydevproject -.project -.settings/ - -# Linux file backups -*~ - diff --git a/README.md b/README.md index 93befa6..22f1bf4 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,19 @@ XPS Tools ========= -Blender an addon to Export/Import Haydee assets. -With Blender 2.80 released there where many changes. +Fork of XNALara Mesh import/export tool with Blender 5.0 compatibility. -From v2.0.0 of this addon will only work with Blender 2.80+ (and viceversa). -For Blender 2.79 download v1.8.7 +Since the original author (johnzero7) has not updated the code for five years as of 2025, the old version of the plugin can no longer run on the new version of Blender. Therefore, I will maintain this version going forward. -- Blender 2.80 ==> v2.0.0 -- Blender 2.79 ==> v1.8.7 +Original addon by XNALara community. + +With Blender 4.40 released there where many changes. + +From v2.1.0 of this addon will only work with Blender 4.4. +From v2.2.0 of this addon will only work with Blender 5.0. + +- Blender 5.00 ==> v2.2.0+ +- Blender 4.40 ==> v2.1.0 Blender Toolshelf, an addon for Blender to: @@ -21,6 +26,11 @@ Main Features: - Creates Materials on import - Easily set a new rest pose for the model -more info at: -http://johnzero7.github.io/XNALaraMesh/ +### Known Issues + +- Summary: Due to critical compatibility issues with the XPS binary format, I have disabled the native .xps binary export. The exporter now redirects to ASCII format by default, which ensures perfect compatibility and stability. + +- Technical Details: The current mock_xps_data.py lacks the necessary file header logic. After I manually attempted to implement the header construction, it resulted in severe byte misalignment. While the file is generated, the scene appears empty upon re-import. +The XPS format requires strict alignment for the 1080-byte Settings Block. Due to changes in Blender 5.0's I/O handling, the logic that worked in older versions now causes offset shifts. As an individual developer, the binary structure of XPS remains a "black box" to me, and my current technical skills are insufficient to perform the precise byte-level adjustments required to fix this. +- Call for Help: I welcome any experienced developers to help fix this alignment issue. If you can resolve the binary header construction for Blender 5.0, please submit a Pull Request on GitHub. Thank you for your support! diff --git a/__init__.py b/__init__.py index 46cc814..a4adcf5 100644 --- a/__init__.py +++ b/__init__.py @@ -1,167 +1,73 @@ -# - -"""Blender Addon. XNALara/XPS importer/exporter.""" - bl_info = { - "name": "XNALara/XPS Import/Export", - "author": "johnzero7", - "version": (2, 0, 2), - "blender": (2, 80, 0), - "location": "File > Import-Export > XNALara/XPS", - "description": "Import-Export XNALara/XPS", - "warning": "", - "wiki_url": "https://github.com/johnzero7/xps_tools", - "tracker_url": "https://github.com/johnzero7/xps_tools/issues", + "name": "XPS Import/Export", + "author": "maylog", + "version": (2, 2, 6), + "blender": (5, 0, 0), + "location": "File > Import-Export", + "description": "Community-maintained fork of the original XNALara/XPS Tools. Fully Blender 5.0+ compatible.", "category": "Import-Export", + "support": "COMMUNITY", + "credits": "2025 johnzero7 (original author), 2025 Clothoid, 2025 XNALara/XPS community, 2025 maylog (Blender 5.0+ update & Extensions submission)", } -############################################# -# support reloading sub-modules -_modules = [ - 'xps_panels', - 'xps_tools', - 'xps_toolshelf', - 'xps_const', - 'xps_types', - 'xps_material', - 'write_ascii_xps', - 'write_bin_xps', - 'read_ascii_xps', - 'read_bin_xps', - 'mock_xps_data', - 'export_xnalara_model', - 'export_xnalara_pose', - 'import_xnalara_model', - 'import_xnalara_pose', - 'import_obj', - 'export_obj', - 'ascii_ops', - 'bin_ops', - 'timing', - 'material_creator', - 'node_shader_utils', - 'addon_updater_ops', -] - -# Reload previously loaded modules -if "bpy" in locals(): - from importlib import reload - _modules_loaded[:] = [reload(module) for module in _modules_loaded] - del reload - - -# First import the modules -__import__(name=__name__, fromlist=_modules) -_namespace = globals() -_modules_loaded = [_namespace[name] for name in _modules] -del _namespace -# support reloading sub-modules -############################################# - -import bpy - - -class UpdaterPreferences(bpy.types.AddonPreferences): - """Updater Class.""" - - bl_idname = __package__ - - # addon updater preferences from `__init__`, be sure to copy all of them - auto_check_update: bpy.props.BoolProperty( - name="Auto-check for Update", - description="If enabled, auto-check for updates using an interval", - default=False, - ) - updater_interval_months: bpy.props.IntProperty( - name='Months', - description="Number of months between checking for updates", - default=0, - min=0 - ) - updater_interval_days: bpy.props.IntProperty( - name='Days', - description="Number of days between checking for updates", - default=7, - min=0, - ) - updater_interval_hours: bpy.props.IntProperty( - name='Hours', - description="Number of hours between checking for updates", - default=0, - min=0, - max=23 - ) - updater_interval_minutes: bpy.props.IntProperty( - name='Minutes', - description="Number of minutes between checking for updates", - default=0, - min=0, - max=59 - ) - - def draw(self, context): - """Draw Method.""" - addon_updater_ops.update_settings_ui(self, context) - -# -# Registration -# - - -classesToRegister = [ - UpdaterPreferences, - xps_panels.XPSToolsObjectPanel, - xps_panels.XPSToolsBonesPanel, - xps_panels.XPSToolsAnimPanel, - - xps_toolshelf.ArmatureBonesHideByName_Op, - xps_toolshelf.ArmatureBonesHideByVertexGroup_Op, - xps_toolshelf.ArmatureBonesShowAll_Op, - xps_toolshelf.ArmatureBonesRenameToBlender_Op, - xps_toolshelf.ArmatureBonesRenameToXps_Op, - xps_toolshelf.ArmatureBonesConnect_Op, - xps_toolshelf.NewRestPose_Op, - - xps_tools.Import_Xps_Model_Op, - xps_tools.Export_Xps_Model_Op, - xps_tools.Import_Xps_Pose_Op, - xps_tools.Export_Xps_Pose_Op, - xps_tools.Import_Poses_To_Keyframes_Op, - xps_tools.Export_Frames_To_Poses_Op, - xps_tools.ArmatureBoneDictGenerate_Op, - xps_tools.ArmatureBoneDictRename_Op, - xps_tools.ArmatureBoneDictRestore_Op, - xps_tools.ImportXpsNgff, - xps_tools.ExportXpsNgff, - xps_tools.XpsImportSubMenu, - xps_tools.XpsExportSubMenu, -] - - -# Use factory to create method to register and unregister the classes -registerClasses, unregisterClasses = bpy.utils.register_classes_factory(classesToRegister) - +from . import ( + xps_panels, + xps_tools, + xps_toolshelf, + xps_const, + xps_types, + xps_material, + write_ascii_xps, + write_bin_xps, + read_ascii_xps, + read_bin_xps, + mock_xps_data, + export_xnalara_model, + export_xnalara_pose, + import_xnalara_model, + import_xnalara_pose, + import_obj, + export_obj, + ascii_ops, + bin_ops, + timing, + material_creator, + node_shader_utils, +) + + +modules = ( + xps_const, + xps_types, + xps_material, + read_ascii_xps, + read_bin_xps, + write_ascii_xps, + write_bin_xps, + mock_xps_data, + material_creator, + node_shader_utils, + timing, + ascii_ops, + bin_ops, + import_obj, + export_obj, + import_xnalara_model, + export_xnalara_model, + import_xnalara_pose, + export_xnalara_pose, + xps_tools, + xps_toolshelf, + xps_panels, +) def register(): - """Register addon classes.""" - registerClasses() - xps_tools.register() - addon_updater_ops.register(bl_info) - + for mod in modules: + if hasattr(mod, "register"): + mod.register() def unregister(): - """Unregister addon classes.""" - addon_updater_ops.unregister() - xps_tools.unregister() - unregisterClasses() - - -if __name__ == "__main__": - register() - - # call exporter - # bpy.ops.xps_tools.export_model('INVOKE_DEFAULT') - - # call importer - # bpy.ops.xps_tools.import_model('INVOKE_DEFAULT') + for mod in reversed(modules): + if hasattr(mod, "unregister"): + mod.unregister() \ No newline at end of file diff --git a/addon_updater.py b/addon_updater.py deleted file mode 100644 index 54149ab..0000000 --- a/addon_updater.py +++ /dev/null @@ -1,1744 +0,0 @@ -# ##### BEGIN GPL LICENSE BLOCK ##### -# -# This program 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 2 -# of the License, or (at your option) any later version. -# -# This program 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 this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# ##### END GPL LICENSE BLOCK ##### - - -""" -See documentation for usage -https://github.com/CGCookie/blender-addon-updater -""" - -__version__ = "1.1.0" - -import errno -import traceback -import platform -import ssl -import urllib.request -import urllib -import os -import json -import zipfile -import shutil -import threading -import fnmatch -from datetime import datetime, timedelta - -# Blender imports, used in limited cases. -import bpy -import addon_utils - -# ----------------------------------------------------------------------------- -# The main class -# ----------------------------------------------------------------------------- - - -class SingletonUpdater: - """Addon updater service class. - - This is the singleton class to instance once and then reference where - needed throughout the addon. It implements all the interfaces for running - updates. - """ - def __init__(self): - - self._engine = GithubEngine() - self._user = None - self._repo = None - self._website = None - self._current_version = None - self._subfolder_path = None - self._tags = list() - self._tag_latest = None - self._tag_names = list() - self._latest_release = None - self._use_releases = False - self._include_branches = False - self._include_branch_list = ['master'] - self._include_branch_auto_check = False - self._manual_only = False - self._version_min_update = None - self._version_max_update = None - - # By default, backup current addon on update/target install. - self._backup_current = True - self._backup_ignore_patterns = None - - # Set patterns the files to overwrite during an update. - self._overwrite_patterns = ["*.py", "*.pyc"] - self._remove_pre_update_patterns = list() - - # By default, don't auto disable+re-enable the addon after an update, - # as this is less stable/often won't fully reload all modules anyways. - self._auto_reload_post_update = False - - # Settings for the frequency of automated background checks. - self._check_interval_enabled = False - self._check_interval_months = 0 - self._check_interval_days = 7 - self._check_interval_hours = 0 - self._check_interval_minutes = 0 - - # runtime variables, initial conditions - self._verbose = False - self._use_print_traces = True - self._fake_install = False - self._async_checking = False # only true when async daemon started - self._update_ready = None - self._update_link = None - self._update_version = None - self._source_zip = None - self._check_thread = None - self._select_link = None - self.skip_tag = None - - # Get data from the running blender module (addon). - self._addon = __package__.lower() - self._addon_package = __package__ # Must not change. - self._updater_path = os.path.join( - os.path.dirname(__file__), self._addon + "_updater") - self._addon_root = os.path.dirname(__file__) - self._json = dict() - self._error = None - self._error_msg = None - self._prefiltered_tag_count = 0 - - # UI properties, not used within this module but still useful to have. - - # to verify a valid import, in place of placeholder import - self.show_popups = True # UI uses to show popups or not. - self.invalid_updater = False - - # pre-assign basic select-link function - def select_link_function(self, tag): - return tag["zipball_url"] - - self._select_link = select_link_function - - def print_trace(self): - """Print handled exception details when use_print_traces is set""" - if self._use_print_traces: - traceback.print_exc() - - def print_verbose(self, msg): - """Print out a verbose logging message if verbose is true.""" - if not self._verbose: - return - print("{} addon: ".format(self.addon) + msg) - - # ------------------------------------------------------------------------- - # Getters and setters - # ------------------------------------------------------------------------- - @property - def addon(self): - return self._addon - - @addon.setter - def addon(self, value): - self._addon = str(value) - - @property - def api_url(self): - return self._engine.api_url - - @api_url.setter - def api_url(self, value): - if not self.check_is_url(value): - raise ValueError("Not a valid URL: " + value) - self._engine.api_url = value - - @property - def async_checking(self): - return self._async_checking - - @property - def auto_reload_post_update(self): - return self._auto_reload_post_update - - @auto_reload_post_update.setter - def auto_reload_post_update(self, value): - try: - self._auto_reload_post_update = bool(value) - except: - raise ValueError("auto_reload_post_update must be a boolean value") - - @property - def backup_current(self): - return self._backup_current - - @backup_current.setter - def backup_current(self, value): - if value is None: - self._backup_current = False - else: - self._backup_current = value - - @property - def backup_ignore_patterns(self): - return self._backup_ignore_patterns - - @backup_ignore_patterns.setter - def backup_ignore_patterns(self, value): - if value is None: - self._backup_ignore_patterns = None - elif not isinstance(value, list): - raise ValueError("Backup pattern must be in list format") - else: - self._backup_ignore_patterns = value - - @property - def check_interval(self): - return (self._check_interval_enabled, - self._check_interval_months, - self._check_interval_days, - self._check_interval_hours, - self._check_interval_minutes) - - @property - def current_version(self): - return self._current_version - - @current_version.setter - def current_version(self, tuple_values): - if tuple_values is None: - self._current_version = None - return - elif type(tuple_values) is not tuple: - try: - tuple(tuple_values) - except: - raise ValueError( - "current_version must be a tuple of integers") - for i in tuple_values: - if type(i) is not int: - raise ValueError( - "current_version must be a tuple of integers") - self._current_version = tuple(tuple_values) - - @property - def engine(self): - return self._engine.name - - @engine.setter - def engine(self, value): - engine = value.lower() - if engine == "github": - self._engine = GithubEngine() - elif engine == "gitlab": - self._engine = GitlabEngine() - elif engine == "bitbucket": - self._engine = BitbucketEngine() - else: - raise ValueError("Invalid engine selection") - - @property - def error(self): - return self._error - - @property - def error_msg(self): - return self._error_msg - - @property - def fake_install(self): - return self._fake_install - - @fake_install.setter - def fake_install(self, value): - if not isinstance(value, bool): - raise ValueError("fake_install must be a boolean value") - self._fake_install = bool(value) - - # not currently used - @property - def include_branch_auto_check(self): - return self._include_branch_auto_check - - @include_branch_auto_check.setter - def include_branch_auto_check(self, value): - try: - self._include_branch_auto_check = bool(value) - except: - raise ValueError("include_branch_autocheck must be a boolean") - - @property - def include_branch_list(self): - return self._include_branch_list - - @include_branch_list.setter - def include_branch_list(self, value): - try: - if value is None: - self._include_branch_list = ['master'] - elif not isinstance(value, list) or len(value) == 0: - raise ValueError( - "include_branch_list should be a list of valid branches") - else: - self._include_branch_list = value - except: - raise ValueError( - "include_branch_list should be a list of valid branches") - - @property - def include_branches(self): - return self._include_branches - - @include_branches.setter - def include_branches(self, value): - try: - self._include_branches = bool(value) - except: - raise ValueError("include_branches must be a boolean value") - - @property - def json(self): - if len(self._json) == 0: - self.set_updater_json() - return self._json - - @property - def latest_release(self): - if self._latest_release is None: - return None - return self._latest_release - - @property - def manual_only(self): - return self._manual_only - - @manual_only.setter - def manual_only(self, value): - try: - self._manual_only = bool(value) - except: - raise ValueError("manual_only must be a boolean value") - - @property - def overwrite_patterns(self): - return self._overwrite_patterns - - @overwrite_patterns.setter - def overwrite_patterns(self, value): - if value is None: - self._overwrite_patterns = ["*.py", "*.pyc"] - elif not isinstance(value, list): - raise ValueError("overwrite_patterns needs to be in a list format") - else: - self._overwrite_patterns = value - - @property - def private_token(self): - return self._engine.token - - @private_token.setter - def private_token(self, value): - if value is None: - self._engine.token = None - else: - self._engine.token = str(value) - - @property - def remove_pre_update_patterns(self): - return self._remove_pre_update_patterns - - @remove_pre_update_patterns.setter - def remove_pre_update_patterns(self, value): - if value is None: - self._remove_pre_update_patterns = list() - elif not isinstance(value, list): - raise ValueError( - "remove_pre_update_patterns needs to be in a list format") - else: - self._remove_pre_update_patterns = value - - @property - def repo(self): - return self._repo - - @repo.setter - def repo(self, value): - try: - self._repo = str(value) - except: - raise ValueError("repo must be a string value") - - @property - def select_link(self): - return self._select_link - - @select_link.setter - def select_link(self, value): - # ensure it is a function assignment, with signature: - # input self, tag; returns link name - if not hasattr(value, "__call__"): - raise ValueError("select_link must be a function") - self._select_link = value - - @property - def stage_path(self): - return self._updater_path - - @stage_path.setter - def stage_path(self, value): - if value is None: - self.print_verbose("Aborting assigning stage_path, it's null") - return - elif value is not None and not os.path.exists(value): - try: - os.makedirs(value) - except: - self.print_verbose("Error trying to staging path") - self.print_trace() - return - self._updater_path = value - - @property - def subfolder_path(self): - return self._subfolder_path - - @subfolder_path.setter - def subfolder_path(self, value): - self._subfolder_path = value - - @property - def tags(self): - if len(self._tags) == 0: - return list() - tag_names = list() - for tag in self._tags: - tag_names.append(tag["name"]) - return tag_names - - @property - def tag_latest(self): - if self._tag_latest is None: - return None - return self._tag_latest["name"] - - @property - def update_link(self): - return self._update_link - - @property - def update_ready(self): - return self._update_ready - - @property - def update_version(self): - return self._update_version - - @property - def use_releases(self): - return self._use_releases - - @use_releases.setter - def use_releases(self, value): - try: - self._use_releases = bool(value) - except: - raise ValueError("use_releases must be a boolean value") - - @property - def user(self): - return self._user - - @user.setter - def user(self, value): - try: - self._user = str(value) - except: - raise ValueError("User must be a string value") - - @property - def verbose(self): - return self._verbose - - @verbose.setter - def verbose(self, value): - try: - self._verbose = bool(value) - self.print_verbose("Verbose is enabled") - except: - raise ValueError("Verbose must be a boolean value") - - @property - def use_print_traces(self): - return self._use_print_traces - - @use_print_traces.setter - def use_print_traces(self, value): - try: - self._use_print_traces = bool(value) - except: - raise ValueError("use_print_traces must be a boolean value") - - @property - def version_max_update(self): - return self._version_max_update - - @version_max_update.setter - def version_max_update(self, value): - if value is None: - self._version_max_update = None - return - if not isinstance(value, tuple): - raise ValueError("Version maximum must be a tuple") - for subvalue in value: - if type(subvalue) is not int: - raise ValueError("Version elements must be integers") - self._version_max_update = value - - @property - def version_min_update(self): - return self._version_min_update - - @version_min_update.setter - def version_min_update(self, value): - if value is None: - self._version_min_update = None - return - if not isinstance(value, tuple): - raise ValueError("Version minimum must be a tuple") - for subvalue in value: - if type(subvalue) != int: - raise ValueError("Version elements must be integers") - self._version_min_update = value - - @property - def website(self): - return self._website - - @website.setter - def website(self, value): - if not self.check_is_url(value): - raise ValueError("Not a valid URL: " + value) - self._website = value - - # ------------------------------------------------------------------------- - # Parameter validation related functions - # ------------------------------------------------------------------------- - @staticmethod - def check_is_url(url): - if not ("http://" in url or "https://" in url): - return False - if "." not in url: - return False - return True - - def _get_tag_names(self): - tag_names = list() - self.get_tags() - for tag in self._tags: - tag_names.append(tag["name"]) - return tag_names - - def set_check_interval(self, enabled=False, - months=0, days=14, hours=0, minutes=0): - """Set the time interval between automated checks, and if enabled. - - Has enabled = False as default to not check against frequency, - if enabled, default is 2 weeks. - """ - - if type(enabled) is not bool: - raise ValueError("Enable must be a boolean value") - if type(months) is not int: - raise ValueError("Months must be an integer value") - if type(days) is not int: - raise ValueError("Days must be an integer value") - if type(hours) is not int: - raise ValueError("Hours must be an integer value") - if type(minutes) is not int: - raise ValueError("Minutes must be an integer value") - - if not enabled: - self._check_interval_enabled = False - else: - self._check_interval_enabled = True - - self._check_interval_months = months - self._check_interval_days = days - self._check_interval_hours = hours - self._check_interval_minutes = minutes - - def __repr__(self): - return "".format(a=__file__) - - def __str__(self): - return "Updater, with user: {a}, repository: {b}, url: {c}".format( - a=self._user, b=self._repo, c=self.form_repo_url()) - - # ------------------------------------------------------------------------- - # API-related functions - # ------------------------------------------------------------------------- - def form_repo_url(self): - return self._engine.form_repo_url(self) - - def form_tags_url(self): - return self._engine.form_tags_url(self) - - def form_branch_url(self, branch): - return self._engine.form_branch_url(branch, self) - - def get_tags(self): - request = self.form_tags_url() - self.print_verbose("Getting tags from server") - - # get all tags, internet call - all_tags = self._engine.parse_tags(self.get_api(request), self) - if all_tags is not None: - self._prefiltered_tag_count = len(all_tags) - else: - self._prefiltered_tag_count = 0 - all_tags = list() - - # pre-process to skip tags - if self.skip_tag is not None: - self._tags = [tg for tg in all_tags if not self.skip_tag(self, tg)] - else: - self._tags = all_tags - - # get additional branches too, if needed, and place in front - # Does NO checking here whether branch is valid - if self._include_branches: - temp_branches = self._include_branch_list.copy() - temp_branches.reverse() - for branch in temp_branches: - request = self.form_branch_url(branch) - include = { - "name": branch.title(), - "zipball_url": request - } - self._tags = [include] + self._tags # append to front - - if self._tags is None: - # some error occurred - self._tag_latest = None - self._tags = list() - - elif self._prefiltered_tag_count == 0 and not self._include_branches: - self._tag_latest = None - if self._error is None: # if not None, could have had no internet - self._error = "No releases found" - self._error_msg = "No releases or tags found in repository" - self.print_verbose("No releases or tags found in repository") - - elif self._prefiltered_tag_count == 0 and self._include_branches: - if not self._error: - self._tag_latest = self._tags[0] - branch = self._include_branch_list[0] - self.print_verbose("{} branch found, no releases: {}".format( - branch, self._tags[0])) - - elif ((len(self._tags) - len(self._include_branch_list) == 0 - and self._include_branches) - or (len(self._tags) == 0 and not self._include_branches) - and self._prefiltered_tag_count > 0): - self._tag_latest = None - self._error = "No releases available" - self._error_msg = "No versions found within compatible version range" - self.print_verbose(self._error_msg) - - else: - if not self._include_branches: - self._tag_latest = self._tags[0] - self.print_verbose( - "Most recent tag found:" + str(self._tags[0]['name'])) - else: - # Don't return branch if in list. - n = len(self._include_branch_list) - self._tag_latest = self._tags[n] # guaranteed at least len()=n+1 - self.print_verbose( - "Most recent tag found:" + str(self._tags[n]['name'])) - - def get_raw(self, url): - """All API calls to base url.""" - request = urllib.request.Request(url) - try: - context = ssl._create_unverified_context() - except: - # Some blender packaged python versions don't have this, largely - # useful for local network setups otherwise minimal impact. - context = None - - # Setup private request headers if appropriate. - if self._engine.token is not None: - if self._engine.name == "gitlab": - request.add_header('PRIVATE-TOKEN', self._engine.token) - else: - self.print_verbose("Tokens not setup for engine yet") - - # Always set user agent. - request.add_header( - 'User-Agent', "Python/" + str(platform.python_version())) - - # Run the request. - try: - if context: - result = urllib.request.urlopen(request, context=context) - else: - result = urllib.request.urlopen(request) - except urllib.error.HTTPError as e: - if str(e.code) == "403": - self._error = "HTTP error (access denied)" - self._error_msg = str(e.code) + " - server error response" - print(self._error, self._error_msg) - else: - self._error = "HTTP error" - self._error_msg = str(e.code) - print(self._error, self._error_msg) - self.print_trace() - self._update_ready = None - except urllib.error.URLError as e: - reason = str(e.reason) - if "TLSV1_ALERT" in reason or "SSL" in reason.upper(): - self._error = "Connection rejected, download manually" - self._error_msg = reason - print(self._error, self._error_msg) - else: - self._error = "URL error, check internet connection" - self._error_msg = reason - print(self._error, self._error_msg) - self.print_trace() - self._update_ready = None - return None - else: - result_string = result.read() - result.close() - return result_string.decode() - - def get_api(self, url): - """Result of all api calls, decoded into json format.""" - get = None - get = self.get_raw(url) - if get is not None: - try: - return json.JSONDecoder().decode(get) - except Exception as e: - self._error = "API response has invalid JSON format" - self._error_msg = str(e.reason) - self._update_ready = None - print(self._error, self._error_msg) - self.print_trace() - return None - else: - return None - - def stage_repository(self, url): - """Create a working directory and download the new files""" - - local = os.path.join(self._updater_path, "update_staging") - error = None - - # Make/clear the staging folder, to ensure the folder is always clean. - self.print_verbose( - "Preparing staging folder for download:\n" + str(local)) - if os.path.isdir(local): - try: - shutil.rmtree(local) - os.makedirs(local) - except: - error = "failed to remove existing staging directory" - self.print_trace() - else: - try: - os.makedirs(local) - except: - error = "failed to create staging directory" - self.print_trace() - - if error is not None: - self.print_verbose("Error: Aborting update, " + error) - self._error = "Update aborted, staging path error" - self._error_msg = "Error: {}".format(error) - return False - - if self._backup_current: - self.create_backup() - - self.print_verbose("Now retrieving the new source zip") - self._source_zip = os.path.join(local, "source.zip") - self.print_verbose("Starting download update zip") - try: - request = urllib.request.Request(url) - context = ssl._create_unverified_context() - - # Setup private token if appropriate. - if self._engine.token is not None: - if self._engine.name == "gitlab": - request.add_header('PRIVATE-TOKEN', self._engine.token) - else: - self.print_verbose( - "Tokens not setup for selected engine yet") - - # Always set user agent - request.add_header( - 'User-Agent', "Python/" + str(platform.python_version())) - - self.url_retrieve(urllib.request.urlopen(request, context=context), - self._source_zip) - # Add additional checks on file size being non-zero. - self.print_verbose("Successfully downloaded update zip") - return True - except Exception as e: - self._error = "Error retrieving download, bad link?" - self._error_msg = "Error: {}".format(e) - print("Error retrieving download, bad link?") - print("Error: {}".format(e)) - self.print_trace() - return False - - def create_backup(self): - """Save a backup of the current installed addon prior to an update.""" - self.print_verbose("Backing up current addon folder") - local = os.path.join(self._updater_path, "backup") - tempdest = os.path.join( - self._addon_root, os.pardir, self._addon + "_updater_backup_temp") - - self.print_verbose("Backup destination path: " + str(local)) - - if os.path.isdir(local): - try: - shutil.rmtree(local) - except: - self.print_verbose( - "Failed to removed previous backup folder, continuing") - self.print_trace() - - # Remove the temp folder. - # Shouldn't exist but could if previously interrupted. - if os.path.isdir(tempdest): - try: - shutil.rmtree(tempdest) - except: - self.print_verbose( - "Failed to remove existing temp folder, continuing") - self.print_trace() - - # Make a full addon copy, temporarily placed outside the addon folder. - if self._backup_ignore_patterns is not None: - try: - shutil.copytree(self._addon_root, tempdest, - ignore=shutil.ignore_patterns( - *self._backup_ignore_patterns)) - except: - print("Failed to create backup, still attempting update.") - self.print_trace() - return - else: - try: - shutil.copytree(self._addon_root, tempdest) - except: - print("Failed to create backup, still attempting update.") - self.print_trace() - return - shutil.move(tempdest, local) - - # Save the date for future reference. - now = datetime.now() - self._json["backup_date"] = "{m}-{d}-{yr}".format( - m=now.strftime("%B"), d=now.day, yr=now.year) - self.save_updater_json() - - def restore_backup(self): - """Restore the last backed up addon version, user initiated only""" - self.print_verbose("Restoring backup, backing up current addon folder") - backuploc = os.path.join(self._updater_path, "backup") - tempdest = os.path.join( - self._addon_root, os.pardir, self._addon + "_updater_backup_temp") - tempdest = os.path.abspath(tempdest) - - # Move instead contents back in place, instead of copy. - shutil.move(backuploc, tempdest) - shutil.rmtree(self._addon_root) - os.rename(tempdest, self._addon_root) - - self._json["backup_date"] = "" - self._json["just_restored"] = True - self._json["just_updated"] = True - self.save_updater_json() - - self.reload_addon() - - def unpack_staged_zip(self, clean=False): - """Unzip the downloaded file, and validate contents""" - if not os.path.isfile(self._source_zip): - self.print_verbose("Error, update zip not found") - self._error = "Install failed" - self._error_msg = "Downloaded zip not found" - return -1 - - # Clear the existing source folder in case previous files remain. - outdir = os.path.join(self._updater_path, "source") - try: - shutil.rmtree(outdir) - self.print_verbose("Source folder cleared") - except: - self.print_trace() - - # Create parent directories if needed, would not be relevant unless - # installing addon into another location or via an addon manager. - try: - os.mkdir(outdir) - except Exception as err: - print("Error occurred while making extract dir:") - print(str(err)) - self.print_trace() - self._error = "Install failed" - self._error_msg = "Failed to make extract directory" - return -1 - - if not os.path.isdir(outdir): - print("Failed to create source directory") - self._error = "Install failed" - self._error_msg = "Failed to create extract directory" - return -1 - - self.print_verbose( - "Begin extracting source from zip:" + str(self._source_zip)) - zfile = zipfile.ZipFile(self._source_zip, "r") - - if not zfile: - self._error = "Install failed" - self._error_msg = "Resulting file is not a zip, cannot extract" - self.print_verbose(self._error_msg) - return -1 - - # Now extract directly from the first subfolder (not root) - # this avoids adding the first subfolder to the path length, - # which can be too long if the download has the SHA in the name. - zsep = '/' # Not using os.sep, always the / value even on windows. - for name in zfile.namelist(): - if zsep not in name: - continue - top_folder = name[:name.index(zsep) + 1] - if name == top_folder + zsep: - continue # skip top level folder - sub_path = name[name.index(zsep) + 1:] - if name.endswith(zsep): - try: - os.mkdir(os.path.join(outdir, sub_path)) - self.print_verbose( - "Extract - mkdir: " + os.path.join(outdir, sub_path)) - except OSError as exc: - if exc.errno != errno.EEXIST: - self._error = "Install failed" - self._error_msg = "Could not create folder from zip" - self.print_trace() - return -1 - else: - with open(os.path.join(outdir, sub_path), "wb") as outfile: - data = zfile.read(name) - outfile.write(data) - self.print_verbose( - "Extract - create: " + os.path.join(outdir, sub_path)) - - self.print_verbose("Extracted source") - - unpath = os.path.join(self._updater_path, "source") - if not os.path.isdir(unpath): - self._error = "Install failed" - self._error_msg = "Extracted path does not exist" - print("Extracted path does not exist: ", unpath) - return -1 - - if self._subfolder_path: - self._subfolder_path.replace('/', os.path.sep) - self._subfolder_path.replace('\\', os.path.sep) - - # Either directly in root of zip/one subfolder, or use specified path. - if not os.path.isfile(os.path.join(unpath, "__init__.py")): - dirlist = os.listdir(unpath) - if len(dirlist) > 0: - if self._subfolder_path == "" or self._subfolder_path is None: - unpath = os.path.join(unpath, dirlist[0]) - else: - unpath = os.path.join(unpath, self._subfolder_path) - - # Smarter check for additional sub folders for a single folder - # containing the __init__.py file. - if not os.path.isfile(os.path.join(unpath, "__init__.py")): - print("Not a valid addon found") - print("Paths:") - print(dirlist) - self._error = "Install failed" - self._error_msg = "No __init__ file found in new source" - return -1 - - # Merge code with the addon directory, using blender default behavior, - # plus any modifiers indicated by user (e.g. force remove/keep). - self.deep_merge_directory(self._addon_root, unpath, clean) - - # Now save the json state. - # Change to True to trigger the handler on other side if allowing - # reloading within same blender session. - self._json["just_updated"] = True - self.save_updater_json() - self.reload_addon() - self._update_ready = False - return 0 - - def deep_merge_directory(self, base, merger, clean=False): - """Merge folder 'merger' into 'base' without deleting existing""" - if not os.path.exists(base): - self.print_verbose("Base path does not exist:" + str(base)) - return -1 - elif not os.path.exists(merger): - self.print_verbose("Merger path does not exist") - return -1 - - # Path to be aware of and not overwrite/remove/etc. - staging_path = os.path.join(self._updater_path, "update_staging") - - # If clean install is enabled, clear existing files ahead of time - # note: will not delete the update.json, update folder, staging, or - # staging but will delete all other folders/files in addon directory. - error = None - if clean: - try: - # Implement clearing of all folders/files, except the updater - # folder and updater json. - # Careful, this deletes entire subdirectories recursively... - # Make sure that base is not a high level shared folder, but - # is dedicated just to the addon itself. - self.print_verbose( - "clean=True, clearing addon folder to fresh install state") - - # Remove root files and folders (except update folder). - files = [f for f in os.listdir(base) - if os.path.isfile(os.path.join(base, f))] - folders = [f for f in os.listdir(base) - if os.path.isdir(os.path.join(base, f))] - - for f in files: - os.remove(os.path.join(base, f)) - self.print_verbose( - "Clean removing file {}".format(os.path.join(base, f))) - for f in folders: - if os.path.join(base, f) is self._updater_path: - continue - shutil.rmtree(os.path.join(base, f)) - self.print_verbose( - "Clean removing folder and contents {}".format( - os.path.join(base, f))) - - except Exception as err: - error = "failed to create clean existing addon folder" - print(error, str(err)) - self.print_trace() - - # Walk through the base addon folder for rules on pre-removing - # but avoid removing/altering backup and updater file. - for path, dirs, files in os.walk(base): - # Prune ie skip updater folder. - dirs[:] = [d for d in dirs - if os.path.join(path, d) not in [self._updater_path]] - for file in files: - for pattern in self.remove_pre_update_patterns: - if fnmatch.filter([file], pattern): - try: - fl = os.path.join(path, file) - os.remove(fl) - self.print_verbose("Pre-removed file " + file) - except OSError: - print("Failed to pre-remove " + file) - self.print_trace() - - # Walk through the temp addon sub folder for replacements - # this implements the overwrite rules, which apply after - # the above pre-removal rules. This also performs the - # actual file copying/replacements. - for path, dirs, files in os.walk(merger): - # Verify structure works to prune updater sub folder overwriting. - dirs[:] = [d for d in dirs - if os.path.join(path, d) not in [self._updater_path]] - rel_path = os.path.relpath(path, merger) - dest_path = os.path.join(base, rel_path) - if not os.path.exists(dest_path): - os.makedirs(dest_path) - for file in files: - # Bring in additional logic around copying/replacing. - # Blender default: overwrite .py's, don't overwrite the rest. - dest_file = os.path.join(dest_path, file) - srcFile = os.path.join(path, file) - - # Decide to replace if file already exists, and copy new over. - if os.path.isfile(dest_file): - # Otherwise, check each file for overwrite pattern match. - replaced = False - for pattern in self._overwrite_patterns: - if fnmatch.filter([file], pattern): - replaced = True - break - if replaced: - os.remove(dest_file) - os.rename(srcFile, dest_file) - self.print_verbose( - "Overwrote file " + os.path.basename(dest_file)) - else: - self.print_verbose( - "Pattern not matched to {}, not overwritten".format( - os.path.basename(dest_file))) - else: - # File did not previously exist, simply move it over. - os.rename(srcFile, dest_file) - self.print_verbose( - "New file " + os.path.basename(dest_file)) - - # now remove the temp staging folder and downloaded zip - try: - shutil.rmtree(staging_path) - except: - error = ("Error: Failed to remove existing staging directory, " - "consider manually removing ") + staging_path - self.print_verbose(error) - self.print_trace() - - def reload_addon(self): - # if post_update false, skip this function - # else, unload/reload addon & trigger popup - if not self._auto_reload_post_update: - print("Restart blender to reload addon and complete update") - return - - self.print_verbose("Reloading addon...") - addon_utils.modules(refresh=True) - bpy.utils.refresh_script_paths() - - # not allowed in restricted context, such as register module - # toggle to refresh - if "addon_disable" in dir(bpy.ops.wm): # 2.7 - bpy.ops.wm.addon_disable(module=self._addon_package) - bpy.ops.wm.addon_refresh() - bpy.ops.wm.addon_enable(module=self._addon_package) - print("2.7 reload complete") - else: # 2.8 - bpy.ops.preferences.addon_disable(module=self._addon_package) - bpy.ops.preferences.addon_refresh() - bpy.ops.preferences.addon_enable(module=self._addon_package) - print("2.8 reload complete") - - # ------------------------------------------------------------------------- - # Other non-api functions and setups - # ------------------------------------------------------------------------- - def clear_state(self): - self._update_ready = None - self._update_link = None - self._update_version = None - self._source_zip = None - self._error = None - self._error_msg = None - - def url_retrieve(self, url_file, filepath): - """Custom urlretrieve implementation""" - chunk = 1024 * 8 - f = open(filepath, "wb") - while 1: - data = url_file.read(chunk) - if not data: - # print("done.") - break - f.write(data) - # print("Read %s bytes" % len(data)) - f.close() - - def version_tuple_from_text(self, text): - """Convert text into a tuple of numbers (int). - - Should go through string and remove all non-integers, and for any - given break split into a different section. - """ - if text is None: - return () - - segments = list() - tmp = '' - for char in str(text): - if not char.isdigit(): - if len(tmp) > 0: - segments.append(int(tmp)) - tmp = '' - else: - tmp += char - if len(tmp) > 0: - segments.append(int(tmp)) - - if len(segments) == 0: - self.print_verbose("No version strings found text: " + str(text)) - if not self._include_branches: - return () - else: - return (text) - return tuple(segments) - - def check_for_update_async(self, callback=None): - """Called for running check in a background thread""" - is_ready = ( - self._json is not None - and "update_ready" in self._json - and self._json["version_text"] != dict() - and self._json["update_ready"]) - - if is_ready: - self._update_ready = True - self._update_link = self._json["version_text"]["link"] - self._update_version = str(self._json["version_text"]["version"]) - # Cached update. - callback(True) - return - - # do the check - if not self._check_interval_enabled: - return - elif self._async_checking: - self.print_verbose("Skipping async check, already started") - # already running the bg thread - elif self._update_ready is None: - print("{} updater: Running background check for update".format( - self.addon)) - self.start_async_check_update(False, callback) - - def check_for_update_now(self, callback=None): - self._error = None - self._error_msg = None - self.print_verbose( - "Check update pressed, first getting current status") - if self._async_checking: - self.print_verbose("Skipping async check, already started") - return # already running the bg thread - elif self._update_ready is None: - self.start_async_check_update(True, callback) - else: - self._update_ready = None - self.start_async_check_update(True, callback) - - def check_for_update(self, now=False): - """Check for update not in a syncrhonous manner. - - This function is not async, will always return in sequential fashion - but should have a parent which calls it in another thread. - """ - self.print_verbose("Checking for update function") - - # clear the errors if any - self._error = None - self._error_msg = None - - # avoid running again in, just return past result if found - # but if force now check, then still do it - if self._update_ready is not None and not now: - return (self._update_ready, - self._update_version, - self._update_link) - - if self._current_version is None: - raise ValueError("current_version not yet defined") - - if self._repo is None: - raise ValueError("repo not yet defined") - - if self._user is None: - raise ValueError("username not yet defined") - - self.set_updater_json() # self._json - - if not now and not self.past_interval_timestamp(): - self.print_verbose( - "Aborting check for updated, check interval not reached") - return (False, None, None) - - # check if using tags or releases - # note that if called the first time, this will pull tags from online - if self._fake_install: - self.print_verbose( - "fake_install = True, setting fake version as ready") - self._update_ready = True - self._update_version = "(999,999,999)" - self._update_link = "http://127.0.0.1" - - return (self._update_ready, - self._update_version, - self._update_link) - - # Primary internet call, sets self._tags and self._tag_latest. - self.get_tags() - - self._json["last_check"] = str(datetime.now()) - self.save_updater_json() - - # Can be () or ('master') in addition to branches, and version tag. - new_version = self.version_tuple_from_text(self.tag_latest) - - if len(self._tags) == 0: - self._update_ready = False - self._update_version = None - self._update_link = None - return (False, None, None) - - if not self._include_branches: - link = self.select_link(self, self._tags[0]) - else: - n = len(self._include_branch_list) - if len(self._tags) == n: - # effectively means no tags found on repo - # so provide the first one as default - link = self.select_link(self, self._tags[0]) - else: - link = self.select_link(self, self._tags[n]) - - if new_version == (): - self._update_ready = False - self._update_version = None - self._update_link = None - return (False, None, None) - elif str(new_version).lower() in self._include_branch_list: - # Handle situation where master/whichever branch is included - # however, this code effectively is not triggered now - # as new_version will only be tag names, not branch names. - if not self._include_branch_auto_check: - # Don't offer update as ready, but set the link for the - # default branch for installing. - self._update_ready = False - self._update_version = new_version - self._update_link = link - self.save_updater_json() - return (True, new_version, link) - else: - # Bypass releases and look at timestamp of last update from a - # branch compared to now, see if commit values match or not. - raise ValueError("include_branch_autocheck: NOT YET DEVELOPED") - - else: - # Situation where branches not included. - if new_version > self._current_version: - - self._update_ready = True - self._update_version = new_version - self._update_link = link - self.save_updater_json() - return (True, new_version, link) - - # If no update, set ready to False from None to show it was checked. - self._update_ready = False - self._update_version = None - self._update_link = None - return (False, None, None) - - def set_tag(self, name): - """Assign the tag name and url to update to""" - tg = None - for tag in self._tags: - if name == tag["name"]: - tg = tag - break - if tg: - new_version = self.version_tuple_from_text(self.tag_latest) - self._update_version = new_version - self._update_link = self.select_link(self, tg) - elif self._include_branches and name in self._include_branch_list: - # scenario if reverting to a specific branch name instead of tag - tg = name - link = self.form_branch_url(tg) - self._update_version = name # this will break things - self._update_link = link - if not tg: - raise ValueError("Version tag not found: " + name) - - def run_update(self, force=False, revert_tag=None, clean=False, callback=None): - """Runs an install, update, or reversion of an addon from online source - - Arguments: - force: Install assigned link, even if self.update_ready is False - revert_tag: Version to install, if none uses detected update link - clean: not used, but in future could use to totally refresh addon - callback: used to run function on update completion - """ - self._json["update_ready"] = False - self._json["ignore"] = False # clear ignore flag - self._json["version_text"] = dict() - - if revert_tag is not None: - self.set_tag(revert_tag) - self._update_ready = True - - # clear the errors if any - self._error = None - self._error_msg = None - - self.print_verbose("Running update") - - if self._fake_install: - # Change to True, to trigger the reload/"update installed" handler. - self.print_verbose("fake_install=True") - self.print_verbose( - "Just reloading and running any handler triggers") - self._json["just_updated"] = True - self.save_updater_json() - if self._backup_current is True: - self.create_backup() - self.reload_addon() - self._update_ready = False - res = True # fake "success" zip download flag - - elif not force: - if not self._update_ready: - self.print_verbose("Update stopped, new version not ready") - if callback: - callback( - self._addon_package, - "Update stopped, new version not ready") - return "Update stopped, new version not ready" - elif self._update_link is None: - # this shouldn't happen if update is ready - self.print_verbose("Update stopped, update link unavailable") - if callback: - callback(self._addon_package, - "Update stopped, update link unavailable") - return "Update stopped, update link unavailable" - - if revert_tag is None: - self.print_verbose("Staging update") - else: - self.print_verbose("Staging install") - - res = self.stage_repository(self._update_link) - if not res: - print("Error in staging repository: " + str(res)) - if callback is not None: - callback(self._addon_package, self._error_msg) - return self._error_msg - res = self.unpack_staged_zip(clean) - if res < 0: - if callback: - callback(self._addon_package, self._error_msg) - return res - - else: - if self._update_link is None: - self.print_verbose("Update stopped, could not get link") - return "Update stopped, could not get link" - self.print_verbose("Forcing update") - - res = self.stage_repository(self._update_link) - if not res: - print("Error in staging repository: " + str(res)) - if callback: - callback(self._addon_package, self._error_msg) - return self._error_msg - res = self.unpack_staged_zip(clean) - if res < 0: - return res - # would need to compare against other versions held in tags - - # run the front-end's callback if provided - if callback: - callback(self._addon_package) - - # return something meaningful, 0 means it worked - return 0 - - def past_interval_timestamp(self): - if not self._check_interval_enabled: - return True # ie this exact feature is disabled - - if "last_check" not in self._json or self._json["last_check"] == "": - return True - - now = datetime.now() - last_check = datetime.strptime( - self._json["last_check"], "%Y-%m-%d %H:%M:%S.%f") - offset = timedelta( - days=self._check_interval_days + 30 * self._check_interval_months, - hours=self._check_interval_hours, - minutes=self._check_interval_minutes) - - delta = (now - offset) - last_check - if delta.total_seconds() > 0: - self.print_verbose("Time to check for updates!") - return True - - self.print_verbose("Determined it's not yet time to check for updates") - return False - - def get_json_path(self): - """Returns the full path to the JSON state file used by this updater. - - Will also rename old file paths to addon-specific path if found. - """ - json_path = os.path.join( - self._updater_path, - "{}_updater_status.json".format(self._addon_package)) - old_json_path = os.path.join(self._updater_path, "updater_status.json") - - # Rename old file if it exists. - try: - os.rename(old_json_path, json_path) - except FileNotFoundError: - pass - except Exception as err: - print("Other OS error occurred while trying to rename old JSON") - print(err) - self.print_trace() - return json_path - - def set_updater_json(self): - """Load or initialize JSON dictionary data for updater state""" - if self._updater_path is None: - raise ValueError("updater_path is not defined") - elif not os.path.isdir(self._updater_path): - os.makedirs(self._updater_path) - - jpath = self.get_json_path() - if os.path.isfile(jpath): - with open(jpath) as data_file: - self._json = json.load(data_file) - self.print_verbose("Read in JSON settings from file") - else: - self._json = { - "last_check": "", - "backup_date": "", - "update_ready": False, - "ignore": False, - "just_restored": False, - "just_updated": False, - "version_text": dict() - } - self.save_updater_json() - - def save_updater_json(self): - """Trigger save of current json structure into file within addon""" - if self._update_ready: - if isinstance(self._update_version, tuple): - self._json["update_ready"] = True - self._json["version_text"]["link"] = self._update_link - self._json["version_text"]["version"] = self._update_version - else: - self._json["update_ready"] = False - self._json["version_text"] = dict() - else: - self._json["update_ready"] = False - self._json["version_text"] = dict() - - jpath = self.get_json_path() - if not os.path.isdir(os.path.dirname(jpath)): - print("State error: Directory does not exist, cannot save json: ", - os.path.basename(jpath)) - return - try: - with open(jpath, 'w') as outf: - data_out = json.dumps(self._json, indent=4) - outf.write(data_out) - except: - print("Failed to open/save data to json: ", jpath) - self.print_trace() - self.print_verbose("Wrote out updater JSON settings with content:") - self.print_verbose(str(self._json)) - - def json_reset_postupdate(self): - self._json["just_updated"] = False - self._json["update_ready"] = False - self._json["version_text"] = dict() - self.save_updater_json() - - def json_reset_restore(self): - self._json["just_restored"] = False - self._json["update_ready"] = False - self._json["version_text"] = dict() - self.save_updater_json() - self._update_ready = None # Reset so you could check update again. - - def ignore_update(self): - self._json["ignore"] = True - self.save_updater_json() - - # ------------------------------------------------------------------------- - # ASYNC related methods - # ------------------------------------------------------------------------- - def start_async_check_update(self, now=False, callback=None): - """Start a background thread which will check for updates""" - if self._async_checking: - return - self.print_verbose("Starting background checking thread") - check_thread = threading.Thread(target=self.async_check_update, - args=(now, callback,)) - check_thread.daemon = True - self._check_thread = check_thread - check_thread.start() - - def async_check_update(self, now, callback=None): - """Perform update check, run as target of background thread""" - self._async_checking = True - self.print_verbose("Checking for update now in background") - - try: - self.check_for_update(now=now) - except Exception as exception: - print("Checking for update error:") - print(exception) - self.print_trace() - if not self._error: - self._update_ready = False - self._update_version = None - self._update_link = None - self._error = "Error occurred" - self._error_msg = "Encountered an error while checking for updates" - - self._async_checking = False - self._check_thread = None - - if callback: - self.print_verbose("Finished check update, doing callback") - callback(self._update_ready) - self.print_verbose("BG thread: Finished check update, no callback") - - def stop_async_check_update(self): - """Method to give impression of stopping check for update. - - Currently does nothing but allows user to retry/stop blocking UI from - hitting a refresh button. This does not actually stop the thread, as it - will complete after the connection timeout regardless. If the thread - does complete with a successful response, this will be still displayed - on next UI refresh (ie no update, or update available). - """ - if self._check_thread is not None: - self.print_verbose("Thread will end in normal course.") - # however, "There is no direct kill method on a thread object." - # better to let it run its course - # self._check_thread.stop() - self._async_checking = False - self._error = None - self._error_msg = None - - -# ----------------------------------------------------------------------------- -# Updater Engines -# ----------------------------------------------------------------------------- - - -class BitbucketEngine: - """Integration to Bitbucket API for git-formatted repositories""" - - def __init__(self): - self.api_url = 'https://api.bitbucket.org' - self.token = None - self.name = "bitbucket" - - def form_repo_url(self, updater): - return "{}/2.0/repositories/{}/{}".format( - self.api_url, updater.user, updater.repo) - - def form_tags_url(self, updater): - return self.form_repo_url(updater) + "/refs/tags?sort=-name" - - def form_branch_url(self, branch, updater): - return self.get_zip_url(branch, updater) - - def get_zip_url(self, name, updater): - return "https://bitbucket.org/{user}/{repo}/get/{name}.zip".format( - user=updater.user, - repo=updater.repo, - name=name) - - def parse_tags(self, response, updater): - if response is None: - return list() - return [ - { - "name": tag["name"], - "zipball_url": self.get_zip_url(tag["name"], updater) - } for tag in response["values"]] - - -class GithubEngine: - """Integration to Github API""" - - def __init__(self): - self.api_url = 'https://api.github.com' - self.token = None - self.name = "github" - - def form_repo_url(self, updater): - return "{}/repos/{}/{}".format( - self.api_url, updater.user, updater.repo) - - def form_tags_url(self, updater): - if updater.use_releases: - return "{}/releases".format(self.form_repo_url(updater)) - else: - return "{}/tags".format(self.form_repo_url(updater)) - - def form_branch_list_url(self, updater): - return "{}/branches".format(self.form_repo_url(updater)) - - def form_branch_url(self, branch, updater): - return "{}/zipball/{}".format(self.form_repo_url(updater), branch) - - def parse_tags(self, response, updater): - if response is None: - return list() - return response - - -class GitlabEngine: - """Integration to GitLab API""" - - def __init__(self): - self.api_url = 'https://gitlab.com' - self.token = None - self.name = "gitlab" - - def form_repo_url(self, updater): - return "{}/api/v4/projects/{}".format(self.api_url, updater.repo) - - def form_tags_url(self, updater): - return "{}/repository/tags".format(self.form_repo_url(updater)) - - def form_branch_list_url(self, updater): - # does not validate branch name. - return "{}/repository/branches".format( - self.form_repo_url(updater)) - - def form_branch_url(self, branch, updater): - # Could clash with tag names and if it does, it will download TAG zip - # instead of branch zip to get direct path, would need. - return "{}/repository/archive.zip?sha={}".format( - self.form_repo_url(updater), branch) - - def get_zip_url(self, sha, updater): - return "{base}/repository/archive.zip?sha={sha}".format( - base=self.form_repo_url(updater), - sha=sha) - - # def get_commit_zip(self, id, updater): - # return self.form_repo_url(updater)+"/repository/archive.zip?sha:"+id - - def parse_tags(self, response, updater): - if response is None: - return list() - return [ - { - "name": tag["name"], - "zipball_url": self.get_zip_url(tag["commit"]["id"], updater) - } for tag in response] - - -# ----------------------------------------------------------------------------- -# The module-shared class instance, -# should be what's imported to other files -# ----------------------------------------------------------------------------- - -Updater = SingletonUpdater() diff --git a/addon_updater_ops.py b/addon_updater_ops.py deleted file mode 100644 index 2db279a..0000000 --- a/addon_updater_ops.py +++ /dev/null @@ -1,1538 +0,0 @@ -# ##### BEGIN GPL LICENSE BLOCK ##### -# -# This program 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 2 -# of the License, or (at your option) any later version. -# -# This program 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 this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# ##### END GPL LICENSE BLOCK ##### - -"""Blender UI integrations for the addon updater. - -Implements draw calls, popups, and operators that use the addon_updater. -""" - -import os -import traceback - -import bpy -from bpy.app.handlers import persistent - -# Safely import the updater. -# Prevents popups for users with invalid python installs e.g. missing libraries -# and will replace with a fake class instead if it fails (so UI draws work). -try: - from .addon_updater import Updater as updater -except Exception as e: - print("ERROR INITIALIZING UPDATER") - print(str(e)) - traceback.print_exc() - - class SingletonUpdaterNone(object): - """Fake, bare minimum fields and functions for the updater object.""" - - def __init__(self): - self.invalid_updater = True # Used to distinguish bad install. - - self.addon = None - self.verbose = False - self.use_print_traces = True - self.error = None - self.error_msg = None - self.async_checking = None - - def clear_state(self): - self.addon = None - self.verbose = False - self.invalid_updater = True - self.error = None - self.error_msg = None - self.async_checking = None - - def run_update(self, force, callback, clean): - pass - - def check_for_update(self, now): - pass - - updater = SingletonUpdaterNone() - updater.error = "Error initializing updater module" - updater.error_msg = str(e) - -# Must declare this before classes are loaded, otherwise the bl_idname's will -# not match and have errors. Must be all lowercase and no spaces! Should also -# be unique among any other addons that could exist (using this updater code), -# to avoid clashes in operator registration. -updater.addon = "xps_tools" - - -# ----------------------------------------------------------------------------- -# Blender version utils -# ----------------------------------------------------------------------------- -def make_annotations(cls): - """Add annotation attribute to fields to avoid Blender 2.8+ warnings""" - if not hasattr(bpy.app, "version") or bpy.app.version < (2, 80): - return cls - if bpy.app.version < (2, 93, 0): - bl_props = {k: v for k, v in cls.__dict__.items() - if isinstance(v, tuple)} - else: - bl_props = {k: v for k, v in cls.__dict__.items() - if isinstance(v, bpy.props._PropertyDeferred)} - if bl_props: - if '__annotations__' not in cls.__dict__: - setattr(cls, '__annotations__', {}) - annotations = cls.__dict__['__annotations__'] - for k, v in bl_props.items(): - annotations[k] = v - delattr(cls, k) - return cls - - -def layout_split(layout, factor=0.0, align=False): - """Intermediate method for pre and post blender 2.8 split UI function""" - if not hasattr(bpy.app, "version") or bpy.app.version < (2, 80): - return layout.split(percentage=factor, align=align) - return layout.split(factor=factor, align=align) - - -def get_user_preferences(context=None): - """Intermediate method for pre and post blender 2.8 grabbing preferences""" - if not context: - context = bpy.context - prefs = None - if hasattr(context, "user_preferences"): - prefs = context.user_preferences.addons.get(__package__, None) - elif hasattr(context, "preferences"): - prefs = context.preferences.addons.get(__package__, None) - if prefs: - return prefs.preferences - # To make the addon stable and non-exception prone, return None - # raise Exception("Could not fetch user preferences") - return None - - -# ----------------------------------------------------------------------------- -# Updater operators -# ----------------------------------------------------------------------------- - - -# Simple popup to prompt use to check for update & offer install if available. -class AddonUpdaterInstallPopup(bpy.types.Operator): - """Check and install update if available""" - bl_label = "Update {x} addon".format(x=updater.addon) - bl_idname = updater.addon + ".updater_install_popup" - bl_description = "Popup to check and display current updates available" - bl_options = {'REGISTER', 'INTERNAL'} - - # if true, run clean install - ie remove all files before adding new - # equivalent to deleting the addon and reinstalling, except the - # updater folder/backup folder remains - clean_install = bpy.props.BoolProperty( - name="Clean install", - description=("If enabled, completely clear the addon's folder before " - "installing new update, creating a fresh install"), - default=False, - options={'HIDDEN'} - ) - - ignore_enum = bpy.props.EnumProperty( - name="Process update", - description="Decide to install, ignore, or defer new addon update", - items=[ - ("install", "Update Now", "Install update now"), - ("ignore", "Ignore", "Ignore this update to prevent future popups"), - ("defer", "Defer", "Defer choice till next blender session") - ], - options={'HIDDEN'} - ) - - def check(self, context): - return True - - def invoke(self, context, event): - return context.window_manager.invoke_props_dialog(self) - - def draw(self, context): - layout = self.layout - if updater.invalid_updater: - layout.label(text="Updater module error") - return - elif updater.update_ready: - col = layout.column() - col.scale_y = 0.7 - col.label(text="Update {} ready!".format(updater.update_version), - icon="LOOP_FORWARDS") - col.label(text="Choose 'Update Now' & press OK to install, ", - icon="BLANK1") - col.label(text="or click outside window to defer", icon="BLANK1") - row = col.row() - row.prop(self, "ignore_enum", expand=True) - col.split() - elif not updater.update_ready: - col = layout.column() - col.scale_y = 0.7 - col.label(text="No updates available") - col.label(text="Press okay to dismiss dialog") - # add option to force install - else: - # Case: updater.update_ready = None - # we have not yet checked for the update. - layout.label(text="Check for update now?") - - # Potentially in future, UI to 'check to select/revert to old version'. - - def execute(self, context): - # In case of error importing updater. - if updater.invalid_updater: - return {'CANCELLED'} - - if updater.manual_only: - bpy.ops.wm.url_open(url=updater.website) - elif updater.update_ready: - - # Action based on enum selection. - if self.ignore_enum == 'defer': - return {'FINISHED'} - elif self.ignore_enum == 'ignore': - updater.ignore_update() - return {'FINISHED'} - - res = updater.run_update(force=False, - callback=post_update_callback, - clean=self.clean_install) - - # Should return 0, if not something happened. - if updater.verbose: - if res == 0: - print("Updater returned successful") - else: - print("Updater returned {}, error occurred".format(res)) - elif updater.update_ready is None: - _ = updater.check_for_update(now=True) - - # Re-launch this dialog. - atr = AddonUpdaterInstallPopup.bl_idname.split(".") - getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') - else: - updater.print_verbose("Doing nothing, not ready for update") - return {'FINISHED'} - - -# User preference check-now operator -class AddonUpdaterCheckNow(bpy.types.Operator): - bl_label = "Check now for " + updater.addon + " update" - bl_idname = updater.addon + ".updater_check_now" - bl_description = "Check now for an update to the {} addon".format( - updater.addon) - bl_options = {'REGISTER', 'INTERNAL'} - - def execute(self, context): - if updater.invalid_updater: - return {'CANCELLED'} - - if updater.async_checking and updater.error is None: - # Check already happened. - # Used here to just avoid constant applying settings below. - # Ignoring if error, to prevent being stuck on the error screen. - return {'CANCELLED'} - - # apply the UI settings - settings = get_user_preferences(context) - if not settings: - updater.print_verbose( - "Could not get {} preferences, update check skipped".format( - __package__)) - return {'CANCELLED'} - - updater.set_check_interval( - enabled=settings.auto_check_update, - months=settings.updater_interval_months, - days=settings.updater_interval_days, - hours=settings.updater_interval_hours, - minutes=settings.updater_interval_minutes) - - # Input is an optional callback function. This function should take a - # bool input. If true: update ready, if false: no update ready. - updater.check_for_update_now(ui_refresh) - - return {'FINISHED'} - - -class AddonUpdaterUpdateNow(bpy.types.Operator): - bl_label = "Update " + updater.addon + " addon now" - bl_idname = updater.addon + ".updater_update_now" - bl_description = "Update to the latest version of the {x} addon".format( - x=updater.addon) - bl_options = {'REGISTER', 'INTERNAL'} - - # If true, run clean install - ie remove all files before adding new - # equivalent to deleting the addon and reinstalling, except the updater - # folder/backup folder remains. - clean_install = bpy.props.BoolProperty( - name="Clean install", - description=("If enabled, completely clear the addon's folder before " - "installing new update, creating a fresh install"), - default=False, - options={'HIDDEN'} - ) - - def execute(self, context): - - # in case of error importing updater - if updater.invalid_updater: - return {'CANCELLED'} - - if updater.manual_only: - bpy.ops.wm.url_open(url=updater.website) - if updater.update_ready: - # if it fails, offer to open the website instead - try: - res = updater.run_update(force=False, - callback=post_update_callback, - clean=self.clean_install) - - # Should return 0, if not something happened. - if updater.verbose: - if res == 0: - print("Updater returned successful") - else: - print("Updater error response: {}".format(res)) - except Exception as expt: - updater._error = "Error trying to run update" - updater._error_msg = str(expt) - updater.print_trace() - atr = AddonUpdaterInstallManually.bl_idname.split(".") - getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') - elif updater.update_ready is None: - (update_ready, version, link) = updater.check_for_update(now=True) - # Re-launch this dialog. - atr = AddonUpdaterInstallPopup.bl_idname.split(".") - getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') - - elif not updater.update_ready: - self.report({'INFO'}, "Nothing to update") - return {'CANCELLED'} - else: - self.report( - {'ERROR'}, "Encountered a problem while trying to update") - return {'CANCELLED'} - - return {'FINISHED'} - - -class AddonUpdaterUpdateTarget(bpy.types.Operator): - bl_label = updater.addon + " version target" - bl_idname = updater.addon + ".updater_update_target" - bl_description = "Install a targeted version of the {x} addon".format( - x=updater.addon) - bl_options = {'REGISTER', 'INTERNAL'} - - def target_version(self, context): - # In case of error importing updater. - if updater.invalid_updater: - ret = [] - - ret = [] - i = 0 - for tag in updater.tags: - ret.append((tag, tag, "Select to install " + tag)) - i += 1 - return ret - - target = bpy.props.EnumProperty( - name="Target version to install", - description="Select the version to install", - items=target_version - ) - - # If true, run clean install - ie remove all files before adding new - # equivalent to deleting the addon and reinstalling, except the - # updater folder/backup folder remains. - clean_install = bpy.props.BoolProperty( - name="Clean install", - description=("If enabled, completely clear the addon's folder before " - "installing new update, creating a fresh install"), - default=False, - options={'HIDDEN'} - ) - - @classmethod - def poll(cls, context): - if updater.invalid_updater: - return False - return updater.update_ready is not None and len(updater.tags) > 0 - - def invoke(self, context, event): - return context.window_manager.invoke_props_dialog(self) - - def draw(self, context): - layout = self.layout - if updater.invalid_updater: - layout.label(text="Updater error") - return - split = layout_split(layout, factor=0.5) - sub_col = split.column() - sub_col.label(text="Select install version") - sub_col = split.column() - sub_col.prop(self, "target", text="") - - def execute(self, context): - # In case of error importing updater. - if updater.invalid_updater: - return {'CANCELLED'} - - res = updater.run_update( - force=False, - revert_tag=self.target, - callback=post_update_callback, - clean=self.clean_install) - - # Should return 0, if not something happened. - if res == 0: - updater.print_verbose("Updater returned successful") - else: - updater.print_verbose( - "Updater returned {}, , error occurred".format(res)) - return {'CANCELLED'} - - return {'FINISHED'} - - -class AddonUpdaterInstallManually(bpy.types.Operator): - """As a fallback, direct the user to download the addon manually""" - bl_label = "Install update manually" - bl_idname = updater.addon + ".updater_install_manually" - bl_description = "Proceed to manually install update" - bl_options = {'REGISTER', 'INTERNAL'} - - error = bpy.props.StringProperty( - name="Error Occurred", - default="", - options={'HIDDEN'} - ) - - def invoke(self, context, event): - return context.window_manager.invoke_popup(self) - - def draw(self, context): - layout = self.layout - - if updater.invalid_updater: - layout.label(text="Updater error") - return - - # Display error if a prior autoamted install failed. - if self.error != "": - col = layout.column() - col.scale_y = 0.7 - col.label(text="There was an issue trying to auto-install", - icon="ERROR") - col.label(text="Press the download button below and install", - icon="BLANK1") - col.label(text="the zip file like a normal addon.", icon="BLANK1") - else: - col = layout.column() - col.scale_y = 0.7 - col.label(text="Install the addon manually") - col.label(text="Press the download button below and install") - col.label(text="the zip file like a normal addon.") - - # If check hasn't happened, i.e. accidentally called this menu, - # allow to check here. - - row = layout.row() - - if updater.update_link is not None: - row.operator( - "wm.url_open", - text="Direct download").url = updater.update_link - else: - row.operator( - "wm.url_open", - text="(failed to retrieve direct download)") - row.enabled = False - - if updater.website is not None: - row = layout.row() - ops = row.operator("wm.url_open", text="Open website") - ops.url = updater.website - else: - row = layout.row() - row.label(text="See source website to download the update") - - def execute(self, context): - return {'FINISHED'} - - -class AddonUpdaterUpdatedSuccessful(bpy.types.Operator): - """Addon in place, popup telling user it completed or what went wrong""" - bl_label = "Installation Report" - bl_idname = updater.addon + ".updater_update_successful" - bl_description = "Update installation response" - bl_options = {'REGISTER', 'INTERNAL', 'UNDO'} - - error = bpy.props.StringProperty( - name="Error Occurred", - default="", - options={'HIDDEN'} - ) - - def invoke(self, context, event): - return context.window_manager.invoke_props_popup(self, event) - - def draw(self, context): - layout = self.layout - - if updater.invalid_updater: - layout.label(text="Updater error") - return - - saved = updater.json - if self.error != "": - col = layout.column() - col.scale_y = 0.7 - col.label(text="Error occurred, did not install", icon="ERROR") - if updater.error_msg: - msg = updater.error_msg - else: - msg = self.error - col.label(text=str(msg), icon="BLANK1") - rw = col.row() - rw.scale_y = 2 - rw.operator( - "wm.url_open", - text="Click for manual download.", - icon="BLANK1").url = updater.website - elif not updater.auto_reload_post_update: - # Tell user to restart blender after an update/restore! - if "just_restored" in saved and saved["just_restored"]: - col = layout.column() - col.label(text="Addon restored", icon="RECOVER_LAST") - alert_row = col.row() - alert_row.alert = True - alert_row.operator( - "wm.quit_blender", - text="Restart blender to reload", - icon="BLANK1") - updater.json_reset_restore() - else: - col = layout.column() - col.label( - text="Addon successfully installed", icon="FILE_TICK") - alert_row = col.row() - alert_row.alert = True - alert_row.operator( - "wm.quit_blender", - text="Restart blender to reload", - icon="BLANK1") - - else: - # reload addon, but still recommend they restart blender - if "just_restored" in saved and saved["just_restored"]: - col = layout.column() - col.scale_y = 0.7 - col.label(text="Addon restored", icon="RECOVER_LAST") - col.label( - text="Consider restarting blender to fully reload.", - icon="BLANK1") - updater.json_reset_restore() - else: - col = layout.column() - col.scale_y = 0.7 - col.label( - text="Addon successfully installed", icon="FILE_TICK") - col.label( - text="Consider restarting blender to fully reload.", - icon="BLANK1") - - def execute(self, context): - return {'FINISHED'} - - -class AddonUpdaterRestoreBackup(bpy.types.Operator): - """Restore addon from backup""" - bl_label = "Restore backup" - bl_idname = updater.addon + ".updater_restore_backup" - bl_description = "Restore addon from backup" - bl_options = {'REGISTER', 'INTERNAL'} - - @classmethod - def poll(cls, context): - try: - return os.path.isdir(os.path.join(updater.stage_path, "backup")) - except: - return False - - def execute(self, context): - # in case of error importing updater - if updater.invalid_updater: - return {'CANCELLED'} - updater.restore_backup() - return {'FINISHED'} - - -class AddonUpdaterIgnore(bpy.types.Operator): - """Ignore update to prevent future popups""" - bl_label = "Ignore update" - bl_idname = updater.addon + ".updater_ignore" - bl_description = "Ignore update to prevent future popups" - bl_options = {'REGISTER', 'INTERNAL'} - - @classmethod - def poll(cls, context): - if updater.invalid_updater: - return False - elif updater.update_ready: - return True - else: - return False - - def execute(self, context): - # in case of error importing updater - if updater.invalid_updater: - return {'CANCELLED'} - updater.ignore_update() - self.report({"INFO"}, "Open addon preferences for updater options") - return {'FINISHED'} - - -class AddonUpdaterEndBackground(bpy.types.Operator): - """Stop checking for update in the background""" - bl_label = "End background check" - bl_idname = updater.addon + ".end_background_check" - bl_description = "Stop checking for update in the background" - bl_options = {'REGISTER', 'INTERNAL'} - - def execute(self, context): - # in case of error importing updater - if updater.invalid_updater: - return {'CANCELLED'} - updater.stop_async_check_update() - return {'FINISHED'} - - -# ----------------------------------------------------------------------------- -# Handler related, to create popups -# ----------------------------------------------------------------------------- - - -# global vars used to prevent duplicate popup handlers -ran_auto_check_install_popup = False -ran_update_success_popup = False - -# global var for preventing successive calls -ran_background_check = False - - -@persistent -def updater_run_success_popup_handler(scene): - global ran_update_success_popup - ran_update_success_popup = True - - # in case of error importing updater - if updater.invalid_updater: - return - - try: - if "scene_update_post" in dir(bpy.app.handlers): - bpy.app.handlers.scene_update_post.remove( - updater_run_success_popup_handler) - else: - bpy.app.handlers.depsgraph_update_post.remove( - updater_run_success_popup_handler) - except: - pass - - atr = AddonUpdaterUpdatedSuccessful.bl_idname.split(".") - getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') - - -@persistent -def updater_run_install_popup_handler(scene): - global ran_auto_check_install_popup - ran_auto_check_install_popup = True - updater.print_verbose("Running the install popup handler.") - - # in case of error importing updater - if updater.invalid_updater: - return - - try: - if "scene_update_post" in dir(bpy.app.handlers): - bpy.app.handlers.scene_update_post.remove( - updater_run_install_popup_handler) - else: - bpy.app.handlers.depsgraph_update_post.remove( - updater_run_install_popup_handler) - except: - pass - - if "ignore" in updater.json and updater.json["ignore"]: - return # Don't do popup if ignore pressed. - elif "version_text" in updater.json and updater.json["version_text"].get("version"): - version = updater.json["version_text"]["version"] - ver_tuple = updater.version_tuple_from_text(version) - - if ver_tuple < updater.current_version: - # User probably manually installed to get the up to date addon - # in here. Clear out the update flag using this function. - updater.print_verbose( - "{} updater: appears user updated, clearing flag".format( - updater.addon)) - updater.json_reset_restore() - return - atr = AddonUpdaterInstallPopup.bl_idname.split(".") - getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') - - -def background_update_callback(update_ready): - """Passed into the updater, background thread updater""" - global ran_auto_check_install_popup - updater.print_verbose("Running background update callback") - - # In case of error importing updater. - if updater.invalid_updater: - return - if not updater.show_popups: - return - if not update_ready: - return - - # See if we need add to the update handler to trigger the popup. - handlers = [] - if "scene_update_post" in dir(bpy.app.handlers): # 2.7x - handlers = bpy.app.handlers.scene_update_post - else: # 2.8+ - handlers = bpy.app.handlers.depsgraph_update_post - in_handles = updater_run_install_popup_handler in handlers - - if in_handles or ran_auto_check_install_popup: - return - - if "scene_update_post" in dir(bpy.app.handlers): # 2.7x - bpy.app.handlers.scene_update_post.append( - updater_run_install_popup_handler) - else: # 2.8+ - bpy.app.handlers.depsgraph_update_post.append( - updater_run_install_popup_handler) - ran_auto_check_install_popup = True - updater.print_verbose("Attempted popup prompt") - - -def post_update_callback(module_name, res=None): - """Callback for once the run_update function has completed. - - Only makes sense to use this if "auto_reload_post_update" == False, - i.e. don't auto-restart the addon. - - Arguments: - module_name: returns the module name from updater, but unused here. - res: If an error occurred, this is the detail string. - """ - - # In case of error importing updater. - if updater.invalid_updater: - return - - if res is None: - # This is the same code as in conditional at the end of the register - # function, ie if "auto_reload_post_update" == True, skip code. - updater.print_verbose( - "{} updater: Running post update callback".format(updater.addon)) - - atr = AddonUpdaterUpdatedSuccessful.bl_idname.split(".") - getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') - global ran_update_success_popup - ran_update_success_popup = True - else: - # Some kind of error occurred and it was unable to install, offer - # manual download instead. - atr = AddonUpdaterUpdatedSuccessful.bl_idname.split(".") - getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT', error=res) - return - - -def ui_refresh(update_status): - """Redraw the ui once an async thread has completed""" - for windowManager in bpy.data.window_managers: - for window in windowManager.windows: - for area in window.screen.areas: - area.tag_redraw() - - -def check_for_update_background(): - """Function for asynchronous background check. - - *Could* be called on register, but would be bad practice as the bare - minimum code should run at the moment of registration (addon ticked). - """ - if updater.invalid_updater: - return - global ran_background_check - if ran_background_check: - # Global var ensures check only happens once. - return - elif updater.update_ready is not None or updater.async_checking: - # Check already happened. - # Used here to just avoid constant applying settings below. - return - - # Apply the UI settings. - settings = get_user_preferences(bpy.context) - if not settings: - return - updater.set_check_interval(enabled=settings.auto_check_update, - months=settings.updater_interval_months, - days=settings.updater_interval_days, - hours=settings.updater_interval_hours, - minutes=settings.updater_interval_minutes) - - # Input is an optional callback function. This function should take a bool - # input, if true: update ready, if false: no update ready. - updater.check_for_update_async(background_update_callback) - ran_background_check = True - - -def check_for_update_nonthreaded(self, context): - """Can be placed in front of other operators to launch when pressed""" - if updater.invalid_updater: - return - - # Only check if it's ready, ie after the time interval specified should - # be the async wrapper call here. - settings = get_user_preferences(bpy.context) - if not settings: - if updater.verbose: - print("Could not get {} preferences, update check skipped".format( - __package__)) - return - updater.set_check_interval(enabled=settings.auto_check_update, - months=settings.updater_interval_months, - days=settings.updater_interval_days, - hours=settings.updater_interval_hours, - minutes=settings.updater_interval_minutes) - - (update_ready, version, link) = updater.check_for_update(now=False) - if update_ready: - atr = AddonUpdaterInstallPopup.bl_idname.split(".") - getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') - else: - updater.print_verbose("No update ready") - self.report({'INFO'}, "No update ready") - - -def show_reload_popup(): - """For use in register only, to show popup after re-enabling the addon. - - Must be enabled by developer. - """ - if updater.invalid_updater: - return - saved_state = updater.json - global ran_update_success_popup - - has_state = saved_state is not None - just_updated = "just_updated" in saved_state - updated_info = saved_state["just_updated"] - - if not (has_state and just_updated and updated_info): - return - - updater.json_reset_postupdate() # So this only runs once. - - # No handlers in this case. - if not updater.auto_reload_post_update: - return - - # See if we need add to the update handler to trigger the popup. - handlers = [] - if "scene_update_post" in dir(bpy.app.handlers): # 2.7x - handlers = bpy.app.handlers.scene_update_post - else: # 2.8+ - handlers = bpy.app.handlers.depsgraph_update_post - in_handles = updater_run_success_popup_handler in handlers - - if in_handles or ran_update_success_popup: - return - - if "scene_update_post" in dir(bpy.app.handlers): # 2.7x - bpy.app.handlers.scene_update_post.append( - updater_run_success_popup_handler) - else: # 2.8+ - bpy.app.handlers.depsgraph_update_post.append( - updater_run_success_popup_handler) - ran_update_success_popup = True - - -# ----------------------------------------------------------------------------- -# Example UI integrations -# ----------------------------------------------------------------------------- -def update_notice_box_ui(self, context): - """Update notice draw, to add to the end or beginning of a panel. - - After a check for update has occurred, this function will draw a box - saying an update is ready, and give a button for: update now, open website, - or ignore popup. Ideal to be placed at the end / beginning of a panel. - """ - - if updater.invalid_updater: - return - - saved_state = updater.json - if not updater.auto_reload_post_update: - if "just_updated" in saved_state and saved_state["just_updated"]: - layout = self.layout - box = layout.box() - col = box.column() - alert_row = col.row() - alert_row.alert = True - alert_row.operator( - "wm.quit_blender", - text="Restart blender", - icon="ERROR") - col.label(text="to complete update") - return - - # If user pressed ignore, don't draw the box. - if "ignore" in updater.json and updater.json["ignore"]: - return - if not updater.update_ready: - return - - layout = self.layout - box = layout.box() - col = box.column(align=True) - col.alert = True - col.label(text="Update ready!", icon="ERROR") - col.alert = False - col.separator() - row = col.row(align=True) - split = row.split(align=True) - colL = split.column(align=True) - colL.scale_y = 1.5 - colL.operator(AddonUpdaterIgnore.bl_idname, icon="X", text="Ignore") - colR = split.column(align=True) - colR.scale_y = 1.5 - if not updater.manual_only: - colR.operator(AddonUpdaterUpdateNow.bl_idname, - text="Update", icon="LOOP_FORWARDS") - col.operator("wm.url_open", text="Open website").url = updater.website - # ops = col.operator("wm.url_open",text="Direct download") - # ops.url=updater.update_link - col.operator(AddonUpdaterInstallManually.bl_idname, - text="Install manually") - else: - # ops = col.operator("wm.url_open", text="Direct download") - # ops.url=updater.update_link - col.operator("wm.url_open", text="Get it now").url = updater.website - - -def update_settings_ui(self, context, element=None): - """Preferences - for drawing with full width inside user preferences - - A function that can be run inside user preferences panel for prefs UI. - Place inside UI draw using: - addon_updater_ops.update_settings_ui(self, context) - or by: - addon_updater_ops.update_settings_ui(context) - """ - - # Element is a UI element, such as layout, a row, column, or box. - if element is None: - element = self.layout - box = element.box() - - # In case of error importing updater. - if updater.invalid_updater: - box.label(text="Error initializing updater code:") - box.label(text=updater.error_msg) - return - settings = get_user_preferences(context) - if not settings: - box.label(text="Error getting updater preferences", icon='ERROR') - return - - # auto-update settings - box.label(text="Updater Settings") - row = box.row() - - # special case to tell user to restart blender, if set that way - if not updater.auto_reload_post_update: - saved_state = updater.json - if "just_updated" in saved_state and saved_state["just_updated"]: - row.alert = True - row.operator("wm.quit_blender", - text="Restart blender to complete update", - icon="ERROR") - return - - split = layout_split(row, factor=0.4) - sub_col = split.column() - sub_col.prop(settings, "auto_check_update") - sub_col = split.column() - - if not settings.auto_check_update: - sub_col.enabled = False - sub_row = sub_col.row() - sub_row.label(text="Interval between checks") - sub_row = sub_col.row(align=True) - check_col = sub_row.column(align=True) - check_col.prop(settings, "updater_interval_months") - check_col = sub_row.column(align=True) - check_col.prop(settings, "updater_interval_days") - check_col = sub_row.column(align=True) - - # Consider un-commenting for local dev (e.g. to set shorter intervals) - # check_col.prop(settings,"updater_interval_hours") - # check_col = sub_row.column(align=True) - # check_col.prop(settings,"updater_interval_minutes") - - # Checking / managing updates. - row = box.row() - col = row.column() - if updater.error is not None: - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.scale_y = 2 - if "ssl" in updater.error_msg.lower(): - split.enabled = True - split.operator(AddonUpdaterInstallManually.bl_idname, - text=updater.error) - else: - split.enabled = False - split.operator(AddonUpdaterCheckNow.bl_idname, - text=updater.error) - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="", icon="FILE_REFRESH") - - elif updater.update_ready is None and not updater.async_checking: - col.scale_y = 2 - col.operator(AddonUpdaterCheckNow.bl_idname) - elif updater.update_ready is None: # async is running - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.enabled = False - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, text="Checking...") - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterEndBackground.bl_idname, text="", icon="X") - - elif updater.include_branches and \ - len(updater.tags) == len(updater.include_branch_list) and not \ - updater.manual_only: - # No releases found, but still show the appropriate branch. - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.scale_y = 2 - update_now_txt = "Update directly to {}".format( - updater.include_branch_list[0]) - split.operator(AddonUpdaterUpdateNow.bl_idname, text=update_now_txt) - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="", icon="FILE_REFRESH") - - elif updater.update_ready and not updater.manual_only: - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterUpdateNow.bl_idname, - text="Update now to " + str(updater.update_version)) - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="", icon="FILE_REFRESH") - - elif updater.update_ready and updater.manual_only: - col.scale_y = 2 - dl_now_txt = "Download " + str(updater.update_version) - col.operator("wm.url_open", - text=dl_now_txt).url = updater.website - else: # i.e. that updater.update_ready == False. - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.enabled = False - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="Addon is up to date") - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="", icon="FILE_REFRESH") - - if not updater.manual_only: - col = row.column(align=True) - if updater.include_branches and len(updater.include_branch_list) > 0: - branch = updater.include_branch_list[0] - col.operator(AddonUpdaterUpdateTarget.bl_idname, - text="Install {} / old version".format(branch)) - else: - col.operator(AddonUpdaterUpdateTarget.bl_idname, - text="(Re)install addon version") - last_date = "none found" - backup_path = os.path.join(updater.stage_path, "backup") - if "backup_date" in updater.json and os.path.isdir(backup_path): - if updater.json["backup_date"] == "": - last_date = "Date not found" - else: - last_date = updater.json["backup_date"] - backup_text = "Restore addon backup ({})".format(last_date) - col.operator(AddonUpdaterRestoreBackup.bl_idname, text=backup_text) - - row = box.row() - row.scale_y = 0.7 - last_check = updater.json["last_check"] - if updater.error is not None and updater.error_msg is not None: - row.label(text=updater.error_msg) - elif last_check: - last_check = last_check[0: last_check.index(".")] - row.label(text="Last update check: " + last_check) - else: - row.label(text="Last update check: Never") - - -def update_settings_ui_condensed(self, context, element=None): - """Preferences - Condensed drawing within preferences. - - Alternate draw for user preferences or other places, does not draw a box. - """ - - # Element is a UI element, such as layout, a row, column, or box. - if element is None: - element = self.layout - row = element.row() - - # In case of error importing updater. - if updater.invalid_updater: - row.label(text="Error initializing updater code:") - row.label(text=updater.error_msg) - return - settings = get_user_preferences(context) - if not settings: - row.label(text="Error getting updater preferences", icon='ERROR') - return - - # Special case to tell user to restart blender, if set that way. - if not updater.auto_reload_post_update: - saved_state = updater.json - if "just_updated" in saved_state and saved_state["just_updated"]: - row.alert = True # mark red - row.operator( - "wm.quit_blender", - text="Restart blender to complete update", - icon="ERROR") - return - - col = row.column() - if updater.error is not None: - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.scale_y = 2 - if "ssl" in updater.error_msg.lower(): - split.enabled = True - split.operator(AddonUpdaterInstallManually.bl_idname, - text=updater.error) - else: - split.enabled = False - split.operator(AddonUpdaterCheckNow.bl_idname, - text=updater.error) - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="", icon="FILE_REFRESH") - - elif updater.update_ready is None and not updater.async_checking: - col.scale_y = 2 - col.operator(AddonUpdaterCheckNow.bl_idname) - elif updater.update_ready is None: # Async is running. - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.enabled = False - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, text="Checking...") - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterEndBackground.bl_idname, text="", icon="X") - - elif updater.include_branches and \ - len(updater.tags) == len(updater.include_branch_list) and not \ - updater.manual_only: - # No releases found, but still show the appropriate branch. - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.scale_y = 2 - now_txt = "Update directly to " + str(updater.include_branch_list[0]) - split.operator(AddonUpdaterUpdateNow.bl_idname, text=now_txt) - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="", icon="FILE_REFRESH") - - elif updater.update_ready and not updater.manual_only: - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterUpdateNow.bl_idname, - text="Update now to " + str(updater.update_version)) - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="", icon="FILE_REFRESH") - - elif updater.update_ready and updater.manual_only: - col.scale_y = 2 - dl_txt = "Download " + str(updater.update_version) - col.operator("wm.url_open", text=dl_txt).url = updater.website - else: # i.e. that updater.update_ready == False. - sub_col = col.row(align=True) - sub_col.scale_y = 1 - split = sub_col.split(align=True) - split.enabled = False - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="Addon is up to date") - split = sub_col.split(align=True) - split.scale_y = 2 - split.operator(AddonUpdaterCheckNow.bl_idname, - text="", icon="FILE_REFRESH") - - row = element.row() - row.prop(settings, "auto_check_update") - - row = element.row() - row.scale_y = 0.7 - last_check = updater.json["last_check"] - if updater.error is not None and updater.error_msg is not None: - row.label(text=updater.error_msg) - elif last_check != "" and last_check is not None: - last_check = last_check[0: last_check.index(".")] - row.label(text="Last check: " + last_check) - else: - row.label(text="Last check: Never") - - -def skip_tag_function(self, tag): - """A global function for tag skipping. - - A way to filter which tags are displayed, e.g. to limit downgrading too - long ago. - - Args: - self: The instance of the singleton addon update. - tag: the text content of a tag from the repo, e.g. "v1.2.3". - - Returns: - bool: True to skip this tag name (ie don't allow for downloading this - version), or False if the tag is allowed. - """ - - # In case of error importing updater. - if self.invalid_updater: - return False - - # ---- write any custom code here, return true to disallow version ---- # - # - # # Filter out e.g. if 'beta' is in name of release - # if 'beta' in tag.lower(): - # return True - # ---- write any custom code above, return true to disallow version --- # - - if self.include_branches: - for branch in self.include_branch_list: - if tag["name"].lower() == branch: - return False - - # Function converting string to tuple, ignoring e.g. leading 'v'. - # Be aware that this strips out other text that you might otherwise - # want to be kept and accounted for when checking tags (e.g. v1.1a vs 1.1b) - tupled = self.version_tuple_from_text(tag["name"]) - if not isinstance(tupled, tuple): - return True - - # Select the min tag version - change tuple accordingly. - if self.version_min_update is not None: - if tupled < self.version_min_update: - return True # Skip if current version below this. - - # Select the max tag version. - if self.version_max_update is not None: - if tupled >= self.version_max_update: - return True # Skip if current version at or above this. - - # In all other cases, allow showing the tag for updating/reverting. - # To simply and always show all tags, this return False could be moved - # to the start of the function definition so all tags are allowed. - return False - - -def select_link_function(self, tag): - """Only customize if trying to leverage "attachments" in *GitHub* releases. - - A way to select from one or multiple attached downloadable files from the - server, instead of downloading the default release/tag source code. - """ - - # -- Default, universal case (and is the only option for GitLab/Bitbucket) - link = tag["zipball_url"] - - # -- Example: select the first (or only) asset instead source code -- - # if "assets" in tag and "browser_download_url" in tag["assets"][0]: - # link = tag["assets"][0]["browser_download_url"] - - # -- Example: select asset based on OS, where multiple builds exist -- - # # not tested/no error checking, modify to fit your own needs! - # # assume each release has three attached builds: - # # release_windows.zip, release_OSX.zip, release_linux.zip - # # This also would logically not be used with "branches" enabled - # if platform.system() == "Darwin": # ie OSX - # link = [asset for asset in tag["assets"] if 'OSX' in asset][0] - # elif platform.system() == "Windows": - # link = [asset for asset in tag["assets"] if 'windows' in asset][0] - # elif platform.system() == "Linux": - # link = [asset for asset in tag["assets"] if 'linux' in asset][0] - - return link - - -# ----------------------------------------------------------------------------- -# Register, should be run in the register module itself -# ----------------------------------------------------------------------------- -classes = ( - AddonUpdaterInstallPopup, - AddonUpdaterCheckNow, - AddonUpdaterUpdateNow, - AddonUpdaterUpdateTarget, - AddonUpdaterInstallManually, - AddonUpdaterUpdatedSuccessful, - AddonUpdaterRestoreBackup, - AddonUpdaterIgnore, - AddonUpdaterEndBackground -) - - -def register(bl_info): - """Registering the operators in this module""" - # Safer failure in case of issue loading module. - if updater.error: - print("Exiting updater registration, " + updater.error) - return - updater.clear_state() # Clear internal vars, avoids reloading oddities. - - # Confirm your updater "engine" (Github is default if not specified). - updater.engine = "Github" - # updater.engine = "GitLab" - # updater.engine = "Bitbucket" - - # If using private repository, indicate the token here - # Must be set after assigning the engine. - # **WARNING** Depending on the engine, this token can act like a password!! - # Only provide a token if the project is *non-public*, see readme for - # other considerations and suggestions from a security standpoint. - updater.private_token = None # "tokenstring" - - # Choose your own username, must match website (not needed for GitLab). - updater.user = "johnzero7" - - # Choose your own repository, must match git name for GitHUb and Bitbucket, - # for GitLab use project ID (numbers only). - updater.repo = "xps_tools" - - #updater.addon = # define at top of module, MUST be done first - - # Website for manual addon download, optional but recommended to set - updater.website = "https://github.com/johnzero7/XNALaraMesh/" - - # Addon subfolder path - # "sample/path/to/addon" - # default is "" or None, meaning root - updater.subfolder_path = "" - - # Used to check/compare versions. - updater.current_version = bl_info["version"] - - # Optional, to hard-set update frequency, use this here - however, this - # demo has this set via UI properties. - # updater.set_check_interval(enabled=False, months=0, days=0, hours=0, minutes=2) - - # Optional, consider turning off for production or allow as an option - # This will print out additional debugging info to the console - updater.verbose = True # make False for production default - - # Optional, customize where the addon updater processing subfolder is, - # essentially a staging folder used by the updater on its own - # Needs to be within the same folder as the addon itself - # Need to supply a full, absolute path to folder - # updater.updater_path = # set path of updater folder, by default: - # /addons/{__package__}/{__package__}_updater - - # Auto create a backup of the addon when installing other versions. - updater.backup_current = True # True by default - - # Sample ignore patterns for when creating backup of current during update. - updater.backup_ignore_patterns = ["__pycache__"] - # Alternate example patterns: - # updater.backup_ignore_patterns = [".git", "__pycache__", "*.bat", ".gitignore", "*.exe"] - - # Patterns for files to actively overwrite if found in new update file and - # are also found in the currently installed addon. Note that by default - # (ie if set to []), updates are installed in the same way as blender: - # .py files are replaced, but other file types (e.g. json, txt, blend) - # will NOT be overwritten if already present in current install. Thus - # if you want to automatically update resources/non py files, add them - # as a part of the pattern list below so they will always be overwritten by an - # update. If a pattern file is not found in new update, no action is taken - # NOTE: This does NOT delete anything proactively, rather only defines what - # is allowed to be overwritten during an update execution. - updater.overwrite_patterns = ["*.png", "*.jpg", "README.md", "LICENSE.txt"] - # updater.overwrite_patterns = [] - # other examples: - # ["*"] means ALL files/folders will be overwritten by update, was the - # behavior pre updater v1.0.4. - # [] or ["*.py","*.pyc"] matches default blender behavior, ie same effect - # if user installs update manually without deleting the existing addon - # first e.g. if existing install and update both have a resource.blend - # file, the existing installed one will remain. - # ["some.py"] means if some.py is found in addon update, it will overwrite - # any existing some.py in current addon install, if any. - # ["*.json"] means all json files found in addon update will overwrite - # those of same name in current install. - # ["*.png","README.md","LICENSE.txt"] means the readme, license, and all - # pngs will be overwritten by update. - - # Patterns for files to actively remove prior to running update. - # Useful if wanting to remove old code due to changes in filenames - # that otherwise would accumulate. Note: this runs after taking - # a backup (if enabled) but before placing in new update. If the same - # file name removed exists in the update, then it acts as if pattern - # is placed in the overwrite_patterns property. Note this is effectively - # ignored if clean=True in the run_update method. - updater.remove_pre_update_patterns = ["*.py", "*.pyc"] - # Note setting ["*"] here is equivalent to always running updates with - # clean = True in the run_update method, ie the equivalent of a fresh, - # new install. This would also delete any resources or user-made/modified - # files setting ["__pycache__"] ensures the pycache folder always removed. - # The configuration of ["*.py","*.pyc"] is a safe option as this - # will ensure no old python files/caches remain in event different addon - # versions have different filenames or structures - - # Allow branches like 'master' as an option to update to, regardless - # of release or version. - # Default behavior: releases will still be used for auto check (popup), - # but the user has the option from user preferences to directly - # update to the master branch or any other branches specified using - # the "install {branch}/older version" operator. - updater.include_branches = True - - # (GitHub only) This options allows using "releases" instead of "tags", - # which enables pulling down release logs/notes, as well as installs update - # from release-attached zips (instead of the auto-packaged code generated - # with a release/tag). Setting has no impact on BitBucket or GitLab repos. - updater.use_releases = False - # Note: Releases always have a tag, but a tag may not always be a release. - # Therefore, setting True above will filter out any non-annotated tags. - # Note 2: Using this option will also display (and filter by) the release - # name instead of the tag name, bear this in mind given the - # skip_tag_function filtering above. - - # Populate if using "include_branches" option above. - # Note: updater.include_branch_list defaults to ['master'] branch if set to - # none. Example targeting another multiple branches allowed to pull from: - # updater.include_branch_list = ['master', 'dev'] - updater.include_branch_list = None # None is the equivalent = ['master'] - - # Only allow manual install, thus prompting the user to open - # the addon's web page to download, specifically: updater.website - # Useful if only wanting to get notification of updates but not - # directly install. - updater.manual_only = False - - # Used for development only, "pretend" to install an update to test - # reloading conditions. - updater.fake_install = False # Set to true to test callback/reloading. - - # Show popups, ie if auto-check for update is enabled or a previous - # check for update in user preferences found a new version, show a popup - # (at most once per blender session, and it provides an option to ignore - # for future sessions); default behavior is set to True. - updater.show_popups = True - # note: if set to false, there will still be an "update ready" box drawn - # using the `update_notice_box_ui` panel function. - - # Override with a custom function on what tags - # to skip showing for updater; see code for function above. - # Set the min and max versions allowed to install. - # Optional, default None - # min install (>=) will install this and higher - updater.version_min_update = (2,0,0) - # updater.version_min_update = None # if not wanting to define a min - - # Max install (<) will install strictly anything lower than this version - # number, useful to limit the max version a given user can install (e.g. - # if support for a future version of blender is going away, and you don't - # want users to be prompted to install a non-functioning addon) - # updater.version_max_update = (9,9,9) - updater.version_max_update = None # None or default for no max. - - # Function defined above, customize as appropriate per repository - updater.skip_tag = skip_tag_function # min and max used in this function - - # Function defined above, optionally customize as needed per repository. - updater.select_link = select_link_function - - # Recommended false to encourage blender restarts on update completion - # Setting this option to True is NOT as stable as false (could cause - # blender crashes). - updater.auto_reload_post_update = False - - # The register line items for all operators/panels. - # If using bpy.utils.register_module(__name__) to register elsewhere - # in the addon, delete these lines (also from unregister). - for cls in classes: - # Apply annotations to remove Blender 2.8+ warnings, no effect on 2.7 - make_annotations(cls) - # Comment out this line if using bpy.utils.register_module(__name__) - bpy.utils.register_class(cls) - - # Special situation: we just updated the addon, show a popup to tell the - # user it worked. Could enclosed in try/catch in case other issues arise. - show_reload_popup() - - -def unregister(): - for cls in reversed(classes): - # Comment out this line if using bpy.utils.unregister_module(__name__). - bpy.utils.unregister_class(cls) - - # Clear global vars since they may persist if not restarting blender. - updater.clear_state() # Clear internal vars, avoids reloading oddities. - - global ran_auto_check_install_popup - ran_auto_check_install_popup = False - - global ran_update_success_popup - ran_update_success_popup = False - - global ran_background_check - ran_background_check = False diff --git a/blender_manifest.toml b/blender_manifest.toml new file mode 100644 index 0000000..3e14989 --- /dev/null +++ b/blender_manifest.toml @@ -0,0 +1,30 @@ +schema_version = "1.0.0" +id = "io_xnalara" +name = "XPS Import/Export" +version = "2.2.6" +tagline = "Import-Export for XNALara/XPS files" +maintainer = "maylog" +type = "add-on" +tags = ["Import-Export", "Pipeline"] +blender_version_min = "5.0.0" +license = ['SPDX:GPL-3.0-or-later'] +website = "https://github.com/mayloglog/XNALaraMesh-blender4.4" +source_repository = "https://github.com/johnzero7/XNALaraMesh" +description = """ +FORKED VERSION: This is a maintained fork of the original XNALaraMesh addon. +Import and export XPS (XNALara/XPS) model files (.mesh, .xps) and pose data. +Supports meshes, armatures, materials, textures, and animation. +Features: +• Import XPS models with bones and materials +• Export models to XPS format +• Pose import/export functionality +• Texture and material support +Note: This is a community-maintained version with Blender 5.0+ compatibility. +""" + +credits = [ + "2025 johnzero7 – original XNALaraMesh developer", + "2025 Clothoid & original XPS Tools contributors", + "2025 XNALara/XPS community", + "2025 maylog – current maintainer, Blender 5.0+ update & Extensions submission" +] \ No newline at end of file diff --git a/export_obj.py b/export_obj.py index 1d37b7b..6a8da4c 100644 --- a/export_obj.py +++ b/export_obj.py @@ -1,898 +1,315 @@ -# ##### BEGIN GPL LICENSE BLOCK ##### -# -# This program 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 2 -# of the License, or (at your option) any later version. -# -# This program 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 this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# ##### END GPL LICENSE BLOCK ##### - import os - import bpy import mathutils -import operator -import bpy_extras.io_utils - -from bpy_extras.wm_utils.progress_report import ( - ProgressReport, - ProgressReportSubstep, -) - - -def name_compat(name): - if name is None: - return 'None' - else: - return name.replace(' ', '_') - - -def mesh_triangulate(me): - import bmesh - bm = bmesh.new() - bm.from_mesh(me) - bmesh.ops.triangulate(bm, faces=bm.faces) - bm.to_mesh(me) - bm.free() - - -def write_arl(scene, filepath, path_mode, copy_set, mtl_dict, armatures): - source_dir = os.path.dirname(bpy.data.filepath) - dest_dir = os.path.dirname(filepath) +from mathutils import Matrix, Vector - armature_ob, ob_mat, EXPORT_GLOBAL_MATRIX = armatures[0] - - if armature_ob: - with open(filepath, "w", encoding="utf8", newline="\n") as f: - fw = f.write - fw('# XPS NGFF ARL Blender Exporter file: %r\n' % - (os.path.basename(bpy.data.filepath) or "None")) - fw('# Version: %g\n' % (0.1)) - fw('%i # bone Count\n' % len(armature_ob.data.bones)) - - armature_data = armature_ob.data.copy() - armature_data.transform(EXPORT_GLOBAL_MATRIX * ob_mat) - - bones = armature_data.bones - for bone in bones: - fw('%s\n' % bone.name) - parent_bone_id = -1 - if bone.parent: - parent_bone_name = bone.parent.name - parent_bone_id = bones.find(parent_bone_name) - fw('%i\n' % parent_bone_id) - fw('%g %g %g\n' % bone.head_local[:]) - - -def write_mtl(scene, filepath, path_mode, copy_set, mtl_dict): - from mathutils import Color, Vector - - world = scene.world - if world: - world_amb = world.ambient_color - else: - world_amb = Color((0.0, 0.0, 0.0)) - - source_dir = os.path.dirname(bpy.data.filepath) - dest_dir = os.path.dirname(filepath) +def write_arl(filepath, armatures, global_matrix=None): + """Write XPS ARL bone file.""" + if global_matrix is None: + global_matrix = Matrix() + + if not armatures: + print("Warning: No armatures found for ARL export") + return False + + # We only use the first armature found (typical for XPS models) + armature_ob, ob_mat = armatures[0] + + print(f"Writing ARL file: {filepath}") + print(f"Armature: {armature_ob.name}, Bones: {len(armature_ob.data.bones)}") + with open(filepath, "w", encoding="utf8", newline="\n") as f: fw = f.write - - fw('# Blender MTL File: %r\n' % + fw('# XPS NGFF ARL Blender Exporter file: %r\n' % (os.path.basename(bpy.data.filepath) or "None")) - fw('# Material Count: %i\n' % len(mtl_dict)) - - mtl_dict_values = list(mtl_dict.values()) - mtl_dict_values.sort(key=lambda m: m[0]) - - # Write material/image combinations we have used. - # Using mtl_dict.values() directly gives un-predictable order. - for mtl_mat_name, mat, face_img in mtl_dict_values: - # Get the Blender data for the material and the image. - # Having an image named None will make a bug, dont do it :) - - # Define a new material: matname_imgname - fw('\nnewmtl %s\n' % mtl_mat_name) - - if mat: - use_mirror = mat.raytrace_mirror.use and mat.raytrace_mirror.reflect_factor != 0.0 - - # convert from blenders spec to 0 - 1000 range. - if mat.specular_shader == 'WARDISO': - tspec = (0.4 - mat.specular_slope) / 0.0004 - else: - tspec = (mat.specular_hardness - 1) / 0.51 - fw('Ns %.6f\n' % tspec) - del tspec - - # Ambient - if use_mirror: - fw('Ka %.6f %.6f %.6f\n' % (mat.raytrace_mirror.reflect_factor * mat.mirror_color)[:]) - else: - fw('Ka %.6f %.6f %.6f\n' % (mat.ambient, mat.ambient, mat.ambient)) # Do not use world color! - fw('Kd %.6f %.6f %.6f\n' % (mat.diffuse_intensity * mat.diffuse_color)[:]) # Diffuse - fw('Ks %.6f %.6f %.6f\n' % (mat.specular_intensity * mat.specular_color)[:]) # Specular - # Emission, not in original MTL standard but seems pretty common, see T45766. - # XXX Blender has no color emission, it's using diffuse color instead... - fw('Ke %.6f %.6f %.6f\n' % (mat.emit * mat.diffuse_color)[:]) - if hasattr(mat, "raytrace_transparency") and hasattr(mat.raytrace_transparency, "ior"): - fw('Ni %.6f\n' % mat.raytrace_transparency.ior) # Refraction index - else: - fw('Ni %.6f\n' % 1.0) - fw('d %.6f\n' % mat.alpha) # Alpha (obj uses 'd' for dissolve) - - # See http://en.wikipedia.org/wiki/Wavefront_.obj_file for whole list of values... - # Note that mapping is rather fuzzy sometimes, trying to do our best here. - if mat.use_shadeless: - fw('illum 0\n') # ignore lighting - elif mat.specular_intensity == 0: - fw('illum 1\n') # no specular. - elif use_mirror: - if mat.use_transparency and mat.transparency_method == 'RAYTRACE': - if mat.raytrace_mirror.fresnel != 0.0: - fw('illum 7\n') # Reflection, Transparency, Ray trace and Fresnel - else: - fw('illum 6\n') # Reflection, Transparency, Ray trace - elif mat.raytrace_mirror.fresnel != 0.0: - fw('illum 5\n') # Reflection, Ray trace and Fresnel - else: - fw('illum 3\n') # Reflection and Ray trace - elif mat.use_transparency and mat.transparency_method == 'RAYTRACE': - fw('illum 9\n') # 'Glass' transparency and no Ray trace reflection... fuzzy matching, but... - else: - fw('illum 2\n') # light normaly - - else: - # Write a dummy material here? - fw('Ns 0\n') - fw('Ka %.6f %.6f %.6f\n' % world_amb[:]) # Ambient, uses mirror color, - fw('Kd 0.8 0.8 0.8\n') - fw('Ks 0.8 0.8 0.8\n') - fw('d 1\n') # No alpha - fw('illum 2\n') # light normaly - - # Write images! - if face_img: # We have an image on the face! - filepath = face_img.filepath - if filepath: # may be '' for generated images - # write relative image path - filepath = bpy_extras.io_utils.path_reference( - filepath, source_dir, dest_dir, - path_mode, "", copy_set, face_img.library) - fw('map_Kd %s\n' % filepath) # Diffuse mapping image - del filepath - else: - # so we write the materials image. - face_img = None - - if mat: # No face image. if we havea material search for MTex image. - image_map = {} - # backwards so topmost are highest priority - for mtex in reversed(mat.texture_slots): - if mtex and mtex.texture and mtex.texture.type == 'IMAGE': - image = mtex.texture.image - if image: - # texface overrides others - if (mtex.use_map_color_diffuse and - (face_img is None) and - (mtex.use_map_warp is False) and - (mtex.texture_coords != 'REFLECTION')): - image_map["map_Kd"] = (mtex, image) - if mtex.use_map_ambient: - image_map["map_Ka"] = (mtex, image) - # this is the Spec intensity channel but Ks stands for specular Color - ''' - if mtex.use_map_specular: - image_map["map_Ks"] = (mtex, image) - ''' - if mtex.use_map_color_spec: # specular color - image_map["map_Ks"] = (mtex, image) - if mtex.use_map_hardness: # specular hardness/glossiness - image_map["map_Ns"] = (mtex, image) - if mtex.use_map_alpha: - image_map["map_d"] = (mtex, image) - if mtex.use_map_translucency: - image_map["map_Tr"] = (mtex, image) - if mtex.use_map_normal: - image_map["map_Bump"] = (mtex, image) - if mtex.use_map_displacement: - image_map["disp"] = (mtex, image) - if mtex.use_map_color_diffuse and (mtex.texture_coords == 'REFLECTION'): - image_map["refl"] = (mtex, image) - if mtex.use_map_emit: - image_map["map_Ke"] = (mtex, image) - - for key, (mtex, image) in sorted(image_map.items()): - filepath = bpy_extras.io_utils.path_reference( - image.filepath, source_dir, dest_dir, - path_mode, "", copy_set, image.library) - options = [] - if key == "map_Bump": - if mtex.normal_factor != 1.0: - options.append('-bm %.6f' % mtex.normal_factor) - if mtex.offset != Vector((0.0, 0.0, 0.0)): - options.append('-o %.6f %.6f %.6f' % mtex.offset[:]) - if mtex.scale != Vector((1.0, 1.0, 1.0)): - options.append('-s %.6f %.6f %.6f' % mtex.scale[:]) - fw('%s %s %s\n' % (key, " ".join(options), repr(filepath)[1:-1])) - - -def test_nurbs_compat(ob): - if ob.type != 'CURVE': + fw('# Version: %g\n' % 0.1) + fw('%i # bone Count\n' % len(armature_ob.data.bones)) + + # Create a copy to apply transformation without modifying original + armature_data = armature_ob.data.copy() + armature_data.transform(global_matrix @ ob_mat) + + bones = armature_data.bones + for bone in bones: + fw('%s\n' % bone.name) + parent_bone_id = -1 + if bone.parent: + parent_bone_name = bone.parent.name + parent_bone_id = bones.find(parent_bone_name) + fw('%i\n' % parent_bone_id) + fw('%g %g %g\n' % bone.head_local[:]) + + print(f"ARL file written successfully: {filepath}") + return True + + +def add_bw_lines_to_obj(obj_filepath, all_weights, global_matrix=None): + """ + Add bone weight lines (bw) to OBJ file + all_weights: list of [(vertex_index, [(bone_id, weight), ...]), ...] + """ + if not all_weights: return False - - for nu in ob.data.splines: - if nu.point_count_v == 1 and nu.type != 'BEZIER': # not a surface and not bezier - return True - + + print(f"Adding bw lines to {obj_filepath}") + + # Read original OBJ file + with open(obj_filepath, 'r') as f: + lines = f.readlines() + + # Find position after all vertices + vertex_count = 0 + insert_pos = 0 + for i, line in enumerate(lines): + if line.startswith('v '): + vertex_count += 1 + elif line.startswith(('vt ', 'vn ', 'f ', 'g ', 'o ', 's ')): + # Insert before first non-vertex line + if insert_pos == 0: + insert_pos = i + break + + if insert_pos == 0: + insert_pos = len(lines) + + print(f"Found {vertex_count} vertices, inserting at position {insert_pos}") + + # Generate bw lines + bw_lines = [] + for v_idx, weights in all_weights: + # Note: OBJ vertex indices start from 1, but our indices start from 0 + # bw line format: bw [ [bone_id,weight], ... ] + bw_str = 'bw [%s]\n' % ', '.join('[%i,%g]' % w for w in weights) + bw_lines.append(bw_str) + + # Insert bw lines + new_lines = lines[:insert_pos] + bw_lines + lines[insert_pos:] + + # Write back to file + with open(obj_filepath, 'w') as f: + f.writelines(new_lines) + + print(f"Added {len(bw_lines)} bw lines to OBJ file") + return True + + +def add_vertex_colors_to_obj(obj_filepath, vcolor_data): + """ + Add vertex color lines (vc) to OBJ file + vcolor_data: [(obj_name, colors_list)], colors_list = [(r,g,b,a), ...] + """ + if not vcolor_data: + return False + + print("Adding vertex colors to OBJ file") + + # Read original OBJ file + with open(obj_filepath, 'r') as f: + lines = f.readlines() + + # Find position after all vertices + vertex_count = 0 + insert_pos = 0 + for i, line in enumerate(lines): + if line.startswith('v '): + vertex_count += 1 + elif line.startswith(('vt ', 'vn ', 'f ', 'g ', 'o ', 's ')): + if insert_pos == 0: + insert_pos = i + break + + if insert_pos == 0: + insert_pos = len(lines) + + # Collect unique vertex colors + vc_lines = [] + vc_dict = {} + vc_count = 0 + + for obj_name, colors in vcolor_data: + for color in colors: + color_key = (round(color[0], 4), round(color[1], 4), round(color[2], 4), round(color[3], 4)) + if color_key not in vc_dict: + vc_dict[color_key] = vc_count + vc_lines.append('vc %.4f %.4f %.4f %.4f\n' % color) + vc_count += 1 + + if vc_lines: + # Insert vc lines + new_lines = lines[:insert_pos] + vc_lines + lines[insert_pos:] + + # Write back to file + with open(obj_filepath, 'w') as f: + f.writelines(new_lines) + + print(f"Added {len(vc_lines)} vertex color definitions") + return True + return False -def write_nurb(fw, ob, ob_mat): - tot_verts = 0 - cu = ob.data - - # use negative indices - for nu in cu.splines: - if nu.type == 'POLY': - DEG_ORDER_U = 1 - else: - DEG_ORDER_U = nu.order_u - 1 # odd but tested to be correct - - if nu.type == 'BEZIER': - print("\tWarning, bezier curve:", ob.name, "only poly and nurbs curves supported") - continue - - if nu.point_count_v > 1: - print("\tWarning, surface:", ob.name, "only poly and nurbs curves supported") - continue - - if len(nu.points) <= DEG_ORDER_U: - print("\tWarning, order_u is lower then vert count, skipping:", ob.name) - continue - - pt_num = 0 - do_closed = nu.use_cyclic_u - do_endpoints = (do_closed == 0) and nu.use_endpoint_u - - for pt in nu.points: - fw('v %.6f %.6f %.6f\n' % (ob_mat * pt.co.to_3d())[:]) - pt_num += 1 - tot_verts += pt_num - - fw('g %s\n' % (name_compat(ob.name))) # name_compat(ob.getData(1)) could use the data name too - fw('cstype bspline\n') # not ideal, hard coded - fw('deg %d\n' % DEG_ORDER_U) # not used for curves but most files have it still - - curve_ls = [-(i + 1) for i in range(pt_num)] - - # 'curv' keyword - if do_closed: - if DEG_ORDER_U == 1: - pt_num += 1 - curve_ls.append(-1) - else: - pt_num += DEG_ORDER_U - curve_ls = curve_ls + curve_ls[0:DEG_ORDER_U] - - fw('curv 0.0 1.0 %s\n' % (" ".join([str(i) for i in curve_ls]))) # Blender has no U and V values for the curve - - # 'parm' keyword - tot_parm = (DEG_ORDER_U + 1) + pt_num - tot_parm_div = float(tot_parm - 1) - parm_ls = [(i / tot_parm_div) for i in range(tot_parm)] - - if do_endpoints: # end points, force param - for i in range(DEG_ORDER_U + 1): - parm_ls[i] = 0.0 - parm_ls[-(1 + i)] = 1.0 - - fw("parm u %s\n" % " ".join(["%.6f" % i for i in parm_ls])) - - fw('end\n') - - return tot_verts - - -def write_file(filepath, objects, scene, - EXPORT_TRI=False, - EXPORT_EDGES=False, - EXPORT_SMOOTH_GROUPS=False, - EXPORT_SMOOTH_GROUPS_BITFLAGS=False, - EXPORT_NORMALS=False, - EXPORT_VCOLORS=False, - EXPORT_UV=True, - EXPORT_MTL=True, - EXPORT_APPLY_MODIFIERS=True, - EXPORT_BLEN_OBS=True, - EXPORT_GROUP_BY_OB=False, - EXPORT_GROUP_BY_MAT=False, - EXPORT_KEEP_VERT_ORDER=False, - EXPORT_POLYGROUPS=False, - EXPORT_CURVE_AS_NURBS=True, - EXPORT_GLOBAL_MATRIX=None, - EXPORT_PATH_MODE='AUTO', - progress=ProgressReport(), - ): +def export_with_xps(context, filepath, **kwargs): """ - Basic write function. The context and options must be already set - This can be accessed externaly - eg. - write( 'c:\\test\\foobar.obj', Blender.Object.GetSelected() ) # Using default options. + Main export function: Call official exporter + add XPS data """ - if EXPORT_GLOBAL_MATRIX is None: - EXPORT_GLOBAL_MATRIX = mathutils.Matrix() - - def veckey3d(v): - return round(v.x, 4), round(v.y, 4), round(v.z, 4) - - def colkey4d(v): - return round(v[0], 4), round(v[1], 4), round(v[2], 4), 1 - - def veckey2d(v): - return round(v[0], 4), round(v[1], 4) - - def findVertexGroupName(face, vWeightMap): - """ - Searches the vertexDict to see what groups is assigned to a given face. - We use a frequency system in order to sort out the name because a given vetex can - belong to two or more groups at the same time. To find the right name for the face - we list all the possible vertex group names with their frequency and then sort by - frequency in descend order. The top element is the one shared by the highest number - of vertices is the face's group - """ - weightDict = {} - for vert_index in face.vertices: - vWeights = vWeightMap[vert_index] - for vGroupName, weight in vWeights: - weightDict[vGroupName] = weightDict.get(vGroupName, 0.0) + weight - - if weightDict: - return max((weight, vGroupName) for vGroupName, weight in weightDict.items())[1] - else: - return '(null)' - - with ProgressReportSubstep(progress, 2, "OBJ Export path: %r" % filepath, "OBJ Export Finished") as subprogress1: - with open(filepath, "w", encoding="utf8", newline="\n") as f: - fw = f.write - - # Write Header - fw('# Blender v%s OBJ File: %r\n' % (bpy.app.version_string, os.path.basename(bpy.data.filepath))) - fw('# www.blender.org\n') - - # Tell the obj file what material file to use. - if EXPORT_MTL: - mtlfilepath = os.path.splitext(filepath)[0] + ".mtl" - # filepath can contain non utf8 chars, use repr - fw('mtllib %s\n' % repr(os.path.basename(mtlfilepath))[1:-1]) - - # Tell the obj file what armature file to use. - EXPORT_ARL = True - if EXPORT_ARL: - arlfilepath = os.path.splitext(filepath)[0] + ".arl" - # filepath can contain non utf8 chars, use repr - fw('arllib %s\n' % repr(os.path.basename(arlfilepath))[1:-1]) - - # Initialize totals, these are updated each object - totverts = totuvco = totno = totvcol = 1 - - face_vert_index = 1 - - # A Dict of Materials - # (material.name, image.name):matname_imagename # matname_imagename has gaps removed. - mtl_dict = {} - # Used to reduce the usage of matname_texname materials, which can become annoying in case of - # repeated exports/imports, yet keeping unique mat names per keys! - # mtl_name: (material.name, image.name) - mtl_rev_dict = {} - - copy_set = set() - - # Get all meshes - subprogress1.enter_substeps(len(objects)) - armatures = [] - for i, ob_main in enumerate(sorted(objects, key=operator.attrgetter('name'))): - armature = ob_main.find_armature() - if armature: - armatures += [[armature, armature.matrix_world, EXPORT_GLOBAL_MATRIX]] - # ignore dupli children - if ob_main.parent and ob_main.parent.dupli_type in {'VERTS', 'FACES'}: - # XXX - subprogress1.step("Ignoring %s, dupli child..." % ob_main.name) + # Extract XPS related parameters + use_xps_arl = kwargs.get('use_xps_arl', True) + use_vcolors = kwargs.get('use_vcolors', False) + use_selection = kwargs.get('use_selection', True) + global_matrix = kwargs.get('global_matrix', None) + + if global_matrix is None: + global_matrix = Matrix() + + print(f"Exporting with XPS: ARL={use_xps_arl}, VColors={use_vcolors}") + + # Get dependency graph + depsgraph = context.evaluated_depsgraph_get() + scene = context.scene + + # Collect objects to export + if use_selection: + objects = context.selected_objects + else: + objects = scene.objects + + print(f"Found {len(objects)} objects to export") + + # Collect armatures for ARL + armatures = [] + # Collect bone weights for each mesh + all_weights = {} # Indexed by object name + # Collect vertex color data (store as data copy, not mesh references) + vcolor_data = [] # [(obj_name, vertex_colors_data)] + + if use_xps_arl or use_vcolors: + for obj in objects: + # Collect armatures + if obj.type == 'ARMATURE' and use_xps_arl: + armature_entry = (obj, obj.matrix_world.copy()) + if armature_entry not in armatures: + armatures.append(armature_entry) + print(f"Found armature: {obj.name}") + + # Collect mesh data (weights and vertex colors) + elif obj.type == 'MESH': + # Get evaluated mesh (with modifiers applied) + obj_eval = obj.evaluated_get(depsgraph) + me = obj_eval.to_mesh() + if me is None: continue - - obs = [(ob_main, ob_main.matrix_world)] - if ob_main.dupli_type != 'NONE': - # XXX - print('creating dupli_list on', ob_main.name) - ob_main.dupli_list_create(scene) - - obs += [(dob.object, dob.matrix) for dob in ob_main.dupli_list] - - # XXX debug print - print(ob_main.name, 'has', len(obs) - 1, 'dupli children') - - subprogress1.enter_substeps(len(obs)) - for ob, ob_mat in obs: - with ProgressReportSubstep(subprogress1, 6) as subprogress2: - uv_unique_count = no_unique_count = vc_unique_count = 0 - - # Nurbs curve support - if EXPORT_CURVE_AS_NURBS and test_nurbs_compat(ob): - ob_mat = EXPORT_GLOBAL_MATRIX * ob_mat - totverts += write_nurb(fw, ob, ob_mat) - continue - # END NURBS - - try: - me = ob.to_mesh(scene, EXPORT_APPLY_MODIFIERS, 'PREVIEW', calc_tessface=False) - except RuntimeError: - me = None - - if me is None: - continue - - me.transform(EXPORT_GLOBAL_MATRIX * ob_mat) - - if EXPORT_TRI: - # _must_ do this first since it re-allocs arrays - mesh_triangulate(me) - - if EXPORT_UV: - faceuv = len(me.uv_textures) > 0 - if faceuv: - uv_texture = me.uv_textures.active.data[:] - uv_layer = me.uv_layers.active.data[:] - else: - faceuv = False - - me_verts = me.vertices[:] - - # Make our own list so it can be sorted to reduce context switching - face_index_pairs = [(face, index) for index, face in enumerate(me.polygons)] - # faces = [ f for f in me.tessfaces ] - - if EXPORT_EDGES: - edges = me.edges - else: - edges = [] - - if not (len(face_index_pairs) + len(edges) + len(me.vertices)): # Make sure there is something to write - # clean up - bpy.data.meshes.remove(me) - continue # dont bother with this mesh. - - if EXPORT_NORMALS and face_index_pairs: - me.calc_normals_split() - # No need to call me.free_normals_split later, as this mesh is deleted anyway! - - loops = me.loops - vcolors = [] - if me.vertex_colors: - vcolors = me.vertex_colors[0] - - if (EXPORT_SMOOTH_GROUPS or EXPORT_SMOOTH_GROUPS_BITFLAGS) and face_index_pairs: - smooth_groups, smooth_groups_tot = me.calc_smooth_groups(EXPORT_SMOOTH_GROUPS_BITFLAGS) - if smooth_groups_tot <= 1: - smooth_groups, smooth_groups_tot = (), 0 - else: - smooth_groups, smooth_groups_tot = (), 0 - - materials = me.materials[:] - material_names = [m.name if m else None for m in materials] - - # avoid bad index errors - if not materials: - materials = [None] - material_names = [name_compat(None)] - - # Sort by Material, then images - # so we dont over context switch in the obj file. - if EXPORT_KEEP_VERT_ORDER: - pass - else: - if faceuv: - if smooth_groups: - sort_func = lambda a: (a[0].material_index, - hash(uv_texture[a[1]].image), - smooth_groups[a[1]] if a[0].use_smooth else False) - else: - sort_func = lambda a: (a[0].material_index, - hash(uv_texture[a[1]].image), - a[0].use_smooth) - elif len(materials) > 1: - if smooth_groups: - sort_func = lambda a: (a[0].material_index, - smooth_groups[a[1]] if a[0].use_smooth else False) - else: - sort_func = lambda a: (a[0].material_index, - a[0].use_smooth) - else: - # no materials - if smooth_groups: - sort_func = lambda a: smooth_groups[a[1] if a[0].use_smooth else False] - else: - sort_func = lambda a: a[0].use_smooth - - face_index_pairs.sort(key=sort_func) - - del sort_func - - # Set the default mat to no material and no image. - contextMat = 0, 0 # Can never be this, so we will label a new material the first chance we get. - contextSmooth = None # Will either be true or false, set bad to force initialization switch. - - if EXPORT_BLEN_OBS or EXPORT_GROUP_BY_OB: - name1 = ob.name - name2 = ob.data.name - if name1 == name2: - obnamestring = name_compat(name1) - else: - obnamestring = '%s_%s' % (name_compat(name1), name_compat(name2)) - - if EXPORT_BLEN_OBS: - fw('o %s\n' % obnamestring) # Write Object name - else: # if EXPORT_GROUP_BY_OB: - fw('g %s\n' % obnamestring) - - subprogress2.step() - - # Vert - for v in me_verts: - fw('v %.6f %.6f %.6f\n' % v.co[:]) - - subprogress2.step() - - # UV - if faceuv: - # in case removing some of these dont get defined. - uv = f_index = uv_index = uv_key = uv_val = uv_ls = None - - uv_face_mapping = [None] * len(face_index_pairs) - - uv_dict = {} - uv_get = uv_dict.get - for f, f_index in face_index_pairs: - uv_ls = uv_face_mapping[f_index] = [] - for uv_index, l_index in enumerate(f.loop_indices): - uv = uv_layer[l_index].uv - # include the vertex index in the key so we don't share UV's between vertices, - # allowed by the OBJ spec but can cause issues for other importers, see: T47010. - - # this works too, shared UV's for all verts - # ~ uv_key = veckey2d(uv) - uv_key = loops[l_index].vertex_index, veckey2d(uv) - - uv_val = uv_get(uv_key) - if uv_val is None: - uv_val = uv_dict[uv_key] = uv_unique_count - fw('vt %.4f %.4f\n' % uv[:]) - uv_unique_count += 1 - uv_ls.append(uv_val) - - del uv_dict, uv, f_index, uv_index, uv_ls, uv_get, uv_key, uv_val - # Only need uv_unique_count and uv_face_mapping - - subprogress2.step() - - # NORMAL, Smooth/Non smoothed. - if EXPORT_NORMALS: - no_key = no_val = None - normals_to_idx = {} - no_get = normals_to_idx.get - loops_to_normals = [0] * len(loops) - for f, f_index in face_index_pairs: - for l_idx in f.loop_indices: - no_key = veckey3d(loops[l_idx].normal) - no_val = no_get(no_key) - if no_val is None: - no_val = normals_to_idx[no_key] = no_unique_count - fw('vn %.4f %.4f %.4f\n' % no_key) - no_unique_count += 1 - loops_to_normals[l_idx] = no_val - del normals_to_idx, no_get, no_key, no_val - else: - loops_to_normals = [] - - # Vertex Color - if EXPORT_VCOLORS and vcolors: - no_key = no_val = None - vcolors_to_idx = {} - no_get = vcolors_to_idx.get - loops_to_vcolors = [0] * len(vcolors.data) - for f, f_index in face_index_pairs: - for l_idx in f.loop_indices: - no_key = colkey4d(vcolors.data[l_idx].color) - no_val = no_get(no_key) - if no_val is None: - no_val = vcolors_to_idx[no_key] = vc_unique_count - fw('vc %.4f %.4f %.4f %.4f\n' % no_key) - vc_unique_count += 1 - loops_to_vcolors[l_idx] = no_val - del vcolors_to_idx, no_get, no_key, no_val - else: - loops_to_vcolors = [] - - # Vertex wights - EXPORT_ARL = True - vweights = ob.vertex_groups - armature_ob = ob.find_armature() - armature_data = armature_ob.data - if EXPORT_ARL and armature: - for v in me_verts: - weights = [[armature_data.bones.find(vweights[g.group].name), g.weight] for g in v.groups] - weights += [[0, 0]] * (4-len(weights)) - weights.sort(key=operator.itemgetter(1), reverse=True) - fw('bw [%s]\n' % ', '.join('[%i,%g]' % tuple(pair) for pair in weights)) - - if not faceuv: - f_image = None - - subprogress2.step() - - # XXX - if EXPORT_POLYGROUPS: - # Retrieve the list of vertex groups - vertGroupNames = ob.vertex_groups.keys() - if vertGroupNames: - currentVGroup = '' - # Create a dictionary keyed by face id and listing, for each vertex, the vertex groups it belongs to - vgroupsMap = [[] for _i in range(len(me_verts))] - for v_idx, v_ls in enumerate(vgroupsMap): - v_ls[:] = [(vertGroupNames[g.group], g.weight) for g in me_verts[v_idx].groups] - - for f, f_index in face_index_pairs: - f_smooth = f.use_smooth - if f_smooth and smooth_groups: - f_smooth = smooth_groups[f_index] - f_mat = min(f.material_index, len(materials) - 1) - - if faceuv: - tface = uv_texture[f_index] - f_image = tface.image - - # MAKE KEY - if faceuv and f_image: # Object is always true. - key = material_names[f_mat], f_image.name - else: - key = material_names[f_mat], None # No image, use None instead. - - # Write the vertex group - if EXPORT_POLYGROUPS: - if vertGroupNames: - # find what vertext group the face belongs to - vgroup_of_face = findVertexGroupName(f, vgroupsMap) - if vgroup_of_face != currentVGroup: - currentVGroup = vgroup_of_face - fw('g %s\n' % vgroup_of_face) - - # CHECK FOR CONTEXT SWITCH - if key == contextMat: - pass # Context already switched, dont do anything - else: - if key[0] is None and key[1] is None: - # Write a null material, since we know the context has changed. - if EXPORT_GROUP_BY_MAT: - # can be mat_image or (null) - fw("g %s_%s\n" % (name_compat(ob.name), name_compat(ob.data.name))) - if EXPORT_MTL: - fw("usemtl (null)\n") # mat, image - - else: - mat_data = mtl_dict.get(key) - if not mat_data: - # First add to global dict so we can export to mtl - # Then write mtl - - # Make a new names from the mat and image name, - # converting any spaces to underscores with name_compat. - - # If none image dont bother adding it to the name - # Try to avoid as much as possible adding texname (or other things) - # to the mtl name (see [#32102])... - mtl_name = "%s" % name_compat(key[0]) - if mtl_rev_dict.get(mtl_name, None) not in {key, None}: - if key[1] is None: - tmp_ext = "_NONE" - else: - tmp_ext = "_%s" % name_compat(key[1]) - i = 0 - while mtl_rev_dict.get(mtl_name + tmp_ext, None) not in {key, None}: - i += 1 - tmp_ext = "_%3d" % i - mtl_name += tmp_ext - mat_data = mtl_dict[key] = mtl_name, materials[f_mat], f_image - mtl_rev_dict[mtl_name] = key - - if EXPORT_GROUP_BY_MAT: - # can be mat_image or (null) - fw("g %s_%s_%s\n" % (name_compat(ob.name), name_compat(ob.data.name), mat_data[0])) - if EXPORT_MTL: - fw("usemtl %s\n" % mat_data[0]) # can be mat_image or (null) - - contextMat = key - if f_smooth != contextSmooth: - if f_smooth: # on now off - if smooth_groups: - f_smooth = smooth_groups[f_index] - fw('s %d\n' % f_smooth) - else: - fw('s 1\n') - else: # was off now on - fw('s off\n') - contextSmooth = f_smooth - - f_v = [(vi, me_verts[v_idx], l_idx) - for vi, (v_idx, l_idx) in enumerate(zip(f.vertices, f.loop_indices))] - - fw('f') - if faceuv: - if EXPORT_NORMALS: - for vi, v, li in f_v: - fw(" %d/%d/%d" % (totverts + v.index, - totuvco + uv_face_mapping[f_index][vi], - totno + loops_to_normals[li], - )) # vert, uv, normal - if EXPORT_VCOLORS and vcolors: - fw("/%d" % (totvcol + loops_to_vcolors[li])) # add vcolor - else: # No Normals - for vi, v, li in f_v: - fw(" %d/%d" % (totverts + v.index, - totuvco + uv_face_mapping[f_index][vi], - )) # vert, uv - if EXPORT_VCOLORS and vcolors: - fw("//%d" % (totvcol + loops_to_vcolors[li])) # add vcolor - - face_vert_index += len(f_v) - - else: # No UV's - if EXPORT_NORMALS: - for vi, v, li in f_v: - fw(" %d//%d" % (totverts + v.index, totno + loops_to_normals[li])) - if EXPORT_VCOLORS and vcolors: - fw("/%d" % (totvcol + loops_to_vcolors[li])) # add vcolor - else: # No Normals - for vi, v, li in f_v: - fw(" %d" % (totverts + v.index)) - if EXPORT_VCOLORS and vcolors: - fw("///%d" % (totvcol + loops_to_vcolors[li])) # add vcolor - fw('\n') - - subprogress2.step() - - # Write edges. - if EXPORT_EDGES: - for ed in edges: - if ed.is_loose: - fw('l %d %d\n' % (totverts + ed.vertices[0], totverts + ed.vertices[1])) - - # Make the indices global rather then per mesh - totverts += len(me_verts) - totuvco += uv_unique_count - totno += no_unique_count - totvcol += vc_unique_count - - # clean up - bpy.data.meshes.remove(me) - - if ob_main.dupli_type != 'NONE': - ob_main.dupli_list_clear() - - subprogress1.leave_substeps("Finished writing geometry of '%s'." % ob_main.name) - subprogress1.leave_substeps() - - subprogress1.step("Finished exporting geometry, now exporting materials") - - # Now we have all our materials, save them - if EXPORT_MTL: - write_mtl(scene, mtlfilepath, EXPORT_PATH_MODE, copy_set, mtl_dict) - - # Save the armature - if EXPORT_ARL: - write_arl(scene, arlfilepath, EXPORT_PATH_MODE, copy_set, mtl_dict, armatures) - - # copy all collected files. - bpy_extras.io_utils.path_reference_copy(copy_set) - - -def _write(context, filepath, - EXPORT_TRI, # ok - EXPORT_EDGES, - EXPORT_SMOOTH_GROUPS, - EXPORT_SMOOTH_GROUPS_BITFLAGS, - EXPORT_NORMALS, # ok - EXPORT_VCOLORS, # ok - EXPORT_UV, # ok - EXPORT_MTL, - EXPORT_APPLY_MODIFIERS, # ok - EXPORT_BLEN_OBS, - EXPORT_GROUP_BY_OB, - EXPORT_GROUP_BY_MAT, - EXPORT_KEEP_VERT_ORDER, - EXPORT_POLYGROUPS, - EXPORT_CURVE_AS_NURBS, - EXPORT_SEL_ONLY, # ok - EXPORT_ANIMATION, - EXPORT_GLOBAL_MATRIX, - EXPORT_PATH_MODE, # Not used - ): - - with ProgressReport(context.window_manager) as progress: - base_name, ext = os.path.splitext(filepath) - context_name = [base_name, '', '', ext] # Base name, scene name, frame number, extension - - scene = context.scene - - # Exit edit mode before exporting, so current object states are exported properly. - if bpy.ops.object.mode_set.poll(): - bpy.ops.object.mode_set(mode='OBJECT') - - orig_frame = scene.frame_current - - # Export an animation? - if EXPORT_ANIMATION: - scene_frames = range(scene.frame_start, scene.frame_end + 1) # Up to and including the end frame. - else: - scene_frames = [orig_frame] # Dont export an animation. - - # Loop through all frames in the scene and export. - progress.enter_substeps(len(scene_frames)) - for frame in scene_frames: - if EXPORT_ANIMATION: # Add frame to the filepath. - context_name[2] = '_%.6d' % frame - - scene.frame_set(frame, 0.0) - if EXPORT_SEL_ONLY: - objects = context.selected_objects - else: - objects = scene.objects - - full_path = ''.join(context_name) - - # erm... bit of a problem here, this can overwrite files when exporting frames. not too bad. - # EXPORT THE FILE. - progress.enter_substeps(1) - write_file(full_path, objects, scene, - EXPORT_TRI, - EXPORT_EDGES, - EXPORT_SMOOTH_GROUPS, - EXPORT_SMOOTH_GROUPS_BITFLAGS, - EXPORT_NORMALS, - EXPORT_VCOLORS, - EXPORT_UV, - EXPORT_MTL, - EXPORT_APPLY_MODIFIERS, - EXPORT_BLEN_OBS, - EXPORT_GROUP_BY_OB, - EXPORT_GROUP_BY_MAT, - EXPORT_KEEP_VERT_ORDER, - EXPORT_POLYGROUPS, - EXPORT_CURVE_AS_NURBS, - EXPORT_GLOBAL_MATRIX, - EXPORT_PATH_MODE, - progress, - ) - progress.leave_substeps() - - scene.frame_set(orig_frame, 0.0) - progress.leave_substeps() - - -""" -Currently the exporter lacks these features: -* multiple scene export (only active scene is written) -* particles -""" + + # Copy mesh data to avoid reference issues + me_copy = me.copy() + + # Collect vertex colors + if use_vcolors and me_copy.vertex_colors: + colors = [] + color_layer = me_copy.vertex_colors.active.data + loops = me_copy.loops + + # Get first loop color for each vertex + vc_map = {} + for loop in loops: + v_idx = loop.vertex_index + if v_idx not in vc_map: + color = color_layer[loop.index].color + vc_map[v_idx] = (color[0], color[1], color[2], 1.0) + + # Convert to list sorted by vertex index + colors = [vc_map[i] for i in range(len(me_copy.vertices)) if i in vc_map] + vcolor_data.append((obj.name, colors)) + print(f"Found vertex colors on {obj.name} with {len(colors)} vertices") + + # Collect bone weights + if use_xps_arl: + armature = obj.find_armature() + if armature: + # Ensure armature is in list + armature_entry = (armature, armature.matrix_world.copy()) + if armature_entry not in armatures: + armatures.append(armature_entry) + print(f"Found armature via mesh: {armature.name}") + + # Get weight data + weights = [] + for v_idx, v in enumerate(me_copy.vertices): + v_weights = [] + for g in v.groups: + group_name = obj.vertex_groups[g.group].name + bone_id = armature.data.bones.find(group_name) + if bone_id != -1 and g.weight > 0: + v_weights.append((bone_id, g.weight)) + + # Sort by weight descending + v_weights.sort(key=lambda x: x[1], reverse=True) + # Pad to 4 weights (XPS format) + while len(v_weights) < 4: + v_weights.append((0, 0.0)) + + weights.append((v_idx, v_weights)) + + if weights: + all_weights[obj.name] = weights + print(f"Object {obj.name}: collected {len(weights)} vertex weights") + + # Clean up temporary mesh + obj_eval.to_mesh_clear() + bpy.data.meshes.remove(me_copy) + + print(f"Collected {len(armatures)} armatures, {len(all_weights)} weighted meshes, {len(vcolor_data)} meshes with vertex colors") + + # Call official OBJ exporter + obj_export_kwargs = { + 'filepath': filepath, + 'export_selected_objects': use_selection, + 'apply_modifiers': kwargs.get('use_mesh_modifiers', True), + 'export_uv': kwargs.get('use_uvs', True), + 'export_normals': kwargs.get('use_normals', False), + 'export_materials': kwargs.get('use_materials', True), + 'export_triangulated_mesh': kwargs.get('use_triangles', False), + 'export_vertex_groups': kwargs.get('use_vertex_groups', False), + 'export_smooth_groups': kwargs.get('use_smooth_groups', False), + 'smooth_group_bitflags': kwargs.get('use_smooth_groups_bitflags', False), + } + + print("Calling official OBJ exporter...") + result = bpy.ops.wm.obj_export(**obj_export_kwargs) + print(f"OBJ export result: {result}") + + if 'FINISHED' not in result: + return result + + # Add XPS bone data + if use_xps_arl and (armatures or all_weights): + print("Adding XPS bone data...") + + # Add bw lines to OBJ file + if all_weights: + # Flatten all weights + flat_weights = [] + for obj_name, weights in all_weights.items(): + flat_weights.extend(weights) + print(f"Object {obj_name}: {len(weights)} weights") + + if flat_weights: + add_bw_lines_to_obj(filepath, flat_weights, global_matrix) + + # Generate ARL file + if armatures: + arl_filepath = os.path.splitext(filepath)[0] + ".arl" + print(f"Generating ARL file: {arl_filepath}") + write_arl(arl_filepath, armatures, global_matrix) + + # Add vertex colors + if use_vcolors and vcolor_data: + print("Adding vertex colors...") + add_vertex_colors_to_obj(filepath, vcolor_data) + print(f"Added vertex colors from {len(vcolor_data)} meshes") + + print("Export completed successfully") + return {'FINISHED'} def save(context, @@ -907,6 +324,7 @@ def save(context, use_uvs=True, use_materials=True, use_mesh_modifiers=True, + use_mesh_modifiers_render=False, use_blen_objects=True, group_by_object=False, group_by_material=False, @@ -916,29 +334,33 @@ def save(context, use_selection=True, use_animation=False, global_matrix=None, - path_mode='AUTO' + path_mode='AUTO', + use_xps_arl=True, ): - - _write(context, filepath, - EXPORT_TRI=use_triangles, - EXPORT_EDGES=use_edges, - EXPORT_SMOOTH_GROUPS=use_smooth_groups, - EXPORT_SMOOTH_GROUPS_BITFLAGS=use_smooth_groups_bitflags, - EXPORT_NORMALS=use_normals, - EXPORT_VCOLORS=use_vcolors, - EXPORT_UV=use_uvs, - EXPORT_MTL=use_materials, - EXPORT_APPLY_MODIFIERS=use_mesh_modifiers, - EXPORT_BLEN_OBS=use_blen_objects, - EXPORT_GROUP_BY_OB=group_by_object, - EXPORT_GROUP_BY_MAT=group_by_material, - EXPORT_KEEP_VERT_ORDER=keep_vertex_order, - EXPORT_POLYGROUPS=use_vertex_groups, - EXPORT_CURVE_AS_NURBS=use_nurbs, - EXPORT_SEL_ONLY=use_selection, - EXPORT_ANIMATION=use_animation, - EXPORT_GLOBAL_MATRIX=global_matrix, - EXPORT_PATH_MODE=path_mode, - ) - - return {'FINISHED'} + + print(f"Starting XPS OBJ export to: {filepath}") + print(f"Options: ARL={use_xps_arl}, VColors={use_vcolors}, Selection={use_selection}") + + # Call export function + return export_with_xps(context, filepath, + use_triangles=use_triangles, + use_edges=use_edges, + use_normals=use_normals, + use_vcolors=use_vcolors, + use_smooth_groups=use_smooth_groups, + use_smooth_groups_bitflags=use_smooth_groups_bitflags, + use_uvs=use_uvs, + use_materials=use_materials, + use_mesh_modifiers=use_mesh_modifiers, + use_mesh_modifiers_render=use_mesh_modifiers_render, + use_blen_objects=use_blen_objects, + group_by_object=group_by_object, + group_by_material=group_by_material, + keep_vertex_order=keep_vertex_order, + use_vertex_groups=use_vertex_groups, + use_nurbs=use_nurbs, + use_selection=use_selection, + use_animation=use_animation, + global_matrix=global_matrix, + path_mode=path_mode, + use_xps_arl=use_xps_arl) \ No newline at end of file diff --git a/export_xnalara_model.py b/export_xnalara_model.py index b80de03..37c8457 100644 --- a/export_xnalara_model.py +++ b/export_xnalara_model.py @@ -7,81 +7,82 @@ from . import bin_ops from . import xps_material from . import xps_types -from . import node_shader_utils from .timing import timing import bpy from mathutils import Vector from collections import Counter -# imported XPS directory -rootDir = '' +try: + from .node_shader_utils import XPSShaderWrapper +except ImportError: + class XPSShaderWrapper: + def __init__(self, material, use_nodes=True): + self.material = material + self.use_nodes = use_nodes + self.diffuse_texture = None + self.lightmap_texture = None + self.normalmap_texture = None + self.normal_mask_texture = None + self.microbump1_texture = None + self.microbump2_texture = None + self.specular_texture = None + self.environment_texture = None + self.emission_texture = None +rootDir = '' +xpsSettings = None +xpsData = None def coordTransform(coords): x, y, z = coords y = -y return (x, z, y) - def faceTransform(face): return [face[0], face[2], face[1]] - def uvTransform(uv): u = uv[0] + xpsSettings.uvDisplX v = 1 - xpsSettings.uvDisplY - uv[1] return [u, v] - def rangeFloatToByte(float): return int(float * 255) % 256 - def rangeByteToFloat(byte): return byte / 255 - def uvTransformLayers(uvLayers): return list(map(uvTransform, uvLayers)) - def getArmature(selected_obj): armature_obj = next((obj for obj in selected_obj if obj.type == 'ARMATURE'), None) return armature_obj - def fillArray(array, minLen, value): - # Complete the array with selected value filled = array + [value] * (minLen - len(array)) return filled - def getOutputFilename(xpsSettingsAux): global xpsSettings xpsSettings = xpsSettingsAux - blenderExportSetup() xpsExport() blenderExportFinalize() - def blenderExportSetup(): - # switch to object mode and deselect all objectMode() - def blenderExportFinalize(): pass - def objectMode(): current_mode = bpy.context.mode if bpy.context.view_layer.objects.active and current_mode != 'OBJECT': bpy.ops.object.mode_set(mode='OBJECT', toggle=False) - def saveXpsFile(filename, xpsData): dirpath, file = os.path.split(filename) basename, ext = os.path.splitext(file) @@ -90,11 +91,9 @@ def saveXpsFile(filename, xpsData): elif ext.lower() in('.ascii'): write_ascii_xps.writeXpsModel(xpsSettings, filename, xpsData) - @timing def xpsExport(): - global rootDir - global xpsData + global rootDir, xpsData print("------------------------------------------------------------") print("---------------EXECUTING XPS PYTHON EXPORTER----------------") @@ -127,7 +126,6 @@ def xpsExport(): saveXpsFile(xpsSettings.filename, xpsData) - def exportSelected(objects): meshes = [] armatures = [] @@ -138,16 +136,13 @@ def exportSelected(objects): elif object.type == 'MESH': meshes.append(object) armature = object.find_armature() or armature - # armature = getArmature(objects) return armature, meshes - def exportArmature(armature): xpsBones = [] if armature: bones = armature.data.bones print('Exporting Armature', len(bones), 'Bones') - # activebones = [bone for bone in bones if bone.layers[0]] activebones = bones for bone in activebones: objectMatrix = armature.matrix_local @@ -165,13 +160,11 @@ def exportArmature(armature): return xpsBones - def exportMeshes(selectedArmature, selectedMeshes): xpsMeshes = [] for mesh in selectedMeshes: print('Exporting Mesh:', mesh.name) meshName = makeNamesFromMesh(mesh) - # meshName = makeNamesFromMaterials(mesh) meshTextures = getXpsMatTextures(mesh) meshVerts, meshFaces = getXpsVertices(selectedArmature, mesh) meshUvCount = len(mesh.data.uv_layers) @@ -184,15 +177,13 @@ def exportMeshes(selectedArmature, selectedMeshes): meshUvCount) xpsMeshes.append(xpsMesh) else: - dummyTexture = [xps_types.XpsTexture(0, 'dummy.png', 0)] - xpsMesh = xps_types.XpsMesh(meshName[0], dummyTexture, + xpsMesh = xps_types.XpsMesh(meshName[0], [], meshVerts[0], meshFaces[0], meshUvCount) xpsMeshes.append(xpsMesh) return xpsMeshes - def makeNamesFromMaterials(mesh): separatedMeshNames = [] materials = mesh.data.materials @@ -200,7 +191,6 @@ def makeNamesFromMaterials(mesh): separatedMeshNames.append(material.name) return separatedMeshNames - def makeNamesFromMesh(mesh): meshFullName = mesh.name renderType = xps_material.makeRenderType(meshFullName) @@ -210,7 +200,6 @@ def makeNamesFromMesh(mesh): separatedMeshNames.append(xps_material.makeRenderTypeName(renderType)) materialsCount = len(mesh.data.materials) - # separate mesh by materials for mat_idx in range(1, materialsCount): partName = '{0}.material{1:02d}'.format(meshName, mat_idx) renderType.meshName = partName @@ -218,12 +207,10 @@ def makeNamesFromMesh(mesh): separatedMeshNames.append(fullName) return separatedMeshNames - def addTexture(tex_dic, texture_type, texture): if texture is not None: tex_dic[texture_type] = texture - def getTextureFilename(texture): textureFile = None if texture and texture.image is not None: @@ -232,11 +219,10 @@ def getTextureFilename(texture): texturePath, textureFile = os.path.split(absFilePath) return textureFile - def makeXpsTexture(mesh, material): active_uv = mesh.data.uv_layers.active active_uv_index = mesh.data.uv_layers.active_index - xpsShaderWrapper = node_shader_utils.XPSShaderWrapper(material) + xpsShaderWrapper = XPSShaderWrapper(material, use_nodes=True) tex_dic = {} texture = getTextureFilename(xpsShaderWrapper.diffuse_texture) @@ -264,8 +250,9 @@ def makeXpsTexture(mesh, material): texutre_list = [] for tex_type in rgTextures: - texture = tex_dic.get(tex_type, 'missing.png') - texutre_list.append(texture) + texture = tex_dic.get(tex_type) + if texture: + texutre_list.append(texture) xpsTextures = [] for id, textute in enumerate(texutre_list): @@ -274,13 +261,11 @@ def makeXpsTexture(mesh, material): return xpsTextures - def getTextures(mesh, material): textures = [] xpsTextures = makeXpsTexture(mesh, material) return xpsTextures - def getXpsMatTextures(mesh): xpsMatTextures = [] for material_slot in mesh.material_slots: @@ -289,20 +274,18 @@ def getXpsMatTextures(mesh): xpsMatTextures.append(xpsTextures) return xpsMatTextures - def generateVertexKey(vertex, uvCoord, seamSideId): - # Generate a unique key for vertex using coords,normal, - # first UV and side of seam - key = '{}{}{}{}'.format(vertex.co, vertex.normal, uvCoord, seamSideId) + # Fix: Convert vectors to strings properly + co_str = str(tuple(vertex.co)) + normal_str = str(tuple(vertex.normal)) + uv_str = str(tuple(uvCoord[0])) if uvCoord else "no_uv" + key = '{}{}{}{}'.format(co_str, normal_str, uv_str, seamSideId) return key - def getXpsVertices(selectedArmature, mesh): - mapMatVertexKeys = [] # remap vertex index - xpsMatVertices = [] # Vertices separated by material - xpsMatFaces = [] # Faces separated by material - # xpsVertices = [] # list of vertices for a single material - # xpsFaces = [] # list of faces for a single material + mapMatVertexKeys = [] + xpsMatVertices = [] + xpsMatFaces = [] exportVertColors = xpsSettings.vColors armature = mesh.find_armature() @@ -311,29 +294,25 @@ def getXpsVertices(selectedArmature, mesh): verts_nor = xpsSettings.exportNormals - # Calculates tesselated faces and normal split to make them available for export - mesh.data.calc_normals_split() + mesh.data.calc_tangents() mesh.data.calc_loop_triangles() mesh.data.update(calc_edges=True) - mesh.data.calc_loop_triangles() matCount = len(mesh.data.materials) if (matCount > 0): for idx in range(matCount): - xpsMatVertices.append([]) # Vertices separated by material - xpsMatFaces.append([]) # Faces separated by material + xpsMatVertices.append([]) + xpsMatFaces.append([]) mapMatVertexKeys.append({}) else: - xpsMatVertices.append([]) # Vertices separated by material - xpsMatFaces.append([]) # Faces separated by material + xpsMatVertices.append([]) + xpsMatFaces.append([]) mapMatVertexKeys.append({}) meshVerts = mesh.data.vertices meshEdges = mesh.data.edges - # tessface accelerator hasSeams = any(edge.use_seam for edge in meshEdges) tessFaces = mesh.data.loop_triangles[:] - # tessFaces = mesh.data.tessfaces tessface_uv_tex = mesh.data.uv_layers tessface_vert_color = mesh.data.vertex_colors meshEdgeKeys = mesh.data.edge_keys @@ -343,20 +322,16 @@ def getXpsVertices(selectedArmature, mesh): preserveSeams = xpsSettings.preserveSeams if (preserveSeams and hasSeams): - # Count edges for faces tessEdgeCount = Counter(tessEdgeKey for tessFace in tessFaces for tessEdgeKey in tessFace.edge_keys) - # create dictionary. faces for each edge for tessface in tessFaces: for tessEdgeKey in tessface.edge_keys: if tessEdgeFaces.get(tessEdgeKey) is None: tessEdgeFaces[tessEdgeKey] = [] tessEdgeFaces[tessEdgeKey].append(tessface.index) - # use Dict to speedup search edgeKeyIndex = {val: index for index, val in enumerate(meshEdgeKeys)} - # create dictionary. Edges connected to each Vert for key in meshEdgeKeys: meshEdge = meshEdges[edgeKeyIndex[key]] vert1, vert2 = key @@ -367,7 +342,6 @@ def getXpsVertices(selectedArmature, mesh): faceSeams = [] for face in tessFaces: - # faceIdx = face.index material_index = face.material_index xpsVertices = xpsMatVertices[material_index] xpsFaces = xpsMatFaces[material_index] @@ -415,7 +389,7 @@ def getXpsVertices(selectedArmature, mesh): else: vCoord = coordTransform(objectMatrix @ vertex.co) if verts_nor: - normal = Vector(face.split_normals[vertEnum]) + normal = vertex.normal else: normal = vertex.normal norm = coordTransform(rotQuaternion @ normal) @@ -438,7 +412,6 @@ def getXpsVertices(selectedArmature, mesh): return xpsMatVertices, xpsMatFaces - def getUvs(tessface_uv_tex, uvIndex): uvs = [] for tessface_uv_layer in tessface_uv_tex: @@ -447,7 +420,6 @@ def getUvs(tessface_uv_tex, uvIndex): uvs.append(uvCoord) return uvs - def getVertexColor(exportVertColors, tessface_vert_color, vColorIndex): vColor = None if exportVertColors and tessface_vert_color: @@ -458,13 +430,11 @@ def getVertexColor(exportVertColors, tessface_vert_color, vColorIndex): vColor = list(map(rangeFloatToByte, vColor)) return vColor - def getBoneWeights(mesh, vertice, armature): boneId = [] boneWeight = [] if armature: for vertGroup in vertice.groups: - # Vertex Group groupIdx = vertGroup.group boneName = mesh.vertex_groups[groupIdx].name boneIdx = armature.data.bones.find(boneName) @@ -478,7 +448,6 @@ def getBoneWeights(mesh, vertice, armature): boneWeight = fillArray(boneWeight, 4, 0) return boneId, boneWeight - def getXpsFace(faceVerts): xpsFaces = [] @@ -491,27 +460,7 @@ def getXpsFace(faceVerts): return xpsFaces - def boneDictGenerate(filepath, armatureObj): boneNames = sorted([import_xnalara_pose.renameBoneToXps(name) for name in armatureObj.data.bones.keys()]) boneDictList = '\n'.join(';'.join((name,) * 2) for name in boneNames) - write_ascii_xps.writeBoneDict(filepath, boneDictList) - - -if __name__ == "__main__": - uvDisplX = 0 - uvDisplY = 0 - exportOnlySelected = True - exportPose = False - modProtected = False - filename1 = (r'G:\3DModeling\XNALara\XNALara_XPS\data\TESTING5\Drake\RECB ' - r'DRAKE Pack_By DamianHandy\DRAKE Sneaking Suit - Open_by ' - r'DamianHandy\Generic_Item - BLENDER pose.mesh') - - filename = r'C:\XPS Tutorial\Yaiba MOMIJIII\momi.mesh.ascii' - - xpsSettings = xps_types.XpsImportSettings(filename, uvDisplX, uvDisplY, - exportOnlySelected, exportPose, - modProtected) - - getOutputFilename(xpsSettings) + write_ascii_xps.writeBoneDict(filepath, boneDictList) \ No newline at end of file diff --git a/export_xnalara_pose.py b/export_xnalara_pose.py index a62cc52..bc8933d 100644 --- a/export_xnalara_pose.py +++ b/export_xnalara_pose.py @@ -5,6 +5,7 @@ from . import write_ascii_xps from . import xps_types from .timing import timing + import bpy from mathutils import Vector @@ -20,9 +21,8 @@ def getOutputPoseSequence(filename): for currFrame in range(startFrame, endFrame + 1): bpy.context.scene.frame_set(currFrame) - numSuffix = '{:0>3d}'.format(currFrame) + numSuffix = f'{currFrame:03d}' name = poseSuffix + numSuffix + ext - newPoseFilename = os.path.join(filepath, name) getOutputFilename(newPoseFilename) @@ -44,134 +44,125 @@ def blenderExportFinalize(): def saveXpsFile(filename, xpsPoseData): - # dirpath, file = os.path.split(filename) - # basename, ext = os.path.splitext(file) write_ascii_xps.writeXpsPose(filename, xpsPoseData) @timing def xpsExport(filename): - global rootDir - global xpsData - print("------------------------------------------------------------") - print("---------------EXECUTING XPS PYTHON EXPORTER----------------") + print("------------- EXECUTING XPS POSE EXPORTER ------------------") print("------------------------------------------------------------") - print("Exporting Pose: ", filename) + print("Exporting Pose:", filename) - rootDir, file = os.path.split(filename) - print('rootDir: {}'.format(rootDir)) + root_dir, _ = os.path.split(filename) + print(f'Root directory: {root_dir}') xpsPoseData = exportPose() - saveXpsFile(filename, xpsPoseData) def exportPose(): - armature = next((obj for obj in bpy.context.selected_objects - if obj.type == 'ARMATURE'), None) + armature = next( + (obj for obj in bpy.context.selected_objects if obj.type == 'ARMATURE'), + None + ) + if not armature: + armature = next( + (obj for obj in bpy.context.scene.objects if obj.type == 'ARMATURE'), + None + ) + + if not armature: + raise ValueError("No armature found. Please select or create an armature to export the pose.") + boneCount = len(armature.data.bones) - print('Exporting Pose', str(boneCount), 'bones') + print(f'Exporting pose for {boneCount} bones') return xpsPoseData(armature) -def xpsPoseData(armature): - context = bpy.context - currentMode = bpy.context.mode - currentObj = bpy.context.active_object - context.view_layer.objects.active = armature - bpy.ops.object.mode_set(mode='OBJECT', toggle=False) - - bpy.ops.object.mode_set(mode='POSE') - bpy.ops.pose.select_all(action='DESELECT') - bones = armature.pose.bones - objectMatrix = armature.matrix_world - - xpsPoseData = {} - for poseBone in bones: - boneName = poseBone.name - boneData = xpsPoseBone(poseBone, objectMatrix) - xpsPoseData[boneName] = boneData - - bpy.ops.object.posemode_toggle() - context.view_layer.objects.active = currentObj - bpy.ops.object.mode_set(mode=currentMode) - - return xpsPoseData - - -def xpsPoseBone(poseBone, objectMatrix): - boneName = poseBone.name - boneRotDelta = xpsBoneRotate(poseBone) - boneCoordDelta = xpsBoneTranslate(poseBone, objectMatrix) - boneScale = xpsBoneScale(poseBone) - boneData = xps_types.XpsBonePose(boneName, boneCoordDelta, boneRotDelta, - boneScale) - return boneData - - -def eulerToXpsBoneRot(rotEuler): - xDeg = degrees(rotEuler.x) - yDeg = degrees(rotEuler.y) - zDeg = degrees(rotEuler.z) - return Vector((xDeg, yDeg, zDeg)) +def xpsPoseData(armatureObj): + original_active = bpy.context.view_layer.objects.active + original_selected = bpy.context.selected_objects.copy() + + for obj in bpy.context.selected_objects: + obj.select_set(False) + + armatureObj.select_set(True) + bpy.context.view_layer.objects.active = armatureObj + + if bpy.context.mode != 'OBJECT': + bpy.ops.object.mode_set(mode='OBJECT') + + poseData = {} + for bone in armatureObj.pose.bones: + coord = xpsBoneTranslate(bone, armatureObj.matrix_world) + rot = xpsBoneRotate(bone) + scale = xpsBoneScale(bone) + + xpsBone = xps_types.XpsBonePose(bone.name, coord, rot, scale) + poseData[bone.name] = xpsBone + + for obj in bpy.context.selected_objects: + obj.select_set(False) + + for obj in original_selected: + obj.select_set(True) + + bpy.context.view_layer.objects.active = original_active + + return poseData + + +def xpsPoseBone(pose_bone, object_matrix): + name = pose_bone.name + coord = xpsBoneTranslate(pose_bone, object_matrix) + rot = xpsBoneRotate(pose_bone) + scale = xpsBoneScale(pose_bone) + + return xps_types.XpsBonePose(name, coord, rot, scale) + + +def eulerToXpsBoneRot(euler): + return Vector((degrees(euler.x), degrees(euler.y), degrees(euler.z))) def vectorTransform(vec): - x = vec.x - y = vec.y - z = vec.z - y = -y - newVec = Vector((x, z, y)) - return newVec + return Vector((vec.x, vec.z, -vec.y)) def vectorTransformTranslate(vec): - x = vec.x - y = vec.y - z = vec.z - y = -y - newVec = Vector((x, z, y)) - return newVec + return Vector((vec.x, vec.z, -vec.y)) def vectorTransformScale(vec): - x = vec.x - y = vec.y - z = vec.z - newVec = Vector((x, y, z)) - return newVec - - -def xpsBoneRotate(poseBone): - # LOCAL PoseBone - poseMatGlobal = poseBone.matrix_basis.to_quaternion() - # LOCAL EditBoneRot - editMatLocal = poseBone.bone.matrix_local.to_quaternion() - - rotQuat = editMatLocal @ poseMatGlobal @ editMatLocal.inverted() - rotEuler = rotQuat.to_euler('YXZ') - xpsRot = eulerToXpsBoneRot(rotEuler) - rot = vectorTransform(xpsRot) - return rot + return Vector((vec.x, vec.y, vec.z)) -def xpsBoneTranslate(poseBone, objectMatrix): - translate = poseBone.location - # LOCAL EditBoneRot - editMatLocal = poseBone.bone.matrix_local.to_quaternion() - vector = editMatLocal @ translate - return vectorTransformTranslate(objectMatrix.to_3x3() @ vector) +def xpsBoneRotate(pose_bone): + if pose_bone.rotation_mode == 'QUATERNION': + pose_quat = pose_bone.rotation_quaternion.copy() + else: + pose_quat = pose_bone.rotation_euler.to_quaternion() + + edit_quat = pose_bone.bone.matrix_local.to_quaternion() + delta_quat = edit_quat @ pose_quat @ edit_quat.inverted() + euler = delta_quat.to_euler('YXZ') + xps_rot = eulerToXpsBoneRot(euler) + return vectorTransform(xps_rot) -def xpsBoneScale(poseBone): - scale = poseBone.scale - return vectorTransformScale(scale) +def xpsBoneTranslate(pose_bone, object_matrix): + translate = pose_bone.location.copy() + edit_quat = pose_bone.bone.matrix_local.to_quaternion() + local_vec = edit_quat @ translate + + world_rot = object_matrix.to_3x3() + world_vec = world_rot @ local_vec + + return vectorTransformTranslate(world_vec) -if __name__ == "__main__": - writePosefilename0 = (r"G:\3DModeling\XNALara\XNALara_XPS\dataTest\Models" - r"\Queen's Blade\echidna pose - copy.pose") - getOutputFilename(writePosefilename0) +def xpsBoneScale(pose_bone): + return vectorTransformScale(pose_bone.scale) \ No newline at end of file diff --git a/import_obj.py b/import_obj.py index 5281206..899690b 100644 --- a/import_obj.py +++ b/import_obj.py @@ -440,18 +440,45 @@ def load_material_image(blender_material, context_material_name, img_data, type) mtl.close() -def hideBone(bone): - bone.layers[1] = True - bone.layers[0] = False - +if bpy.app.version < (4, 0): + def hideBone(bone): + bone.layers[1] = True + bone.layers[0] = False + + + def showBone(bone): + bone.layers[0] = True + bone.layers[1] = False + + + def visibleBone(bone): + return bone.layers[0] +else: + # Bone/Armature layers were removed in 4.0, replaced with Bone Collections. + # Because we cannot set both EditBone and Bone visibility without changing between Object/Pose and Edit modes, we + # control individual bone visibility by adding all bones to a hidden "Bones" collection and then adding/removing + # bones to/from a visible "Visible Bones" collection. + # This is not a good system now and I would recommend replacing it with simply hiding the bones. Though note that + # this would only hide the bone in the current mode. Hiding `Bone` hides only in Pose mode. Hiding `EditBone` hides + # only in Edit mode. + def _ensure_visibility_bones_collection(armature): + col = armature.collections.get("Visible Bones") + if col is None: + return armature.collections.new("Visible Bones") + else: + return col -def showBone(bone): - bone.layers[0] = True - bone.layers[1] = False + def hideBone(bone): + col = _ensure_visibility_bones_collection(bone.id_data) + col.unassign(bone) + def showBone(bone): + col = _ensure_visibility_bones_collection(bone.id_data) + col.assign(bone) -def visibleBone(bone): - return bone.layers[0] + def visibleBone(bone): + col = _ensure_visibility_bones_collection(bone.id_data) + return bone.name in col.bones def setMinimumLenght(bone): @@ -537,6 +564,18 @@ def create_armatures(filepath, relpath, bone.head = bone_heads[bone_id] bone.tail = bone.head # + mathutils.Vector((0,.01,0)) + if bpy.app.version >= (4, 0): + # Create collection to store all bones. + bones_collection = me.collections.new("Bones") + bones_collection.is_visible = False + # Create collection used to toggle bone visibility by adding/removing them from the collection. + visible_bones_collection = me.collections.new("Visible Bones") + + # Assign all bones to both Bone Collections. + for bone in me.edit_bones: + bones_collection.assign(bone) + visible_bones_collection.assign(bone) + # Set bone heirarchy for bone_id, bone_parent_id in enumerate(bone_parents): if bone_parent_id >= 0: diff --git a/import_xnalara_model.py b/import_xnalara_model.py index ed6fed3..567b193 100644 --- a/import_xnalara_model.py +++ b/import_xnalara_model.py @@ -3,159 +3,130 @@ import operator import os import re +from contextlib import ExitStack from . import import_xnalara_pose from . import read_ascii_xps from . import read_bin_xps from . import xps_types from . import material_creator -# from .timing import timing, profile from mathutils import Vector -# imported XPS directory rootDir = '' blenderBoneNames = [] MIN_BONE_LENGHT = 0.005 - +xpsData = None +xpsSettings = None def newBoneName(): global blenderBoneNames blenderBoneNames = [] - def addBoneName(newName): global blenderBoneNames blenderBoneNames.append(newName) - def getBoneName(originalIndex): if originalIndex < len(blenderBoneNames): return blenderBoneNames[originalIndex] else: return None - def coordTransform(coords): x, y, z = coords z = -z return (x, z, y) - def faceTransform(face): return [face[0], face[2], face[1]] - def faceTransformList(faces): - return map(faceTransform, faces) - + return list(map(faceTransform, faces)) def uvTransform(uv): u = uv[0] + xpsSettings.uvDisplX v = 1 + xpsSettings.uvDisplY - uv[1] return [u, v] - def rangeFloatToByte(float): return int(float * 255) % 256 - def rangeByteToFloat(byte): return byte / 255 - def uvTransformLayers(uvLayers): return list(map(uvTransform, uvLayers)) - -# profile def getInputFilename(xpsSettingsAux): - global xpsSettings + global xpsSettings, xpsData xpsSettings = xpsSettingsAux + xpsData = None blenderImportSetup() status = xpsImport() blenderImportFinalize() return status - def blenderImportSetup(): - # switch to object mode and deselect all objectMode() bpy.ops.object.select_all(action='DESELECT') - def blenderImportFinalize(): - # switch to object mode objectMode() - def objectMode(): current_mode = bpy.context.mode if bpy.context.view_layer.objects.active and current_mode != 'OBJECT': bpy.ops.object.mode_set(mode='OBJECT', toggle=False) - def loadXpsFile(filename): dirpath, file = os.path.split(filename) basename, ext = os.path.splitext(file) if ext.lower() in ('.mesh', '.xps'): xpsData = read_bin_xps.readXpsModel(filename) - elif ext.lower() in('.ascii'): + elif ext.lower() in ('.ascii'): xpsData = read_ascii_xps.readXpsModel(filename) else: xpsData = None - return xpsData - def makeMesh(meshFullName): mesh_da = bpy.data.meshes.new(meshFullName) mesh_ob = bpy.data.objects.new(mesh_da.name, mesh_da) - print('Created Mesh: {}'.format(meshFullName)) - print('New Mesh = {}'.format(mesh_da.name)) - # bpy.context.scene.update() - # mesh_da.update() + print('Create mesh: {}'.format(meshFullName)) + print('New mesh = {}'.format(mesh_da.name)) return mesh_ob - def linkToCollection(collection, obj): - # Link Object to collection collection.objects.link(obj) - def xpsImport(): - global rootDir - global xpsData + global rootDir, xpsData print("------------------------------------------------------------") - print("---------------EXECUTING XPS PYTHON IMPORTER----------------") + print("---------------Executing XPS Python Importer----------------") print("------------------------------------------------------------") - print("Importing file: ", xpsSettings.filename) + print("Import file: ", xpsSettings.filename) rootDir, file = os.path.split(xpsSettings.filename) - print('rootDir: {}'.format(rootDir)) + print('Root directory: {}'.format(rootDir)) xpsData = loadXpsFile(xpsSettings.filename) if not xpsData: return '{NONE}' - # Create New Collection fname, fext = os.path.splitext(file) new_collection = bpy.data.collections.new(fname) view_layer = bpy.context.view_layer active_collection = view_layer.active_layer_collection.collection active_collection.children.link(new_collection) - # imports the armature armature_ob = createArmature() if armature_ob: linkToCollection(new_collection, armature_ob) - importBones(armature_ob) - markSelected(armature_ob) + importBones(armature_ob) # 骨骼导入和编辑都在这里完成 - # imports all the meshes meshes_obs = importMeshesList(armature_ob) - # link object to Collection for obj in meshes_obs: linkToCollection(new_collection, obj) markSelected(obj) @@ -163,16 +134,13 @@ def xpsImport(): if armature_ob: armature_ob.pose.use_auto_ik = xpsSettings.autoIk hideUnusedBones([armature_ob]) - # set tail to Children Middle Point - boneTailMiddleObject(armature_ob, xpsSettings.connectBones) + # boneTailMiddleObject 已经被合并到 importBones 中,这里不再需要调用 + # boneTailMiddleObject(armature_ob, xpsSettings.connectBones) - # Import default pose - if(xpsSettings.importDefaultPose and armature_ob): - if(xpsData.header and xpsData.header.pose): - import_xnalara_pose.setXpsPose(armature_ob, xpsData.header.pose) + if xpsSettings.importDefaultPose and armature_ob and xpsData.header and xpsData.header.pose: + import_xnalara_pose.setXpsPose(armature_ob, xpsData.header.pose) return '{FINISHED}' - def setMinimumLenght(bone): default_length = MIN_BONE_LENGHT if bone.length == 0: @@ -180,60 +148,42 @@ def setMinimumLenght(bone): if bone.length < default_length: bone.length = default_length - -def boneTailMiddleObject(armature_ob, connectBones): - bpy.context.view_layer.objects.active = armature_ob - - bpy.ops.object.mode_set(mode='EDIT', toggle=False) - editBones = armature_ob.data.edit_bones - boneTailMiddle(editBones, connectBones) - bpy.ops.object.mode_set(mode='OBJECT', toggle=False) - +# *** 移除 boneTailMiddleObject,其逻辑被合并到 importBones *** def setBoneConnect(connectBones): currMode = bpy.context.mode - bpy.ops.object.mode_set(mode='EDIT', toggle=False) - editBones = bpy.context.view_layer.objects.active.data.edit_bones - connectEditBones(editBones, connectBones) - bpy.ops.object.mode_set(mode=currMode, toggle=False) - + + # 优化:使用 try...finally 确保模式切换正确 + try: + bpy.ops.object.mode_set(mode='EDIT', toggle=False) + editBones = bpy.context.view_layer.objects.active.data.edit_bones + connectEditBones(editBones, connectBones) + finally: + bpy.ops.object.mode_set(mode=currMode, toggle=False) def connectEditBones(editBones, connectBones): for bone in editBones: - if bone.parent: - if bone.head == bone.parent.tail: - bone.use_connect = connectBones - + if bone.parent and bone.head == bone.parent.tail: + bone.use_connect = connectBones def hideBonesByName(armature_objs): - """Hide bones that do not affect any mesh.""" for armature in armature_objs: for bone in armature.data.bones: if bone.name.lower().startswith('unused'): hideBone(bone) - def hideBonesByVertexGroup(armature_objs): - """Hide bones that do not affect any mesh.""" for armature in armature_objs: objs = [obj for obj in armature.children - if obj.type == 'MESH' and obj.modifiers and [ - modif for modif in obj.modifiers if modif - and modif.type == 'ARMATURE' and modif.object == armature]] - - # cycle objects and get all vertex groups - vertexgroups = set( - [vg.name for obj in objs if obj.type == 'MESH' - for vg in obj.vertex_groups]) - + if obj.type == 'MESH' and obj.modifiers and any( + modif for modif in obj.modifiers if modif and modif.type == 'ARMATURE' and modif.object == armature)] + vertexgroups = set(vg.name for obj in objs if obj.type == 'MESH' for vg in obj.vertex_groups) bones = armature.data.bones - # leafBones = [bone for bone in bones if not bone.children] rootBones = [bone for bone in bones if not bone.parent] for bone in rootBones: recurBones(bone, vertexgroups, '') - def recurBones(bone, vertexgroups, name): visibleChild = False for childBone in bone.children: @@ -245,57 +195,53 @@ def recurBones(bone, vertexgroups, name): hideBone(bone) return visibleChain +def _ensure_visibility_bones_collection(armature): + col = armature.collections.get("Visible Bones") + if col is None: + return armature.collections.new("Visible Bones") + return col def hideBone(bone): - bone.layers[1] = True - bone.layers[0] = False - + col = _ensure_visibility_bones_collection(bone.id_data) + col.unassign(bone) def showBone(bone): - bone.layers[0] = True - bone.layers[1] = False - + col = _ensure_visibility_bones_collection(bone.id_data) + col.assign(bone) def visibleBone(bone): - return bone.layers[0] - + col = _ensure_visibility_bones_collection(bone.id_data) + return bone.name in col.bones def showAllBones(armature_objs): - """Move all bones to layer 0.""" for armature in armature_objs: for bone in armature.data.bones: showBone(bone) - def hideBoneChain(bone): hideBone(bone) parentBone = bone.parent if parentBone: hideBoneChain(parentBone) - def showBoneChain(bone): showBone(bone) parentBone = bone.parent if parentBone: showBoneChain(parentBone) - def hideUnusedBones(armature_objs): hideBonesByVertexGroup(armature_objs) hideBonesByName(armature_objs) - def boneDictRename(filepath, armatureObj): boneDictDataRename, boneDictDataRestore = read_ascii_xps.readBoneDict(filepath) renameBonesUsingDict(armatureObj, boneDictDataRename) - def boneDictRestore(filepath, armatureObj): boneDictDataRename, boneDictDataRestore = read_ascii_xps.readBoneDict(filepath) renameBonesUsingDict(armatureObj, boneDictDataRestore) - def renameBonesUsingDict(armatureObj, boneDict): getbone = armatureObj.data.bones.get for key, value in boneDict.items(): @@ -307,99 +253,100 @@ def renameBonesUsingDict(armatureObj, boneDict): if boneOriginal: boneOriginal.name = value - def createArmature(): bones = xpsData.bones armature_ob = None if bones: boneCount = len(bones) - print('Importing Armature', str(boneCount), 'bones') + print('Import armature', str(boneCount), 'bones') armature_da = bpy.data.armatures.new("Armature") armature_da.display_type = 'STICK' armature_ob = bpy.data.objects.new("Armature", armature_da) armature_ob.show_in_front = True - # armature_ob.pose.use_auto_ik = autoIk return armature_ob - def importBones(armature_ob): bones = xpsData.bones + # 优化:使用 try...finally 确保只切换模式一次,并在最后恢复 bpy.context.view_layer.objects.active = armature_ob - bpy.ops.object.mode_set(mode='EDIT') - - newBoneName() - # create all Bones - for bone in bones: - editBone = armature_ob.data.edit_bones.new(bone.name) - # Bone index change after parenting. This keeps original index - addBoneName(editBone.name) - - transformedBone = coordTransform(bone.co) - editBone.head = Vector(transformedBone) - editBone.tail = Vector(editBone.head) + Vector((0, 0, -.1)) - setMinimumLenght(editBone) - - # set all bone parents - for bone in bones: - if (bone.parentId >= 0): - editBone = armature_ob.data.edit_bones[bone.id] - editBone.parent = armature_ob.data.edit_bones[bone.parentId] + try: + bpy.ops.object.mode_set(mode='EDIT') + + editBones = armature_ob.data.edit_bones + newBoneName() + for bone in bones: + editBone = editBones.new(bone.name) + addBoneName(editBone.name) + + transformedBone = coordTransform(bone.co) + editBone.head = Vector(transformedBone) + editBone.tail = Vector(editBone.head) + Vector((0, 0, -.1)) + setMinimumLenght(editBone) + + bones_collection = armature_ob.data.collections.new("Bones") + bones_collection.is_visible = False + visible_bones_collection = armature_ob.data.collections.new("Visible Bones") + + for bone in editBones: + bones_collection.assign(bone) + visible_bones_collection.assign(bone) + + for bone in bones: + if bone.parentId >= 0: + editBone = editBones[bone.id] + editBone.parent = editBones[bone.parentId] + + # 将骨尾计算逻辑合并到这里,避免额外的模式切换 + boneTailMiddle(editBones, xpsSettings.connectBones) + + finally: + bpy.ops.object.mode_set(mode='OBJECT') + markSelected(armature_ob) - bpy.ops.object.mode_set(mode='OBJECT') return armature_ob - def boneTailMiddle(editBones, connectBones): - """Move bone tail to children middle point.""" + # 此函数现在在 EDIT 模式下调用,不需要切换模式 twistboneRegex = r'\b(hip)?(twist|ctr|root|adj)\d*\b' for bone in editBones: - if (bone.name.lower() == "root ground" or not bone.parent): + if bone.name.lower() == "root ground" or not bone.parent: bone.tail = bone.head.xyz + Vector((0, -.5, 0)) - # elif (bone.name.lower() == "root hips"): - # bone.tail = bone.head.xyz + Vector((0, .2, 0)) else: if visibleBone(bone): childBones = [childBone for childBone in bone.children - if visibleBone(childBone) and not (re.search(twistboneRegex, childBone.name))] + if visibleBone(childBone) and not re.search(twistboneRegex, childBone.name)] else: - childBones = [childBone for childBone in bone.children if not (re.search(twistboneRegex, childBone.name))] + childBones = [childBone for childBone in bone.children if not re.search(twistboneRegex, childBone.name)] if childBones: - # Set tail to children middle - bone.tail = Vector(map(sum, zip(*(childBone.head.xyz for childBone in childBones)))) / len(childBones) + # 优化:使用Vector的求和能力计算平均值 + child_heads = [childBone.head for childBone in childBones] + avg_vector = sum(child_heads, Vector((0.0, 0.0, 0.0))) / len(child_heads) + bone.tail = avg_vector else: - # if no child, set tail acording to parent if bone.parent is not None: - if bone.head.xyz != bone.parent.tail.xyz: - # Tail to diference between bone and parent - delta = bone.head.xyz - bone.parent.tail.xyz + if bone.head != bone.parent.tail: + delta = bone.head - bone.parent.tail else: - # Tail to same lenght/direction than parent - delta = bone.parent.tail.xyz - bone.parent.head.xyz - bone.tail = bone.head.xyz + delta + delta = bone.parent.tail - bone.parent.head + bone.tail = bone.head + delta - # Set minimum bone length for bone in editBones: setMinimumLenght(bone) - # Connect Bones to parent connectEditBones(editBones, connectBones) - def markSelected(ob): ob.select_set(state=True) - def makeUvs(mesh_da, faces, uvData, vertColors): - # Create UVLayers for i in range(len(uvData[0])): mesh_da.uv_layers.new(name="UV{}".format(str(i + 1))) if xpsSettings.vColors: mesh_da.vertex_colors.new() - # Assign UVCoords for faceId, face in enumerate(faces): for vertId, faceVert in enumerate(face): loopdId = (faceId * 3) + vertId @@ -409,74 +356,54 @@ def makeUvs(mesh_da, faces, uvData, vertColors): uvCoor = uvData[faceVert][layerIdx] uvLayer.data[loopdId].uv = Vector(uvCoor) - def createJoinedMeshes(): meshPartRegex = re.compile(r'(!.*)*([\d]+nPart)*!') sortedMeshesList = sorted(xpsData.meshes, key=operator.attrgetter('name')) - joinedMeshesNames = list( - {meshPartRegex.sub('', mesh.name, 0) for mesh in sortedMeshesList}) + joinedMeshesNames = list({meshPartRegex.sub('', mesh.name, 0) for mesh in sortedMeshesList}) joinedMeshesNames.sort() newMeshes = [] for joinedMeshName in joinedMeshesNames: - # for each joinedMeshName generate a list of meshes to join - meshesToJoin = [mesh for mesh in sortedMeshesList if meshPartRegex.sub( - '', mesh.name, 0) == joinedMeshName] + meshesToJoin = [mesh for mesh in sortedMeshesList if meshPartRegex.sub('', mesh.name, 0) == joinedMeshName] totalVertexCount = 0 vertexCount = 0 meshCount = 0 - meshName = None - textures = None - vertex = None - faces = None - - # new name for the unified mesh meshName = meshPartRegex.sub('', meshesToJoin[0].name, 0) - # all the meshses share the same textures textures = meshesToJoin[0].textures - # all the meshses share the uv layers count uvCount = meshesToJoin[0].uvCount - # all the new joined mesh names vertex = [] faces = [] for mesh in meshesToJoin: vertexCount = 0 - meshCount = meshCount + 1 + meshCount += 1 if len(meshesToJoin) > 1 or meshesToJoin[0] not in sortedMeshesList: - # unify vertex for vert in mesh.vertices: - vertexCount = vertexCount + 1 + vertexCount += 1 newVertice = xps_types.XpsVertex( vert.id + totalVertexCount, vert.co, vert.norm, vert.vColor, vert.uv, vert.boneWeights) vertex.append(newVertice) - # unify faces for face in mesh.faces: - newFace = [face[0] + totalVertexCount, face[1] - + totalVertexCount, face[2] + totalVertexCount] + newFace = [face[0] + totalVertexCount, face[1] + totalVertexCount, face[2] + totalVertexCount] faces.append(newFace) else: vertex = mesh.vertices faces = mesh.faces - totalVertexCount = totalVertexCount + vertexCount + totalVertexCount += vertexCount - # Creates the nuw unified mesh xpsMesh = xps_types.XpsMesh(meshName, textures, vertex, faces, uvCount) newMeshes.append(xpsMesh) return newMeshes - def importMeshesList(armature_ob): if xpsSettings.joinMeshParts: newMeshes = createJoinedMeshes() else: newMeshes = xpsData.meshes - importedMeshes = [importMesh(armature_ob, meshInfo) - for meshInfo in newMeshes] + importedMeshes = [importMesh(armature_ob, meshInfo) for meshInfo in newMeshes] return [mesh for mesh in importedMeshes if mesh] - def generateVertexKey(vertex): if xpsSettings.joinMeshRips: key = str(vertex.co) + str(vertex.norm) @@ -484,7 +411,6 @@ def generateVertexKey(vertex): key = str(vertex.id) + str(vertex.co) + str(vertex.norm) return key - def getVertexId(vertex, mapVertexKeys, mergedVertList): vertexKey = generateVertexKey(vertex) vertexID = mapVertexKeys.get(vertexKey) @@ -498,7 +424,6 @@ def getVertexId(vertex, mapVertexKeys, mergedVertList): mergedVertList[vertexID].merged = True return vertexID - def makeVertexDict(vertexDict, mergedVertList, uvLayers, vertColor, vertices): mapVertexKeys = {} uvLayerAppend = uvLayers.append @@ -507,28 +432,23 @@ def makeVertexDict(vertexDict, mergedVertList, uvLayers, vertColor, vertices): for vertex in vertices: vColor = vertex.vColor - uvLayerAppend(list(map(uvTransform, vertex.uv))) + # 优化:使用列表推导式代替 list(map) + uvLayerAppend([uvTransform(uv_item) for uv_item in vertex.uv]) vertColorAppend(list(map(rangeByteToFloat, vColor))) vertexID = getVertexId(vertex, mapVertexKeys, mergedVertList) - # old ID to new ID vertexDictAppend(vertexID) - def importMesh(armature_ob, meshInfo): - # boneCount = len(xpsData.bones) useSeams = xpsSettings.markSeams - # Create Mesh meshFullName = meshInfo.name print() - print('---*** Importing Mesh {} ***---'.format(meshFullName)) + print('---*** Import mesh {} ***---'.format(meshFullName)) - # Load UV Layers Count uvLayerCount = meshInfo.uvCount - print('UV Layer Count: {}'.format(str(uvLayerCount))) + print('UV layers: {}'.format(str(uvLayerCount))) - # Load Textures Count textureCount = len(meshInfo.textures) - print('Texture Count: {}'.format(str(textureCount))) + print('Texture count: {}'.format(str(textureCount))) mesh_ob = None vertCount = len(meshInfo.vertices) @@ -539,8 +459,7 @@ def importMesh(armature_ob, meshInfo): vertColors = [] makeVertexDict(vertexDict, mergedVertList, uvLayers, vertColors, meshInfo.vertices) - # new ID to riginal ID - vertexOrig = [[] for x in range(len(mergedVertList))] + vertexOrig = [[] for _ in range(len(mergedVertList))] for vertId, vert in enumerate(vertexDict): vertexOrig[vert].append(vertId) @@ -548,23 +467,16 @@ def importMesh(armature_ob, meshInfo): seamEdgesDict = {} facesData = [] for face in meshInfo.faces: - v1Old = face[0] - v2Old = face[1] - v3Old = face[2] + v1Old, v2Old, v3Old = face v1New = vertexDict[v1Old] v2New = vertexDict[v2Old] v3New = vertexDict[v3Old] - oldFace = ((v1Old, v2Old, v3Old)) + oldFace = (v1Old, v2Old, v3Old) facesData.append((v1New, v2New, v3New)) - if (useSeams): - if (mergedVertList[v1New].merged - or mergedVertList[v2New].merged - or mergedVertList[v3New].merged): - - findMergedEdges(seamEdgesDict, vertexDict, mergedVertList, mergedVertices, oldFace) + if useSeams and (mergedVertList[v1New].merged or mergedVertList[v2New].merged or mergedVertList[v3New].merged): + findMergedEdges(seamEdgesDict, vertexDict, mergedVertList, mergedVertices, oldFace) - # merge Vertices of same coord and normal? mergeByNormal = True if mergeByNormal: vertices = mergedVertList @@ -573,42 +485,31 @@ def importMesh(armature_ob, meshInfo): vertices = meshInfo.vertices facesList = meshInfo.faces - # Create Mesh mesh_ob = makeMesh(meshFullName) mesh_da = mesh_ob.data coords = [] normals = [] - # vrtxList = [] - # nbVrtx = [] - for vertex in vertices: unitnormal = Vector(vertex.norm).normalized() coords.append(coordTransform(vertex.co)) normals.append(coordTransform(unitnormal)) - # vertColors.append(vertex.vColor) - # uvLayers.append(uvTransformLayers(vertex.uv)) - # Create Faces faces = list(faceTransformList(facesList)) mesh_da.from_pydata(coords, [], faces) - mesh_da.polygons.foreach_set( - "use_smooth", [True] * len(mesh_da.polygons)) + mesh_da.polygons.foreach_set("use_smooth", [True] * len(mesh_da.polygons)) - # speedup!!!! if xpsSettings.markSeams: markSeams(mesh_da, seamEdgesDict) - # Make UVLayers origFaces = faceTransformList(meshInfo.faces) makeUvs(mesh_da, origFaces, uvLayers, vertColors) - if (xpsData.header): + if xpsData.header: flags = xpsData.header.flags else: flags = read_bin_xps.flagsDefault() - # Make Material material_creator.makeMaterial(xpsSettings, rootDir, mesh_da, meshInfo, flags) if armature_ob: @@ -617,126 +518,90 @@ def importMesh(armature_ob, meshInfo): makeVertexGroups(mesh_ob, vertices) - # makeBoneGroups if armature_ob: makeBoneGroups(armature_ob, mesh_ob) - # import custom normals verts_nor = xpsSettings.importNormals use_edges = True - # unique_smooth_groups = True if verts_nor: - mesh_da.create_normals_split() - meshCorrected = mesh_da.validate(clean_customdata=False) # *Very* important to not remove nors! + meshCorrected = mesh_da.validate(clean_customdata=False) mesh_da.update(calc_edges=use_edges) mesh_da.normals_split_custom_set_from_vertices(normals) - mesh_da.use_auto_smooth = True else: meshCorrected = mesh_da.validate() - print("Geometry Corrected:", meshCorrected) + print("Geometry corrected:", meshCorrected) return mesh_ob - def markSeams(mesh_da, seamEdgesDict): - # use Dict to speedup search edge_keys = {val: index for index, val in enumerate(mesh_da.edge_keys)} - # mesh_da.show_edge_seams = True - for vert1, list in seamEdgesDict.items(): - for vert2 in list: - edgeIdx = None - if vert1 < vert2: - edgeIdx = edge_keys[(vert1, vert2)] - elif vert2 < vert1: - edgeIdx = edge_keys[(vert2, vert1)] - if edgeIdx: + for vert1, vert_list in seamEdgesDict.items(): + for vert2 in vert_list: + edgeIdx = edge_keys.get((vert1, vert2)) if vert1 < vert2 else edge_keys.get((vert2, vert1)) + if edgeIdx is not None: mesh_da.edges[edgeIdx].use_seam = True - def findMergedEdges(seamEdgesDict, vertexDict, mergedVertList, mergedVertices, oldFace): - findMergedVert(seamEdgesDict, vertexDict, mergedVertList, mergedVertices, oldFace, oldFace[0]) - findMergedVert(seamEdgesDict, vertexDict, mergedVertList, mergedVertices, oldFace, oldFace[1]) - findMergedVert(seamEdgesDict, vertexDict, mergedVertList, mergedVertices, oldFace, oldFace[2]) - + for mergedVert in oldFace: + findMergedVert(seamEdgesDict, vertexDict, mergedVertList, mergedVertices, oldFace, mergedVert) def findMergedVert(seamEdgesDict, vertexDict, mergedVertList, mergedVertices, oldFace, mergedVert): - v1Old = oldFace[0] - v2Old = oldFace[1] - v3Old = oldFace[2] - # v1New = vertexDict[v1Old] - # v2New = vertexDict[v2Old] - # v3New = vertexDict[v3Old] + v1Old, v2Old, v3Old = oldFace vertX = vertexDict[mergedVert] - if (mergedVertList[vertX].merged): - # List Merged vertices original Create - if (mergedVertices.get(vertX) is None): + if mergedVertList[vertX].merged: + if mergedVertices.get(vertX) is None: mergedVertices[vertX] = [] - # List Merged vertices original Loop for facesList in mergedVertices[vertX]: - # Check if original vertices merge - i = 0 matchV1 = False while not matchV1 and i < 3: - if ((vertX == vertexDict[facesList[i]]) and mergedVert != facesList[i]): - if (mergedVert != v1Old): + if vertX == vertexDict[facesList[i]] and mergedVert != facesList[i]: + if mergedVert != v1Old: checkEdgePairForSeam(i, seamEdgesDict, vertexDict, vertX, v1Old, facesList) - if (mergedVert != v2Old): + if mergedVert != v2Old: checkEdgePairForSeam(i, seamEdgesDict, vertexDict, vertX, v2Old, facesList) - if (mergedVert != v3Old): + if mergedVert != v3Old: checkEdgePairForSeam(i, seamEdgesDict, vertexDict, vertX, v3Old, facesList) matchV1 = True - i = i + 1 + i += 1 - # List Merged vertices original Append mergedVertices[vertX].append((v1Old, v2Old, v3Old)) - def checkEdgePairForSeam(i, seamEdgesDict, vertexDict, mergedVert, vert, facesList): - if (i != 0): + if i != 0: makeSeamEdgeDict(0, seamEdgesDict, vertexDict, mergedVert, vert, facesList) - if (i != 1): + if i != 1: makeSeamEdgeDict(1, seamEdgesDict, vertexDict, mergedVert, vert, facesList) - if (i != 2): + if i != 2: makeSeamEdgeDict(2, seamEdgesDict, vertexDict, mergedVert, vert, facesList) - def makeSeamEdgeDict(i, seamEdgesDict, vertexDict, mergedVert, vert, facesList): - if (vertexDict[vert] == vertexDict[facesList[i]]): - if (seamEdgesDict.get(mergedVert) is None): + if vertexDict[vert] == vertexDict[facesList[i]]: + if seamEdgesDict.get(mergedVert) is None: seamEdgesDict[mergedVert] = [] seamEdgesDict[mergedVert].append(vertexDict[vert]) - def setArmatureModifier(armature_ob, mesh_ob): mod = mesh_ob.modifiers.new(type="ARMATURE", name="Armature") mod.use_vertex_groups = True mod.object = armature_ob - def setParent(armature_ob, mesh_ob): mesh_ob.parent = armature_ob - def makeVertexGroups(mesh_ob, vertices): - """Make vertex groups and assign weights.""" - # blender limits vertexGroupNames to 63 chars - # armatures = [mesh_ob.find_armature()] armatures = mesh_ob.find_armature() for vertex in vertices: assignVertexGroup(vertex, armatures, mesh_ob) - def assignVertexGroup(vert, armature, mesh_ob): - for i in range(len(vert.boneWeights)): - vertBoneWeight = vert.boneWeights[i] + for vertBoneWeight in vert.boneWeights: boneIdx = vertBoneWeight.id vertexWeight = vertBoneWeight.weight if vertexWeight != 0: - # use original index to get current bone name in blender boneName = getBoneName(boneIdx) if boneName: vertGroup = mesh_ob.vertex_groups.get(boneName) @@ -744,48 +609,24 @@ def assignVertexGroup(vert, armature, mesh_ob): vertGroup = mesh_ob.vertex_groups.new(name=boneName) vertGroup.add([vert.id], vertexWeight, 'REPLACE') - def makeBoneGroups(armature_ob, mesh_ob): - # Use current theme for selecte and active bone colors - # current_theme = C.user_preferences.themes.items()[0][0] - # theme = C.user_preferences.themes[current_theme] - - # random bone surface color by mesh color1 = material_creator.randomColor() color2 = material_creator.randomColor() color3 = material_creator.randomColor() - bone_pose_surface_color = (color1) - bone_pose_color = (color2) - bone_pose_active_color = (color3) - - boneGroup = armature_ob.pose.bone_groups.new(name=mesh_ob.name) - - boneGroup.color_set = 'CUSTOM' - boneGroup.colors.normal = bone_pose_surface_color - boneGroup.colors.select = bone_pose_color - boneGroup.colors.active = bone_pose_active_color + bone_pose_surface_color = color1 + bone_pose_color = color2 + bone_pose_active_color = color3 + bone_collection = armature_ob.data.collections.new(name=mesh_ob.name) + bone_collection.is_visible = False vertexGroups = mesh_ob.vertex_groups.keys() poseBones = armature_ob.pose.bones for boneName in vertexGroups: - poseBones[boneName].bone_group = boneGroup - - -if __name__ == "__main__": - - readfilename = r'C:\XPS Tutorial\Yaiba MOMIJIII\momi3.mesh.mesh' - uvDisplX = 0 - uvDisplY = 0 - impDefPose = True - joinMeshRips = True - joinMeshParts = True - vColors = True - connectBones = True - autoIk = True - importNormals = True - - xpsSettings = xps_types.XpsImportSettings( - readfilename, uvDisplX, uvDisplY, impDefPose, joinMeshRips, - markSeams, vColors, - joinMeshParts, connectBones, autoIk, importNormals) - getInputFilename(xpsSettings) + pose_bone = poseBones[boneName] + bone_collection.assign(pose_bone) + color = pose_bone.color + color.palette = 'CUSTOM' + custom_colors = color.custom + custom_colors.normal = bone_pose_surface_color + custom_colors.select = bone_pose_color + custom_colors.active = bone_pose_active_color \ No newline at end of file diff --git a/import_xnalara_pose.py b/import_xnalara_pose.py index 96cb52e..158aa27 100644 --- a/import_xnalara_pose.py +++ b/import_xnalara_pose.py @@ -5,8 +5,7 @@ from . import read_ascii_xps from .timing import timing import bpy -from mathutils import Euler, Matrix, Vector - +from mathutils import Euler, Matrix, Vector, Quaternion PLACE_HOLDER = r'*side*' RIGHT_BLENDER_SUFFIX = r'.R' @@ -14,243 +13,205 @@ RIGHT_XPS_SUFFIX = r'right' LEFT_XPS_SUFFIX = r'left' +xpsData = None +rootDir = '' def changeBoneNameToBlender(boneName, xpsSuffix, blenderSuffix): - ''' ''' - # replace suffix with place holder newName = re.sub(xpsSuffix, PLACE_HOLDER, boneName, flags=re.I) - # remove doble spaces - newName = re.sub(r'\s+', ' ', newName, flags=re.I) - newName = str.strip(newName) - if boneName != newName: - newName = '{0}{1}'.format(newName, blenderSuffix) - + newName = re.sub(r'\s+', ' ', newName) + newName = newName.strip() + if boneName.lower() != newName.lower(): + newName = f"{newName}{blenderSuffix}" return newName.strip() - def renameBoneToBlender(oldName): - newName = oldName if PLACE_HOLDER not in oldName.lower(): if re.search(LEFT_XPS_SUFFIX, oldName, flags=re.I): - newName = changeBoneNameToBlender(oldName, LEFT_XPS_SUFFIX, LEFT_BLENDER_SUFFIX) - + return changeBoneNameToBlender(oldName, LEFT_XPS_SUFFIX, LEFT_BLENDER_SUFFIX) if re.search(RIGHT_XPS_SUFFIX, oldName, flags=re.I): - newName = changeBoneNameToBlender(oldName, RIGHT_XPS_SUFFIX, RIGHT_BLENDER_SUFFIX) - - return newName - + return changeBoneNameToBlender(oldName, RIGHT_XPS_SUFFIX, RIGHT_BLENDER_SUFFIX) + return oldName def renameBonesToBlender(armatures_obs): - # currActive = bpy.context.active_object for armature in armatures_obs: for bone in armature.data.bones: bone.name = renameBoneToBlender(bone.name) - def changeBoneNameToXps(oldName, blenderSuffix, xpsSuffix): - # remove '.R' '.L' from the end of the name - newName = re.sub('{0}{1}'.format(re.escape(blenderSuffix), '$'), '', oldName, flags=re.I) - # remove doble spaces - newName = re.sub(r'\s+', ' ', newName, flags=re.I) - # replcace place holder + newName = re.sub(f"{re.escape(blenderSuffix)}$", '', oldName, flags=re.I) + newName = re.sub(r'\s+', ' ', newName) newName = re.sub(re.escape(PLACE_HOLDER), xpsSuffix, newName, flags=re.I) - return newName - + return newName.strip() def renameBoneToXps(oldName): - newName = oldName if PLACE_HOLDER in oldName.lower(): if re.search(re.escape(LEFT_BLENDER_SUFFIX), oldName, re.I): - newName = changeBoneNameToXps(oldName, LEFT_BLENDER_SUFFIX, LEFT_XPS_SUFFIX) - + return changeBoneNameToXps(oldName, LEFT_BLENDER_SUFFIX, LEFT_XPS_SUFFIX) if re.search(re.escape(RIGHT_BLENDER_SUFFIX), oldName, re.I): - newName = changeBoneNameToXps(oldName, RIGHT_BLENDER_SUFFIX, RIGHT_XPS_SUFFIX) - - return newName.strip() - + return changeBoneNameToXps(oldName, RIGHT_BLENDER_SUFFIX, RIGHT_XPS_SUFFIX) + return oldName.strip() def renameBonesToXps(armatures_obs): for armature in armatures_obs: for bone in armature.data.bones: bone.name = renameBoneToXps(bone.name) - def getInputPoseSequence(filename): filepath, file = os.path.split(filename) basename, ext = os.path.splitext(file) poseSuffix = re.sub(r'\d+$', '', basename) files = [] - for f in [file for file in os.listdir(filepath) if os.path.splitext(file)[1] == '.pose']: - fName, fExt = os.path.splitext(f) - fPoseSuffix = re.sub(r'\d+$', '', fName) - if poseSuffix == fPoseSuffix: - files.append(f) + for f in os.listdir(filepath): + if f.lower().endswith('.pose'): + name_part = re.sub(r'\d+$', '', os.path.splitext(f)[0]) + if name_part == poseSuffix: + files.append(f) files.sort() + current_frame = bpy.context.scene.frame_current - initialFrame = bpy.context.scene.frame_current for poseFile in files: - frame = bpy.context.scene.frame_current - poseFilename = os.path.join(filepath, poseFile) - importPoseAsKeyframe(poseFilename) - bpy.context.scene.frame_current = frame + 1 - - bpy.context.scene.frame_current = initialFrame + posePath = os.path.join(filepath, poseFile) + importPoseAsKeyframe(posePath) + bpy.context.scene.frame_current += 1 + bpy.context.scene.frame_current = current_frame def importPoseAsKeyframe(filename): getInputFilename(filename) - def getInputFilename(filename): - blenderImportSetup() xpsImport(filename) blenderImportFinalize() - def blenderImportSetup(): pass - def blenderImportFinalize(): pass - def loadXpsFile(filename): - # dirpath, file = os.path.split(filename) - # basename, ext = os.path.splitext(file) - xpsData = read_ascii_xps.readXpsPose(filename) - - return xpsData - + return read_ascii_xps.readXpsPose(filename) @timing def xpsImport(filename): - global rootDir - global xpsData + global rootDir, xpsData print("------------------------------------------------------------") - print("---------------EXECUTING XPS PYTHON IMPORTER----------------") + print("----------- EXECUTING XPS POSE IMPORTER -------------------") print("------------------------------------------------------------") - print("Importing Pose: ", filename) + print("Importing pose:", filename) - rootDir, file = os.path.split(filename) - print('rootDir: {}'.format(rootDir)) + rootDir, _ = os.path.split(filename) + print("Root directory:", rootDir) xpsData = loadXpsFile(filename) - importPose() - def importPose(): boneCount = len(xpsData) - print('Importing Pose', str(boneCount), 'bones') + print(f"Importing pose with {boneCount} bones") armature = bpy.context.active_object - setXpsPose(armature, xpsData) - + if armature and armature.type == 'ARMATURE': + setXpsPose(armature, xpsData) def resetPose(armature): - for poseBone in armature.pose.bones: - poseBone.matrix_basis = Matrix() - + for pb in armature.pose.bones: + pb.matrix_basis = Matrix() def setXpsPose(armature, xpsData): - currentMode = bpy.context.mode - currentObj = bpy.context.active_object - bpy.ops.object.mode_set(mode='OBJECT', toggle=False) + current_mode = bpy.context.mode + current_obj = bpy.context.active_object - context = bpy.context - rigobj = armature - context.view_layer.objects.active = rigobj - rigobj.select_set(state=True) + bpy.ops.object.mode_set(mode='OBJECT') + bpy.context.view_layer.objects.active = armature + armature.select_set(True) bpy.ops.object.mode_set(mode='POSE') - bpy.ops.pose.select_all(action='DESELECT') - for boneData in xpsData.items(): - xpsBoneData = boneData[1] - boneName = xpsBoneData.boneName - poseBone = rigobj.pose.bones.get(boneName) - if poseBone is None: - poseBone = rigobj.pose.bones.get(renameBoneToBlender(boneName)) + for boneName, boneData in xpsData.items(): + poseBone = armature.pose.bones.get(boneName) + if not poseBone: + poseBone = armature.pose.bones.get(renameBoneToBlender(boneName)) if poseBone: - xpsPoseBone(poseBone, xpsBoneData) - poseBone.bone.select = True - - bpy.ops.anim.keyframe_insert(type='LocRotScale') - bpy.ops.object.posemode_toggle() - context.view_layer.objects.active = currentObj - bpy.ops.object.mode_set(mode=currentMode) - + xpsPoseBone(poseBone, boneData) + + insert_keyframes_for_pose(armature) + + bpy.ops.object.mode_set(mode='OBJECT') + + if current_obj: + bpy.context.view_layer.objects.active = current_obj + if current_mode != 'OBJECT': + bpy.ops.object.mode_set(mode=current_mode) + +def insert_keyframes_for_pose(armature): + scene = bpy.context.scene + current_frame = scene.frame_current + + for pose_bone in armature.pose.bones: + # Fix: Use length_squared for location comparison + if pose_bone.location.length_squared > 0.0001: + pose_bone.keyframe_insert(data_path="location", frame=current_frame) + + if pose_bone.rotation_mode == 'QUATERNION': + default_quat = Quaternion((1, 0, 0, 0)) + diff_quat = pose_bone.rotation_quaternion.rotation_difference(default_quat) + if diff_quat.angle > 0.0001: + pose_bone.keyframe_insert(data_path="rotation_quaternion", frame=current_frame) + else: + # Fix: Use proper Euler angle comparison + default_euler = Euler((0, 0, 0)) + current_euler = pose_bone.rotation_euler + diff_euler = Vector(( + abs(current_euler.x - default_euler.x), + abs(current_euler.y - default_euler.y), + abs(current_euler.z - default_euler.z) + )) + if diff_euler.length > 0.0001: + pose_bone.keyframe_insert(data_path="rotation_euler", frame=current_frame) + + # Fix: Use proper scale comparison + scale_diff = (pose_bone.scale - Vector((1, 1, 1))).length + if scale_diff > 0.0001: + pose_bone.keyframe_insert(data_path="scale", frame=current_frame) def xpsPoseBone(poseBone, xpsBoneData): xpsBoneRotate(poseBone, xpsBoneData.rotDelta) xpsBoneTranslate(poseBone, xpsBoneData.coordDelta) xpsBoneScale(poseBone, xpsBoneData.scale) - def xpsBoneRotToEuler(rotDelta): - xRad = radians(rotDelta.x) - yRad = radians(rotDelta.y) - zRad = radians(rotDelta.z) - return Euler((xRad, yRad, zRad), 'YXZ') - + return Euler((radians(rotDelta.x), radians(rotDelta.y), radians(rotDelta.z)), 'YXZ') def vectorTransform(vec): - x = vec.x - y = vec.y - z = vec.z - z = -z - newVec = Vector((x, z, y)) - return newVec - + return Vector((vec.x, -vec.z, vec.y)) def vectorTransformTranslate(vec): - x = vec.x - y = vec.y - z = vec.z - z = -z - newVec = Vector((x, z, y)) - return newVec - + return Vector((vec.x, -vec.z, vec.y)) def vectorTransformScale(vec): - x = vec.x - y = vec.y - z = vec.z - newVec = Vector((x, y, z)) - return newVec - + return Vector((vec.x, vec.y, vec.z)) def xpsBoneRotate(poseBone, rotDelta): - current_rottion_mode = poseBone.rotation_mode + prev_mode = poseBone.rotation_mode poseBone.rotation_mode = 'QUATERNION' - rotation = vectorTransform(rotDelta) - eulerRot = xpsBoneRotToEuler(rotation) - origRot = poseBone.bone.matrix_local.to_quaternion() # LOCAL EditBone - - rotation = eulerRot.to_quaternion() - poseBone.rotation_quaternion = origRot.inverted() @ rotation @ origRot - poseBone.rotation_mode = current_rottion_mode + rot = vectorTransform(rotDelta) + euler = xpsBoneRotToEuler(rot) + edit_quat = poseBone.bone.matrix_local.to_quaternion() + delta_quat = euler.to_quaternion() -def xpsBoneTranslate(poseBone, coordsDelta): - translate = coordsDelta - translate = vectorTransformTranslate(coordsDelta) - origRot = poseBone.bone.matrix_local.to_quaternion() # LOCAL EditBone - - poseBone.location = origRot.inverted() @ translate + poseBone.rotation_quaternion = edit_quat.inverted() @ delta_quat @ edit_quat + poseBone.rotation_mode = prev_mode +def xpsBoneTranslate(poseBone, coordDelta): + trans = vectorTransformTranslate(coordDelta) + edit_quat = poseBone.bone.matrix_local.to_quaternion() + poseBone.location = edit_quat.inverted() @ trans def xpsBoneScale(poseBone, scale): - newScale = vectorTransformScale(scale) - poseBone.scale = newScale - - -if __name__ == "__main__": - readPosefilename1 = r"G:\3DModeling\XNALara\XNALara_XPS\dataTest\Models\Queen's Blade\hide Kelta.pose" - - getInputFilename(readPosefilename1) + poseBone.scale = vectorTransformScale(scale) \ No newline at end of file diff --git a/material_creator.py b/material_creator.py index bc9b564..5b1f07e 100644 --- a/material_creator.py +++ b/material_creator.py @@ -39,8 +39,8 @@ # Nodes Convert SHADER_NODE_MATH = 'ShaderNodeMath' RGB_TO_BW_NODE = 'ShaderNodeRGBToBW' -SHADER_NODE_SEPARATE_RGB = 'ShaderNodeSeparateRGB' -SHADER_NODE_COMBINE_RGB = 'ShaderNodeCombineRGB' +SHADER_NODE_SEPARATE_COLOR = 'ShaderNodeSeparateColor' +SHADER_NODE_COMBINE_COLOR = 'ShaderNodeCombineColor' # Node Groups NODE_GROUP = 'ShaderNodeGroup' @@ -69,6 +69,43 @@ GREY_COLOR = (0.5, 0.5, 0.5, 1) +if bpy.app.version < (4, 0): + def new_input_socket(node_tree, socket_type, socket_name): + return node_tree.inputs.new(socket_type, socket_name) + + def new_output_socket(node_tree, socket_type, socket_name): + return node_tree.outputs.new(socket_type, socket_name) + + def clear_sockets(node_tree): + node_tree.inputs.clear() + node_tree.outputs.clear() +else: + # Blender 4.0 moved NodeTree inputs and outputs into a combined interface. + # Additionally, only base socket types can be created directly. Subtypes must be set explicitly after socket + # creation. + NODE_SOCKET_SUBTYPES = { + # There are a lot more, but this is the only one in use currently. + NODE_SOCKET_FLOAT_FACTOR: ('FACTOR', NODE_SOCKET_FLOAT), + } + + def _new_socket(node_tree, socket_type, socket_name, in_out): + subtype, base_type = NODE_SOCKET_SUBTYPES.get(socket_type, (None, None)) + new_socket = node_tree.interface.new_socket(socket_name, in_out=in_out, + socket_type=base_type if base_type else socket_type) + if subtype: + new_socket.subtype = subtype + return new_socket + + def new_input_socket(node_tree, socket_type, socket_name): + return _new_socket(node_tree, socket_type, socket_name, 'INPUT') + + def new_output_socket(node_tree, socket_type, socket_name): + return _new_socket(node_tree, socket_type, socket_name, 'OUTPUT') + + def clear_sockets(node_tree): + node_tree.interface.clear() + + def makeMaterialOutputNode(node_tree): node = node_tree.nodes.new(OUTPUT_NODE) node.location = 600, 0 @@ -185,17 +222,10 @@ def makeNodesMaterial(xpsSettings, materialData, rootDir, mesh_da, meshInfo, fla coordNode.location = xpsShadeNode.location + Vector((-2500, 400)) if useAlpha: - materialData.blend_method = 'BLEND' + materialData.blend_method = 'HASHED' node_tree.links.new(xpsShadeNode.outputs['Shader'], ouputNode.inputs['Surface']) - bump1Image = None - bump2Image = None - maskGroupNode = None - normalMixNode = None - diffuseImgNode = None - normalMapNode = None - col_width = 200 imagesPosX = -col_width * 6 imagesPosY = 400 @@ -226,7 +256,6 @@ def makeNodesMaterial(xpsSettings, materialData, rootDir, mesh_da, meshInfo, fla node_tree.links.new(imageNode.outputs['Color'], xpsShadeNode.inputs['Diffuse']) imageNode.location = xpsShadeNode.location + Vector((imagesPosX, imagesPosY * 1)) mappingCoordNode.location = imageNode.location + Vector((-400, 0)) - diffuseImgNode = imageNode if useAlpha: node_tree.links.new(imageNode.outputs['Alpha'], xpsShadeNode.inputs['Alpha']) elif (texType == xps_material.TextureType.LIGHT): @@ -309,13 +338,17 @@ def mix_normal_group(): node_tree = bpy.data.node_groups.new(name=MIX_NORMAL_NODE, type=SHADER_NODE_TREE) node_tree.nodes.clear() - mainNormalSeparateNode = node_tree.nodes.new(SHADER_NODE_SEPARATE_RGB) + mainNormalSeparateNode = node_tree.nodes.new(SHADER_NODE_SEPARATE_COLOR) + mainNormalSeparateNode.mode = 'RGB' mainNormalSeparateNode.location = Vector((0, 0)) - detailNormalSeparateNode = node_tree.nodes.new(SHADER_NODE_SEPARATE_RGB) + detailNormalSeparateNode = node_tree.nodes.new(SHADER_NODE_SEPARATE_COLOR) + detailNormalSeparateNode.mode = 'RGB' detailNormalSeparateNode.location = mainNormalSeparateNode.location + Vector((0, -200)) - mainNormalCombineNode = node_tree.nodes.new(SHADER_NODE_COMBINE_RGB) + mainNormalCombineNode = node_tree.nodes.new(SHADER_NODE_COMBINE_COLOR) + mainNormalCombineNode.mode = 'RGB' mainNormalCombineNode.location = mainNormalSeparateNode.location + Vector((200, 0)) - detailNormalCombineNode = node_tree.nodes.new(SHADER_NODE_COMBINE_RGB) + detailNormalCombineNode = node_tree.nodes.new(SHADER_NODE_COMBINE_COLOR) + detailNormalCombineNode.mode = 'RGB' detailNormalCombineNode.location = mainNormalSeparateNode.location + Vector((200, -200)) multiplyBlueNode = node_tree.nodes.new(SHADER_NODE_MATH) @@ -333,9 +366,11 @@ def mix_normal_group(): subsRGBNode.inputs['Fac'].default_value = 1 subsRGBNode.location = mainNormalSeparateNode.location + Vector((600, -100)) - separateRedBlueNode = node_tree.nodes.new(SHADER_NODE_SEPARATE_RGB) + separateRedBlueNode = node_tree.nodes.new(SHADER_NODE_SEPARATE_COLOR) + separateRedBlueNode.mode = 'RGB' separateRedBlueNode.location = mainNormalSeparateNode.location + Vector((800, -100)) - combineFinalNode = node_tree.nodes.new(SHADER_NODE_COMBINE_RGB) + combineFinalNode = node_tree.nodes.new(SHADER_NODE_COMBINE_COLOR) + combineFinalNode.mode = 'RGB' combineFinalNode.location = mainNormalSeparateNode.location + Vector((1000, -200)) # Input/Output @@ -343,41 +378,41 @@ def mix_normal_group(): group_inputs.location = mainNormalSeparateNode.location + Vector((-200, -100)) group_outputs = node_tree.nodes.new(NODE_GROUP_OUTPUT) group_outputs.location = mainNormalSeparateNode.location + Vector((1200, -100)) - node_tree.inputs.clear() - node_tree.outputs.clear() + clear_sockets(node_tree) # Input Sockets - main_normal_socket = node_tree.inputs.new(NODE_SOCKET_COLOR, 'Main') + main_normal_socket = new_input_socket(node_tree, NODE_SOCKET_COLOR, 'Main') main_normal_socket.default_value = NORMAL_COLOR - detail_normal_socket = node_tree.inputs.new(NODE_SOCKET_COLOR, 'Detail') + detail_normal_socket = new_input_socket(node_tree, NODE_SOCKET_COLOR, 'Detail') detail_normal_socket.default_value = NORMAL_COLOR # Output Sockets - output_value = node_tree.outputs.new(NODE_SOCKET_COLOR, 'Color') + output_value = new_output_socket(node_tree, NODE_SOCKET_COLOR, 'Color') # Links Input links = node_tree.links - links.new(group_inputs.outputs['Main'], mainNormalSeparateNode.inputs['Image']) - links.new(group_inputs.outputs['Detail'], detailNormalSeparateNode.inputs['Image']) - - links.new(mainNormalSeparateNode.outputs['R'], mainNormalCombineNode.inputs['R']) - links.new(mainNormalSeparateNode.outputs['G'], mainNormalCombineNode.inputs['G']) - links.new(mainNormalSeparateNode.outputs['B'], multiplyBlueNode.inputs[0]) - links.new(detailNormalSeparateNode.outputs['R'], detailNormalCombineNode.inputs['R']) - links.new(detailNormalSeparateNode.outputs['G'], detailNormalCombineNode.inputs['G']) - links.new(detailNormalSeparateNode.outputs['B'], multiplyBlueNode.inputs[1]) - - links.new(mainNormalCombineNode.outputs['Image'], addRGBNode.inputs[1]) - links.new(detailNormalCombineNode.outputs['Image'], addRGBNode.inputs[2]) + links.new(group_inputs.outputs['Main'], mainNormalSeparateNode.inputs['Color']) + links.new(group_inputs.outputs['Detail'], detailNormalSeparateNode.inputs['Color']) + + # 使用索引连接: 0=R, 1=G, 2=B + links.new(mainNormalSeparateNode.outputs[0], mainNormalCombineNode.inputs[0]) # R + links.new(mainNormalSeparateNode.outputs[1], mainNormalCombineNode.inputs[1]) # G + links.new(mainNormalSeparateNode.outputs[2], multiplyBlueNode.inputs[0]) # B + links.new(detailNormalSeparateNode.outputs[0], detailNormalCombineNode.inputs[0]) # R + links.new(detailNormalSeparateNode.outputs[1], detailNormalCombineNode.inputs[1]) # G + links.new(detailNormalSeparateNode.outputs[2], multiplyBlueNode.inputs[1]) # B + + links.new(mainNormalCombineNode.outputs['Color'], addRGBNode.inputs[1]) + links.new(detailNormalCombineNode.outputs['Color'], addRGBNode.inputs[2]) links.new(addRGBNode.outputs['Color'], subsRGBNode.inputs[1]) - links.new(subsRGBNode.outputs['Color'], separateRedBlueNode.inputs['Image']) + links.new(subsRGBNode.outputs['Color'], separateRedBlueNode.inputs['Color']) - links.new(separateRedBlueNode.outputs['R'], combineFinalNode.inputs['R']) - links.new(separateRedBlueNode.outputs['G'], combineFinalNode.inputs['G']) - links.new(multiplyBlueNode.outputs['Value'], combineFinalNode.inputs['B']) + links.new(separateRedBlueNode.outputs[0], combineFinalNode.inputs[0]) # R + links.new(separateRedBlueNode.outputs[1], combineFinalNode.inputs[1]) # G + links.new(multiplyBlueNode.outputs['Value'], combineFinalNode.inputs[2]) # B - links.new(combineFinalNode.outputs['Image'], group_outputs.inputs['Color']) + links.new(combineFinalNode.outputs['Color'], group_outputs.inputs['Color']) return node_tree @@ -389,7 +424,8 @@ def invert_channel_group(): node_tree = bpy.data.node_groups.new(name=INVERT_CHANNEL_NODE, type=SHADER_NODE_TREE) node_tree.nodes.clear() - separateRgbNode = node_tree.nodes.new(SHADER_NODE_SEPARATE_RGB) + separateRgbNode = node_tree.nodes.new(SHADER_NODE_SEPARATE_COLOR) + separateRgbNode.mode = 'RGB' separateRgbNode.location = Vector((0, 0)) invertRNode = node_tree.nodes.new(INVERT_NODE) @@ -402,7 +438,8 @@ def invert_channel_group(): invertBNode.inputs[0].default_value = 0 invertBNode.location = separateRgbNode.location + Vector((200, -160)) - combineRgbNode = node_tree.nodes.new(SHADER_NODE_COMBINE_RGB) + combineRgbNode = node_tree.nodes.new(SHADER_NODE_COMBINE_COLOR) + combineRgbNode.mode = 'RGB' combineRgbNode.location = separateRgbNode.location + Vector((600, 0)) # Input/Output @@ -410,42 +447,43 @@ def invert_channel_group(): group_inputs.location = separateRgbNode.location + Vector((-200, -100)) group_outputs = node_tree.nodes.new(NODE_GROUP_OUTPUT) group_outputs.location = combineRgbNode.location + Vector((200, 0)) - node_tree.inputs.clear() - node_tree.outputs.clear() + clear_sockets(node_tree) # Input/Output Sockets - input_color = node_tree.inputs.new(NODE_SOCKET_COLOR, 'Color') + input_color = new_input_socket(node_tree, NODE_SOCKET_COLOR, 'Color') input_color.default_value = GREY_COLOR - invert_r = node_tree.inputs.new(NODE_SOCKET_FLOAT_FACTOR, 'R') + invert_r = new_input_socket(node_tree, NODE_SOCKET_FLOAT_FACTOR, 'R') invert_r.default_value = 0 invert_r.min_value = 0 invert_r.max_value = 1 - invert_g = node_tree.inputs.new(NODE_SOCKET_FLOAT_FACTOR, 'G') + invert_g = new_input_socket(node_tree, NODE_SOCKET_FLOAT_FACTOR, 'G') invert_g.default_value = 0 invert_g.min_value = 0 invert_g.max_value = 1 - invert_b = node_tree.inputs.new(NODE_SOCKET_FLOAT_FACTOR, 'B') + invert_b = new_input_socket(node_tree, NODE_SOCKET_FLOAT_FACTOR, 'B') invert_b.default_value = 0 invert_b.min_value = 0 invert_b.max_value = 1 - output_value = node_tree.outputs.new(NODE_SOCKET_COLOR, 'Color') + output_value = new_output_socket(node_tree, NODE_SOCKET_COLOR, 'Color') # Links Input links = node_tree.links - links.new(group_inputs.outputs['Color'], separateRgbNode.inputs['Image']) + links.new(group_inputs.outputs['Color'], separateRgbNode.inputs['Color']) links.new(group_inputs.outputs['R'], invertRNode.inputs['Fac']) links.new(group_inputs.outputs['G'], invertGNode.inputs['Fac']) links.new(group_inputs.outputs['B'], invertBNode.inputs['Fac']) - links.new(separateRgbNode.outputs['R'], invertRNode.inputs['Color']) - links.new(separateRgbNode.outputs['G'], invertGNode.inputs['Color']) - links.new(separateRgbNode.outputs['B'], invertBNode.inputs['Color']) + + # 使用索引连接: 0=R, 1=G, 2=B + links.new(separateRgbNode.outputs[0], invertRNode.inputs['Color']) # R + links.new(separateRgbNode.outputs[1], invertGNode.inputs['Color']) # G + links.new(separateRgbNode.outputs[2], invertBNode.inputs['Color']) # B - links.new(invertRNode.outputs['Color'], combineRgbNode.inputs['R']) - links.new(invertGNode.outputs['Color'], combineRgbNode.inputs['G']) - links.new(invertBNode.outputs['Color'], combineRgbNode.inputs['B']) + links.new(invertRNode.outputs['Color'], combineRgbNode.inputs[0]) # R + links.new(invertGNode.outputs['Color'], combineRgbNode.inputs[1]) # G + links.new(invertBNode.outputs['Color'], combineRgbNode.inputs[2]) # B - links.new(combineRgbNode.outputs['Image'], group_outputs.inputs['Color']) + links.new(combineRgbNode.outputs['Color'], group_outputs.inputs['Color']) return node_tree @@ -457,7 +495,8 @@ def normal_mask_group(): node_tree = bpy.data.node_groups.new(name=NORMAL_MASK_NODE, type=SHADER_NODE_TREE) node_tree.nodes.clear() - maskSeparateNode = node_tree.nodes.new(SHADER_NODE_SEPARATE_RGB) + maskSeparateNode = node_tree.nodes.new(SHADER_NODE_SEPARATE_COLOR) + maskSeparateNode.mode = 'RGB' # Mask Red Channel maskRedPowerNode = node_tree.nodes.new(SHADER_NODE_MATH) @@ -485,8 +524,8 @@ def normal_mask_group(): normalMixNode = getNodeGroup(node_tree, MIX_NORMAL_NODE) normalMixNode.location = maskSeparateNode.location + Vector((600, 0)) - node_tree.links.new(maskSeparateNode.outputs['R'], maskRedPowerNode.inputs[0]) - node_tree.links.new(maskSeparateNode.outputs['G'], maskGreenPowerNode.inputs[0]) + node_tree.links.new(maskSeparateNode.outputs[0], maskRedPowerNode.inputs[0]) # R + node_tree.links.new(maskSeparateNode.outputs[1], maskGreenPowerNode.inputs[0]) # G node_tree.links.new(maskRedPowerNode.outputs['Value'], maskMixRedNode.inputs[0]) node_tree.links.new(maskGreenPowerNode.outputs['Value'], maskMixGreenNode.inputs[0]) node_tree.links.new(maskMixRedNode.outputs['Color'], normalMixNode.inputs['Main']) @@ -497,21 +536,20 @@ def normal_mask_group(): group_inputs.location = maskSeparateNode.location + Vector((-200, -100)) group_outputs = node_tree.nodes.new(NODE_GROUP_OUTPUT) group_outputs.location = normalMixNode.location + Vector((200, 0)) - node_tree.inputs.clear() - node_tree.outputs.clear() + clear_sockets(node_tree) # Input/Output Sockets - mask_color = node_tree.inputs.new(NODE_SOCKET_COLOR, 'Mask') + mask_color = new_input_socket(node_tree, NODE_SOCKET_COLOR, 'Mask') mask_color.default_value = LIGHTMAP_COLOR - normalMain_color = node_tree.inputs.new(NODE_SOCKET_COLOR, 'Normal1') + normalMain_color = new_input_socket(node_tree, NODE_SOCKET_COLOR, 'Normal1') normalMain_color.default_value = NORMAL_COLOR - normalDetail_color = node_tree.inputs.new(NODE_SOCKET_COLOR, 'Normal2') + normalDetail_color = new_input_socket(node_tree, NODE_SOCKET_COLOR, 'Normal2') normalDetail_color.default_value = NORMAL_COLOR - output_value = node_tree.outputs.new(NODE_SOCKET_COLOR, 'Normal') + output_value = new_output_socket(node_tree, NODE_SOCKET_COLOR, 'Normal') # Link Inputs/Output - node_tree.links.new(group_inputs.outputs['Mask'], maskSeparateNode.inputs['Image']) + node_tree.links.new(group_inputs.outputs['Mask'], maskSeparateNode.inputs['Color']) node_tree.links.new(group_inputs.outputs['Normal1'], maskMixRedNode.inputs[2]) node_tree.links.new(group_inputs.outputs['Normal2'], maskMixGreenNode.inputs[2]) node_tree.links.new(normalMixNode.outputs['Color'], group_outputs.inputs['Normal']) @@ -537,28 +575,28 @@ def xps_shader_group(): group_output = shader.nodes.new(NODE_GROUP_OUTPUT) group_output.location += Vector((600, 0)) - output_diffuse = shader.inputs.new(NODE_SOCKET_COLOR, 'Diffuse') + output_diffuse = new_input_socket(shader, NODE_SOCKET_COLOR, 'Diffuse') output_diffuse.default_value = (DIFFUSE_COLOR) - output_lightmap = shader.inputs.new(NODE_SOCKET_COLOR, 'Lightmap') + output_lightmap = new_input_socket(shader, NODE_SOCKET_COLOR, 'Lightmap') output_lightmap.default_value = (LIGHTMAP_COLOR) - output_specular = shader.inputs.new(NODE_SOCKET_COLOR, 'Specular') + output_specular = new_input_socket(shader, NODE_SOCKET_COLOR, 'Specular') output_specular.default_value = (SPECULAR_COLOR) - output_emission = shader.inputs.new(NODE_SOCKET_COLOR, 'Emission') - output_normal = shader.inputs.new(NODE_SOCKET_COLOR, 'Bump Map') + output_emission = new_input_socket(shader, NODE_SOCKET_COLOR, 'Emission') + output_normal = new_input_socket(shader, NODE_SOCKET_COLOR, 'Bump Map') output_normal.default_value = (NORMAL_COLOR) - output_bump_mask = shader.inputs.new(NODE_SOCKET_COLOR, 'Bump Mask') - output_microbump1 = shader.inputs.new(NODE_SOCKET_COLOR, 'MicroBump 1') + output_bump_mask = new_input_socket(shader, NODE_SOCKET_COLOR, 'Bump Mask') + output_microbump1 = new_input_socket(shader, NODE_SOCKET_COLOR, 'MicroBump 1') output_microbump1.default_value = (NORMAL_COLOR) - output_microbump2 = shader.inputs.new(NODE_SOCKET_COLOR, 'MicroBump 2') + output_microbump2 = new_input_socket(shader, NODE_SOCKET_COLOR, 'MicroBump 2') output_microbump2.default_value = (NORMAL_COLOR) - output_environment = shader.inputs.new(NODE_SOCKET_COLOR, 'Environment') - output_alpha = shader.inputs.new(NODE_SOCKET_FLOAT_FACTOR, 'Alpha') + output_environment = new_input_socket(shader, NODE_SOCKET_COLOR, 'Environment') + output_alpha = new_input_socket(shader, NODE_SOCKET_FLOAT_FACTOR, 'Alpha') output_alpha.min_value = 0 output_alpha.max_value = 1 output_alpha.default_value = 1 # Group outputs - shader.outputs.new(NODE_SOCKET_SHADER, 'Shader') + new_output_socket(shader, NODE_SOCKET_SHADER, 'Shader') principled = shader.nodes.new(PRINCIPLED_SHADER_NODE) @@ -589,7 +627,8 @@ def xps_shader_group(): # Alpha & Emission shader.links.new(group_input.outputs['Alpha'], principled.inputs['Alpha']) - shader.links.new(group_input.outputs['Emission'], principled.inputs['Emission']) + emission_input_name = 'Emission' if bpy.app.version < (4, 0) else 'Emission Color' + shader.links.new(group_input.outputs['Emission'], principled.inputs[emission_input_name]) # Normals normal_invert_channel = getNodeGroup(shader, INVERT_CHANNEL_NODE) @@ -642,4 +681,4 @@ def xps_shader_group(): shader.links.new(principled.outputs['BSDF'], shader_add.inputs[1]) shader.links.new(shader_add.outputs['Shader'], group_output.inputs[0]) - return shader + return shader \ No newline at end of file diff --git a/mock_xps_data.py b/mock_xps_data.py index 99dee8f..d2de766 100644 --- a/mock_xps_data.py +++ b/mock_xps_data.py @@ -7,15 +7,6 @@ import bpy -def mockData(): - xpsHeader = buildHeader() - bones = buildBones() - meshes = buildMeshes() - xpsData = xps_types.XpsData(xpsHeader, bones, meshes) - - return xpsData - - def fillPoseString(poseBytes): poseLenghtUnround = len(poseBytes) poseLenght = bin_ops.roundToMultiple( @@ -30,177 +21,4 @@ def getPoseStringLength(poseString): def bonePoseCount(poseString): boneList = poseString.split('\n') - return len(boneList) - 1 - - -def buildHeader(poseString=''): - invertUserName = getuser()[::-1] - invertHostName = gethostname()[::-1] - header = xps_types.XpsHeader() - header.magic_number = xps_const.MAGIC_NUMBER - header.version_mayor = xps_const.XPS_VERSION_MAYOR - header.version_minor = xps_const.XPS_VERSION_MINOR - header.xna_aral = xps_const.XNA_ARAL - header.machine = invertHostName - header.user = invertUserName - header.files = f'{invertUserName}@{bpy.data.filepath}' - # header.settings = bytes([0])* - # (xps_const.SETTINGS_LEN * xps_const.ROUND_MULTIPLE) - - boneCount = bonePoseCount(poseString) - poseBytes = poseString.encode(xps_const.ENCODING_WRITE) - default_pose = fillPoseString(poseBytes) - poseLengthUnround = getPoseStringLength(poseString) - - var_1 = bin_ops.writeUInt32(180) # Hash - var_2 = bin_ops.writeUInt32(3) # Items - - var_3 = bin_ops.writeUInt32(1) # Type - var_4 = bin_ops.writeUInt32(poseLengthUnround) # Pose Lenght Unround - var_5 = bin_ops.writeUInt32(boneCount) # Pose Bone Counts - # POSE DATA - var_6 = bin_ops.writeUInt32(2) # Type - var_7 = bin_ops.writeUInt32(4) # Count - var_8 = bin_ops.writeUInt32(4) # Info - var_9 = bin_ops.writeUInt32(2) # Count N1 - var_10 = bin_ops.writeUInt32(1) # Count N2 - var_11 = bin_ops.writeUInt32(3) # Count N3 - var_12 = bin_ops.writeUInt32(0) # Count N4 - var_13 = bin_ops.writeUInt32(4) # Type - var_14 = bin_ops.writeUInt32(3) # Count - var_15 = bin_ops.writeUInt32(5) # Info - var_16 = bin_ops.writeUInt32(4) - var_17 = bin_ops.writeUInt32(0) - var_18 = bin_ops.writeUInt32(256) - - header_empty = b'' - header_empty += var_6 - header_empty += var_7 - header_empty += var_8 - header_empty += var_9 - header_empty += var_10 - header_empty += var_11 - header_empty += var_12 - header_empty += var_13 - header_empty += var_14 - header_empty += var_15 - header_empty += var_16 - header_empty += var_17 - header_empty += var_18 - - header_unk = var_1 + var_2 + var_3 - header_pose = var_4 + var_5 + default_pose - empty_count = ((xps_const.SETTINGS_LEN - len(header_empty)) // 4) - header_empty += bin_ops.writeUInt32(0) * empty_count - - settings = header_unk + header_pose + header_empty - header.settingsLen = len(settings) // 4 - header.settings = settings - - # logHeader(header) - return header - - -def buildBones(): - bones = [] - - id = 0 - name = 'bone1' - co = [0, 0, 0] - parentId = -1 - bone = xps_types.XpsBone(id, name, co, parentId) - bones.append(bone) - - id = 1 - name = 'bone2' - co = [0.5, 0.5, 0.5] - parentId = 0 - bone = xps_types.XpsBone(id, name, co, parentId) - bones.append(bone) - return bones - - -def buildMeshes(): - meshes = [] - meshName = 'Mesh1' - uvLayerCount = 1 - - # Textures - textures = [] - texId = 0 - textureFile = 'textutefile1.png' - uvLayerId = 0 - xpsTexture = xps_types.XpsTexture(texId, textureFile, uvLayerId) - textures.append(xpsTexture) - - texId = 1 - textureFile = 'textutefile2.png' - uvLayerId = 0 - xpsTexture = xps_types.XpsTexture(texId, textureFile, uvLayerId) - textures.append(xpsTexture) - - # Vertices - vertex = [] - - # Vertex1 - vertexId = 0 - coord = (1, 0, 0) - normal = (0, 0, 1) - vertexColor = (255, 255, 255, 0) - uvs = [] - uvs.append((.2, .4)) - boneWeights = ( - xps_types.BoneWeight(0, 0), - xps_types.BoneWeight(0, 0), - xps_types.BoneWeight(0, 0), - xps_types.BoneWeight(0, 0)) - xpsVertex = xps_types.XpsVertex( - vertexId, coord, normal, vertexColor, uvs, boneWeights) - - # Vertex2 - vertexId = 1 - coord = (0, 1, 0) - normal = (0, 1, 0) - vertexColor = (255, 255, 255, 0) - uvs = [] - uvs.append((.3, .5)) - boneWeights = ( - xps_types.BoneWeight(0, 0), - xps_types.BoneWeight(0, 0), - xps_types.BoneWeight(0, 0), - xps_types.BoneWeight(0, 0)) - xpsVertex = xps_types.XpsVertex( - vertexId, coord, normal, vertexColor, uvs, boneWeights) - vertex.append(xpsVertex) - - # Vertex3 - vertexId = 2 - coord = (0, 0, 1) - normal = (1, 0, 0) - vertexColor = (255, 255, 255, 0) - uvs = [] - uvs.append((.3, .9)) - boneWeights = ( - xps_types.BoneWeight(0, 0), - xps_types.BoneWeight(0, 0), - xps_types.BoneWeight(0, 0), - xps_types.BoneWeight(0, 0)) - xpsVertex = xps_types.XpsVertex( - vertexId, coord, normal, vertexColor, uvs, boneWeights) - vertex.append(xpsVertex) - - faces = [] - face = (0, 1, 2) - faces.append(face) - - xpsMesh = xps_types.XpsMesh( - meshName, textures, vertex, faces, uvLayerCount) - meshes.append(xpsMesh) - - return meshes - - -if __name__ == "__main__": - print('BUILD') - xx = mockData() - print('FINISH') + return len(boneList) - 1 \ No newline at end of file diff --git a/node_shader_utils.py b/node_shader_utils.py index 278726e..e545f6f 100644 --- a/node_shader_utils.py +++ b/node_shader_utils.py @@ -1,441 +1,495 @@ +import os +from . import import_xnalara_pose +from . import export_xnalara_pose +from . import write_ascii_xps +from . import write_bin_xps +from . import bin_ops +from . import xps_material +from . import xps_types +from .timing import timing + import bpy -from bpy_extras import node_shader_utils from mathutils import Vector - - -class XPSShaderWrapper(node_shader_utils.ShaderWrapper): - """ - Hard coded shader setup, based in XPS Shader. - Should cover most common cases on import, and gives a basic nodal shaders support for export. - """ - NODES_LIST = ( - "node_out", - "node_principled_bsdf", - - "_node_normalmap", - "_node_texcoords", - ) - - __slots__ = ( - "is_readonly", - "material", - *NODES_LIST, - ) - - NODES_LIST = node_shader_utils.ShaderWrapper.NODES_LIST + NODES_LIST - - def __init__(self, material, is_readonly=True, use_nodes=True): - super(XPSShaderWrapper, self).__init__(material, is_readonly, use_nodes) - - def update(self): - super(XPSShaderWrapper, self).update() - - if not self.use_nodes: - return - - tree = self.material.node_tree - - nodes = tree.nodes - links = tree.links - - # -------------------------------------------------------------------- - # Main output and shader. - node_out = None - node_principled = None - for n in nodes: - # print("loop:",n.name) - if n.bl_idname == 'ShaderNodeOutputMaterial' and n.inputs[0].is_linked: - # print("output found:") - node_out = n - node_principled = n.inputs[0].links[0].from_node - elif n.bl_idname == 'ShaderNodeGroup' and n.node_tree.name == 'XPS Shader' and n.outputs[0].is_linked: - # print("xps shader found") - node_principled = n - for lnk in n.outputs[0].links: - node_out = lnk.to_node - if node_out.bl_idname == 'ShaderNodeOutputMaterial': - break - if ( - node_out is not None and node_principled is not None - and node_out.bl_idname == 'ShaderNodeOutputMaterial' - and node_principled.bl_idname == 'ShaderNodeGroup' - and node_principled.node_tree.name == 'XPS Shader' - ): - break - node_out = node_principled = None # Could not find a valid pair, let's try again - - if node_out is not None: - self._grid_to_location(0, 0, ref_node=node_out) - elif not self.is_readonly: - node_out = nodes.new(type='ShaderNodeOutputMaterial') - node_out.label = "Material Out" - node_out.target = 'ALL' - self._grid_to_location(1, 1, dst_node=node_out) - self.node_out = node_out - - if node_principled is not None: - self._grid_to_location(0, 0, ref_node=node_principled) - elif not self.is_readonly: - node_principled = nodes.new(type='XPS Shader') - node_principled.label = "Principled BSDF" - self._grid_to_location(0, 1, dst_node=node_principled) - # Link - links.new(node_principled.outputs["BSDF"], self.node_out.inputs["Surface"]) - self.node_principled_bsdf = node_principled - - # -------------------------------------------------------------------- - # Normal Map, lazy initialization... - self._node_normalmap = ... - - # -------------------------------------------------------------------- - # Tex Coords, lazy initialization... - self._node_texcoords = ... - - # -------------------------------------------------------------------- - # Get Image wrapper. - - def node_texture_get(self, inputName): - if not self.use_nodes or self.node_principled_bsdf is None: - return None - return node_shader_utils.ShaderImageTextureWrapper( - self, self.node_principled_bsdf, - self.node_principled_bsdf.inputs[inputName], - grid_row_diff=1, - ) - - # -------------------------------------------------------------------- - # Get Environment wrapper. - - def node_environment_get(self, inputName): - if not self.use_nodes or self.node_principled_bsdf is None: - return None - return ShaderEnvironmentTextureWrapper( - self, self.node_principled_bsdf, - self.node_principled_bsdf.inputs[inputName], - grid_row_diff=1, - ) - - # -------------------------------------------------------------------- - # Diffuse Texture. - - def diffuse_texture_get(self): - return self.node_texture_get("Diffuse") - - diffuse_texture = property(diffuse_texture_get) - - # -------------------------------------------------------------------- - # Light Map. - - def lightmap_texture_get(self): - return self.node_texture_get("Lightmap") - - lightmap_texture = property(lightmap_texture_get) - - # -------------------------------------------------------------------- - # Specular. - - def specular_texture_get(self): - return self.node_texture_get("Specular") - - specular_texture = property(specular_texture_get) - - # -------------------------------------------------------------------- - # Emission texture. - - def emission_texture_get(self): - return self.node_texture_get("Emission") - - emission_texture = property(emission_texture_get) - - # -------------------------------------------------------------------- - # Normal map. - - def normalmap_texture_get(self): - return self.node_texture_get("Bump Map") - - normalmap_texture = property(normalmap_texture_get) - - # -------------------------------------------------------------------- - # Normal Mask. - - def normal_mask_texture_get(self): - return self.node_texture_get("Bump Mask") - - normal_mask_texture = property(normal_mask_texture_get) - - # -------------------------------------------------------------------- - # Micro Bump 1. - - def microbump1_texture_get(self): - return self.node_texture_get("MicroBump 1") - - microbump1_texture = property(microbump1_texture_get) - - # -------------------------------------------------------------------- - # Micro Bump 2. - - def microbump2_texture_get(self): - return self.node_texture_get("MicroBump 2") - - microbump2_texture = property(microbump2_texture_get) - - # -------------------------------------------------------------------- - # Environment - - def environment_texture_get(self): - return self.node_environment_get("Environment") - - environment_texture = property(environment_texture_get) - - -class ShaderEnvironmentTextureWrapper(): - """ - Generic 'environment texture'-like wrapper, handling image node - """ - - # Note: this class assumes we are using nodes, otherwise it should never be used... - - NODES_LIST = ( - "node_dst", - "socket_dst", - - "_node_image", - "_node_mapping", - ) - - __slots__ = ( - "owner_shader", - "is_readonly", - "grid_row_diff", - "use_alpha", - "colorspace_is_data", - "colorspace_name", - *NODES_LIST, - ) - - def __new__(cls, owner_shader: node_shader_utils.ShaderWrapper, node_dst, socket_dst, *_args, **_kwargs): - instance = owner_shader._textures.get((node_dst, socket_dst), None) - if instance is not None: - return instance - instance = super(ShaderEnvironmentTextureWrapper, cls).__new__(cls) - owner_shader._textures[(node_dst, socket_dst)] = instance - return instance - - def __init__(self, owner_shader: node_shader_utils.ShaderWrapper, node_dst, socket_dst, grid_row_diff=0, - use_alpha=False, colorspace_is_data=..., colorspace_name=...): - self.owner_shader = owner_shader - self.is_readonly = owner_shader.is_readonly - self.node_dst = node_dst - self.socket_dst = socket_dst - self.grid_row_diff = grid_row_diff - self.use_alpha = use_alpha - self.colorspace_is_data = colorspace_is_data - self.colorspace_name = colorspace_name - - self._node_image = ... - self._node_mapping = ... - - # tree = node_dst.id_data - # nodes = tree.nodes - # links = tree.links - - if socket_dst.is_linked: - from_node = socket_dst.links[0].from_node - if from_node.bl_idname == 'ShaderNodeTexEnvironment': - self._node_image = from_node - - if self.node_image is not None: - socket_dst = self.node_image.inputs["Vector"] - if socket_dst.is_linked: - from_node = socket_dst.links[0].from_node - if from_node.bl_idname == 'ShaderNodeMapping': - self._node_mapping = from_node - - def copy_from(self, tex): - # Avoid generating any node in source texture. - is_readonly_back = tex.is_readonly - tex.is_readonly = True - - if tex.node_image is not None: - self.image = tex.image - self.projection = tex.projection - self.texcoords = tex.texcoords - self.copy_mapping_from(tex) - - tex.is_readonly = is_readonly_back - - def copy_mapping_from(self, tex): - # Avoid generating any node in source texture. - is_readonly_back = tex.is_readonly - tex.is_readonly = True - - if tex.node_mapping is None: # Used to actually remove mapping node. - if self.has_mapping_node(): - # We assume node_image can never be None in that case... - # Find potential existing link into image's Vector input. - socket_dst = socket_src = None - if self.node_mapping.inputs["Vector"].is_linked: - socket_dst = self.node_image.inputs["Vector"] - socket_src = self.node_mapping.inputs["Vector"].links[0].from_socket - - tree = self.owner_shader.material.node_tree - tree.nodes.remove(self.node_mapping) - self._node_mapping = None - - # If previously existing, re-link texcoords -> image - if socket_src is not None: - tree.links.new(socket_src, socket_dst) - elif self.node_mapping is not None: - self.translation = tex.translation - self.rotation = tex.rotation - self.scale = tex.scale - - tex.is_readonly = is_readonly_back - - # -------------------------------------------------------------------- - # Image. - - def node_image_get(self): - if self._node_image is ...: - # Running only once, trying to find a valid image node. - if self.socket_dst.is_linked: - node_image = self.socket_dst.links[0].from_node - if node_image.bl_idname == 'ShaderNodeTexImage': - self._node_image = node_image - self.owner_shader._grid_to_location(0, 0, ref_node=node_image) - if self._node_image is ...: - self._node_image = None - if self._node_image is None and not self.is_readonly: - tree = self.owner_shader.material.node_tree - - node_image = tree.nodes.new(type='ShaderNodeTexImage') - self.owner_shader._grid_to_location(-1, 0 + self.grid_row_diff, dst_node=node_image, ref_node=self.node_dst) - - tree.links.new(node_image.outputs["Alpha" if self.use_alpha else "Color"], self.socket_dst) - - self._node_image = node_image - return self._node_image - - node_image = property(node_image_get) - - def image_get(self): - return self.node_image.image if self.node_image is not None else None - - @node_shader_utils._set_check - def image_set(self, image): - if self.colorspace_is_data is not ...: - if image.colorspace_settings.is_data != self.colorspace_is_data and image.users >= 1: - image = image.copy() - image.colorspace_settings.is_data = self.colorspace_is_data - if self.colorspace_name is not ...: - if image.colorspace_settings.is_data != self.colorspace_is_data and image.users >= 1: - image = image.copy() - image.colorspace_settings.name = self.colorspace_name - self.node_image.image = image - - image = property(image_get, image_set) - - def projection_get(self): - return self.node_image.projection if self.node_image is not None else 'EQUIRECTANGULAR' - - @node_shader_utils._set_check - def projection_set(self, projection): - self.node_image.projection = projection - - projection = property(projection_get, projection_set) - - def texcoords_get(self): - if self.node_image is not None: - socket = (self.node_mapping if self.has_mapping_node() else self.node_image).inputs["Vector"] - if socket.is_linked: - return socket.links[0].from_socket.name - return 'UV' - - @node_shader_utils._set_check - def texcoords_set(self, texcoords): - # Image texture node already defaults to UVs, no extra node needed. - # ONLY in case we do not have any texcoords mapping!!! - if texcoords == 'UV' and not self.has_mapping_node(): - return - tree = self.node_image.id_data - links = tree.links - node_dst = self.node_mapping if self.has_mapping_node() else self.node_image - socket_src = self.owner_shader.node_texcoords.outputs[texcoords] - links.new(socket_src, node_dst.inputs["Vector"]) - - texcoords = property(texcoords_get, texcoords_set) - - # -------------------------------------------------------------------- - # Mapping. - - def has_mapping_node(self): - return self._node_mapping not in {None, ...} - - def node_mapping_get(self): - if self._node_mapping is ...: - # Running only once, trying to find a valid mapping node. - if self.node_image is None: - return None - if self.node_image.inputs["Vector"].is_linked: - node_mapping = self.node_image.inputs["Vector"].links[0].from_node - if node_mapping.bl_idname == 'ShaderNodeMapping': - self._node_mapping = node_mapping - self.owner_shader._grid_to_location(0, 0 + self.grid_row_diff, ref_node=node_mapping) - if self._node_mapping is ...: - self._node_mapping = None - if self._node_mapping is None and not self.is_readonly: - # Find potential existing link into image's Vector input. - socket_dst = self.node_image.inputs["Vector"] - # If not already existing, we need to create texcoords -> mapping link (from UV). - socket_src = ( - socket_dst.links[0].from_socket if socket_dst.is_linked - else self.owner_shader.node_texcoords.outputs['UV'] - ) - - tree = self.owner_shader.material.node_tree - node_mapping = tree.nodes.new(type='ShaderNodeMapping') - node_mapping.vector_type = 'TEXTURE' - self.owner_shader._grid_to_location(-1, 0, dst_node=node_mapping, ref_node=self.node_image) - - # Link mapping -> image node. - tree.links.new(node_mapping.outputs["Vector"], socket_dst) - # Link texcoords -> mapping. - tree.links.new(socket_src, node_mapping.inputs["Vector"]) - - self._node_mapping = node_mapping - return self._node_mapping - - node_mapping = property(node_mapping_get) - - def translation_get(self): - if self.node_mapping is None: - return Vector((0.0, 0.0, 0.0)) - return self.node_mapping.inputs['Location'].default_value - - @node_shader_utils._set_check - def translation_set(self, translation): - self.node_mapping.inputs['Location'].default_value = translation - - translation = property(translation_get, translation_set) - - def rotation_get(self): - if self.node_mapping is None: - return Vector((0.0, 0.0, 0.0)) - return self.node_mapping.inputs['Rotation'].default_value - - @node_shader_utils._set_check - def rotation_set(self, rotation): - self.node_mapping.inputs['Rotation'].default_value = rotation - - rotation = property(rotation_get, rotation_set) - - def scale_get(self): - if self.node_mapping is None: - return Vector((1.0, 1.0, 1.0)) - return self.node_mapping.inputs['Scale'].default_value - - @node_shader_utils._set_check - def scale_set(self, scale): - self.node_mapping.inputs['Scale'].default_value = scale - - scale = property(scale_get, scale_set) +from collections import Counter + +class XPSShaderWrapper: + def __init__(self, material, use_nodes=True): + self.material = material + self.use_nodes = use_nodes + + self.diffuse_texture = None + self.lightmap_texture = None + self.normalmap_texture = None + self.normal_mask_texture = None + self.microbump1_texture = None + self.microbump2_texture = None + self.specular_texture = None + self.environment_texture = None + self.emission_texture = None + + if material and use_nodes and material.use_nodes: + self._extract_textures_from_nodes() + + def _extract_textures_from_nodes(self): + for node in self.material.node_tree.nodes: + if node.type == 'TEX_IMAGE' and node.image: + node_name = node.name.lower() if node.name else "" + node_label = node.label.lower() if node.label else "" + + if any(keyword in node_name or keyword in node_label + for keyword in ['diffuse', 'base color', 'albedo']): + self.diffuse_texture = node + elif any(keyword in node_name or keyword in node_label + for keyword in ['light', 'lightmap', 'ambient']): + self.lightmap_texture = node + elif any(keyword in node_name or keyword in node_label + for keyword in ['normal', 'bump', 'normals']): + self.normalmap_texture = node + elif any(keyword in node_name or keyword in node_label + for keyword in ['mask', 'normalmask']): + self.normal_mask_texture = node + elif any(keyword in node_name or keyword in node_label + for keyword in ['microbump1', 'detail1']): + self.microbump1_texture = node + elif any(keyword in node_name or keyword in node_label + for keyword in ['microbump2', 'detail2']): + self.microbump2_texture = node + elif any(keyword in node_name or keyword in node_label + for keyword in ['specular', 'spec']): + self.specular_texture = node + elif any(keyword in node_name or keyword in node_label + for keyword in ['environment', 'reflection']): + self.environment_texture = node + elif any(keyword in node_name or keyword in node_label + for keyword in ['emission', 'emissive', 'glow']): + self.emission_texture = node + +rootDir = '' + +def coordTransform(coords): + x, y, z = coords + y = -y + return (x, z, y) + +def faceTransform(face): + return [face[0], face[2], face[1]] + +def uvTransform(uv): + u = uv[0] + xpsSettings.uvDisplX + v = 1 - xpsSettings.uvDisplY - uv[1] + return [u, v] + +def rangeFloatToByte(float): + return int(float * 255) % 256 + +def rangeByteToFloat(byte): + return byte / 255 + +def uvTransformLayers(uvLayers): + return list(map(uvTransform, uvLayers)) + +def getArmature(selected_obj): + armature_obj = next((obj for obj in selected_obj + if obj.type == 'ARMATURE'), None) + return armature_obj + +def fillArray(array, minLen, value): + filled = array + [value] * (minLen - len(array)) + return filled + +def getOutputFilename(xpsSettingsAux): + global xpsSettings + xpsSettings = xpsSettingsAux + blenderExportSetup() + xpsExport() + blenderExportFinalize() + +def blenderExportSetup(): + objectMode() + +def blenderExportFinalize(): + pass + +def objectMode(): + current_mode = bpy.context.mode + if bpy.context.view_layer.objects.active and current_mode != 'OBJECT': + bpy.ops.object.mode_set(mode='OBJECT', toggle=False) + +def saveXpsFile(filename, xpsData): + dirpath, file = os.path.split(filename) + basename, ext = os.path.splitext(file) + if ext.lower() in ('.mesh', '.xps'): + write_bin_xps.writeXpsModel(xpsSettings, filename, xpsData) + elif ext.lower() in('.ascii'): + write_ascii_xps.writeXpsModel(xpsSettings, filename, xpsData) + +@timing +def xpsExport(): + global rootDir + global xpsData + + print("------------------------------------------------------------") + print("---------------EXECUTING XPS PYTHON EXPORTER----------------") + print("------------------------------------------------------------") + print("Exporting file: ", xpsSettings.filename) + + if xpsSettings.exportOnlySelected: + exportObjects = bpy.context.selected_objects + else: + exportObjects = bpy.context.visible_objects + + selectedArmature, selectedMeshes = exportSelected(exportObjects) + + xpsBones = exportArmature(selectedArmature) + xpsMeshes = exportMeshes(selectedArmature, selectedMeshes) + + poseString = '' + if(xpsSettings.expDefPose): + xpsPoseData = export_xnalara_pose.xpsPoseData(selectedArmature) + poseString = write_ascii_xps.writePose(xpsPoseData).read() + + header = None + hasHeader = bin_ops.hasHeader(xpsSettings.format) + if hasHeader: + header = None + header.version_mayor = xpsSettings.versionMayor + header.version_minor = xpsSettings.versionMinor + xpsData = xps_types.XpsData(header=header, bones=xpsBones, + meshes=xpsMeshes) + + saveXpsFile(xpsSettings.filename, xpsData) + +def exportSelected(objects): + meshes = [] + armatures = [] + armature = None + for object in objects: + if object.type == 'ARMATURE': + armatures.append(object) + elif object.type == 'MESH': + meshes.append(object) + armature = object.find_armature() or armature + return armature, meshes + +def exportArmature(armature): + xpsBones = [] + if armature: + bones = armature.data.bones + print('Exporting Armature', len(bones), 'Bones') + activebones = bones + for bone in activebones: + objectMatrix = armature.matrix_local + id = bones.find(bone.name) + name = bone.name + co = coordTransform(objectMatrix @ bone.head_local.xyz) + parentId = None + if bone.parent: + parentId = bones.find(bone.parent.name) + xpsBone = xps_types.XpsBone(id, name, co, parentId) + xpsBones.append(xpsBone) + if not xpsBones: + xpsBone = xps_types.XpsBone(0, 'root', (0, 0, 0), -1) + xpsBones.append(xpsBone) + + return xpsBones + +def exportMeshes(selectedArmature, selectedMeshes): + xpsMeshes = [] + for mesh in selectedMeshes: + print('Exporting Mesh:', mesh.name) + meshName = makeNamesFromMesh(mesh) + meshTextures = getXpsMatTextures(mesh) + meshVerts, meshFaces = getXpsVertices(selectedArmature, mesh) + meshUvCount = len(mesh.data.uv_layers) + + materialsCount = len(mesh.data.materials) + if (materialsCount > 0): + for idx in range(materialsCount): + xpsMesh = xps_types.XpsMesh(meshName[idx], meshTextures[idx], + meshVerts[idx], meshFaces[idx], + meshUvCount) + xpsMeshes.append(xpsMesh) + else: + emptyTextures = [] + xpsMesh = xps_types.XpsMesh(meshName[0], emptyTextures, + meshVerts[0], meshFaces[0], + meshUvCount) + xpsMeshes.append(xpsMesh) + + return xpsMeshes + +def makeNamesFromMaterials(mesh): + separatedMeshNames = [] + materials = mesh.data.materials + for material in materials: + separatedMeshNames.append(material.name) + return separatedMeshNames + +def makeNamesFromMesh(mesh): + meshFullName = mesh.name + renderType = xps_material.makeRenderType(meshFullName) + meshName = renderType.meshName + + separatedMeshNames = [] + separatedMeshNames.append(xps_material.makeRenderTypeName(renderType)) + + materialsCount = len(mesh.data.materials) + for mat_idx in range(1, materialsCount): + partName = '{0}.material{1:02d}'.format(meshName, mat_idx) + renderType.meshName = partName + fullName = xps_material.makeRenderTypeName(renderType) + separatedMeshNames.append(fullName) + return separatedMeshNames + +def addTexture(tex_dic, texture_type, texture): + if texture is not None: + tex_dic[texture_type] = texture + +def getTextureFilename(texture): + textureFile = None + if texture and texture.image is not None: + texFilePath = texture.image.filepath + absFilePath = bpy.path.abspath(texFilePath) + texturePath, textureFile = os.path.split(absFilePath) + return textureFile + +def makeXpsTexture(mesh, material): + active_uv = mesh.data.uv_layers.active + active_uv_index = mesh.data.uv_layers.active_index + xpsShaderWrapper = XPSShaderWrapper(material, use_nodes=True) + + tex_dic = {} + texture = getTextureFilename(xpsShaderWrapper.diffuse_texture) + addTexture(tex_dic, xps_material.TextureType.DIFFUSE, texture) + texture = getTextureFilename(xpsShaderWrapper.lightmap_texture) + addTexture(tex_dic, xps_material.TextureType.LIGHT, texture) + texture = getTextureFilename(xpsShaderWrapper.normalmap_texture) + addTexture(tex_dic, xps_material.TextureType.BUMP, texture) + texture = getTextureFilename(xpsShaderWrapper.normal_mask_texture) + addTexture(tex_dic, xps_material.TextureType.MASK, texture) + texture = getTextureFilename(xpsShaderWrapper.microbump1_texture) + addTexture(tex_dic, xps_material.TextureType.BUMP1, texture) + texture = getTextureFilename(xpsShaderWrapper.microbump2_texture) + addTexture(tex_dic, xps_material.TextureType.BUMP2, texture) + texture = getTextureFilename(xpsShaderWrapper.specular_texture) + addTexture(tex_dic, xps_material.TextureType.SPECULAR, texture) + texture = getTextureFilename(xpsShaderWrapper.environment_texture) + addTexture(tex_dic, xps_material.TextureType.ENVIRONMENT, texture) + texture = getTextureFilename(xpsShaderWrapper.emission_texture) + addTexture(tex_dic, xps_material.TextureType.EMISSION, texture) + + renderType = xps_material.makeRenderType(mesh.name) + renderGroup = xps_material.RenderGroup(renderType) + rgTextures = renderGroup.rgTexType + + texutre_list = [] + for tex_type in rgTextures: + texture = tex_dic.get(tex_type) + if texture: + texutre_list.append(texture) + + xpsTextures = [] + for id, texture in enumerate(texutre_list): + xpsTexture = xps_types.XpsTexture(id, texture, 0) + xpsTextures.append(xpsTexture) + + return xpsTextures + +def getTextures(mesh, material): + xpsTextures = makeXpsTexture(mesh, material) + return xpsTextures + +def getXpsMatTextures(mesh): + xpsMatTextures = [] + for material_slot in mesh.material_slots: + material = material_slot.material + xpsTextures = getTextures(mesh, material) + xpsMatTextures.append(xpsTextures) + return xpsMatTextures + +def generateVertexKey(vertex, uvCoord, seamSideId): + key = '{}{}{}{}'.format(vertex.co, vertex.normal, uvCoord, seamSideId) + return key + +def getXpsVertices(selectedArmature, mesh): + mapMatVertexKeys = [] + xpsMatVertices = [] + xpsMatFaces = [] + + exportVertColors = xpsSettings.vColors + armature = mesh.find_armature() + objectMatrix = mesh.matrix_world + rotQuaternion = mesh.matrix_world.to_quaternion() + + verts_nor = xpsSettings.exportNormals + + mesh.data.calc_tangents() + mesh.data.calc_loop_triangles() + mesh.data.update(calc_edges=True) + + matCount = len(mesh.data.materials) + if (matCount > 0): + for idx in range(matCount): + xpsMatVertices.append([]) + xpsMatFaces.append([]) + mapMatVertexKeys.append({}) + else: + xpsMatVertices.append([]) + xpsMatFaces.append([]) + mapMatVertexKeys.append({}) + + meshVerts = mesh.data.vertices + meshEdges = mesh.data.edges + hasSeams = any(edge.use_seam for edge in meshEdges) + tessFaces = mesh.data.loop_triangles[:] + tessface_uv_tex = mesh.data.uv_layers + tessface_vert_color = mesh.data.vertex_colors + meshEdgeKeys = mesh.data.edge_keys + + vertEdges = [[] for x in range(len(meshVerts))] + tessEdgeFaces = {} + + preserveSeams = xpsSettings.preserveSeams + if (preserveSeams and hasSeams): + tessEdgeCount = Counter(tessEdgeKey for tessFace in tessFaces for tessEdgeKey in tessFace.edge_keys) + + for tessface in tessFaces: + for tessEdgeKey in tessface.edge_keys: + if tessEdgeFaces.get(tessEdgeKey) is None: + tessEdgeFaces[tessEdgeKey] = [] + tessEdgeFaces[tessEdgeKey].append(tessface.index) + + edgeKeyIndex = {val: index for index, val in enumerate(meshEdgeKeys)} + + for key in meshEdgeKeys: + meshEdge = meshEdges[edgeKeyIndex[key]] + vert1, vert2 = key + vertEdges[vert1].append(meshEdge) + vertEdges[vert2].append(meshEdge) + + faceEdges = [] + faceSeams = [] + + for face in tessFaces: + material_index = face.material_index + xpsVertices = xpsMatVertices[material_index] + xpsFaces = xpsMatFaces[material_index] + mapVertexKeys = mapMatVertexKeys[material_index] + faceVerts = [] + seamSideId = '' + faceVertIndices = face.vertices[:] + faceUvIndices = face.loops[:] + + for vertEnum, vertIndex in enumerate(faceVertIndices): + vertex = meshVerts[vertIndex] + + if (preserveSeams and hasSeams): + connectedFaces = set() + faceEdges = vertEdges[vertIndex] + faceSeams = [edge for edge in faceEdges if edge.use_seam] + + if (len(faceSeams) >= 1): + vertIsBorder = any(tessEdgeCount[edge.index] != 2 for edge in faceEdges) + if (len(faceSeams) > 1) or (len(faceSeams) == 1 and vertIsBorder): + + oldFacesList = set() + connectedFaces = set([face]) + while oldFacesList != connectedFaces: + + oldFacesList = connectedFaces + + allEdgeKeys = set(connEdgeKey for connface in connectedFaces for connEdgeKey in connface.edge_keys) + connEdgesKeys = [edge.key for edge in faceEdges] + connEdgesNotSeamsKeys = [seam.key for seam in faceSeams] + + connectedEdges = allEdgeKeys.intersection(connEdgesKeys).difference(connEdgesNotSeamsKeys) + connectedFaces = set(tessFaces[connFace] for connEdge in connectedEdges for connFace in tessEdgeFaces[connEdge]) + + connectedFaces.add(face) + + faceIndices = [face.index for face in connectedFaces] + seamSideId = '|'.join(str(faceIdx) for faceIdx in sorted(faceIndices)) + + uvs = getUvs(tessface_uv_tex, faceUvIndices[vertEnum]) + vertexKey = generateVertexKey(vertex, uvs, seamSideId) + + if vertexKey in mapVertexKeys: + vertexID = mapVertexKeys[vertexKey] + else: + vCoord = coordTransform(objectMatrix @ vertex.co) + if verts_nor: + normal = vertex.normal + else: + normal = vertex.normal + norm = coordTransform(rotQuaternion @ normal) + vColor = getVertexColor(exportVertColors, tessface_vert_color, faceUvIndices[vertEnum]) + boneId, boneWeight = getBoneWeights(mesh, vertex, armature) + + boneWeights = [] + for idx in range(len(boneId)): + boneWeights.append(xps_types.BoneWeight(boneId[idx], + boneWeight[idx])) + vertexID = len(xpsVertices) + mapVertexKeys[vertexKey] = vertexID + xpsVertex = xps_types.XpsVertex(vertexID, vCoord, norm, vColor, uvs, + boneWeights) + xpsVertices.append(xpsVertex) + faceVerts.append(vertexID) + + meshFaces = getXpsFace(faceVerts) + xpsFaces.extend(meshFaces) + + return xpsMatVertices, xpsMatFaces + +def getUvs(tessface_uv_tex, uvIndex): + uvs = [] + for tessface_uv_layer in tessface_uv_tex: + uvCoord = tessface_uv_layer.data[uvIndex].uv + uvCoord = uvTransform(uvCoord) + uvs.append(uvCoord) + return uvs + +def getVertexColor(exportVertColors, tessface_vert_color, vColorIndex): + vColor = None + if exportVertColors and tessface_vert_color: + vColor = tessface_vert_color[0].data[vColorIndex].color[:] + else: + vColor = [1, 1, 1, 1] + + vColor = list(map(rangeFloatToByte, vColor)) + return vColor + +def getBoneWeights(mesh, vertice, armature): + boneId = [] + boneWeight = [] + if armature: + for vertGroup in vertice.groups: + groupIdx = vertGroup.group + boneName = mesh.vertex_groups[groupIdx].name + boneIdx = armature.data.bones.find(boneName) + weight = vertGroup.weight + if boneIdx < 0: + boneIdx = 0 + weight = 0 + boneId.append(boneIdx) + boneWeight.append(weight) + boneId = fillArray(boneId, 4, 0) + boneWeight = fillArray(boneWeight, 4, 0) + return boneId, boneWeight + +def getXpsFace(faceVerts): + xpsFaces = [] + + if len(faceVerts) == 3: + xpsFaces.append(faceTransform(faceVerts)) + elif len(faceVerts) == 4: + v1, v2, v3, v4 = faceVerts + xpsFaces.append(faceTransform((v1, v2, v3))) + xpsFaces.append(faceTransform((v3, v4, v1))) + + return xpsFaces + +def boneDictGenerate(filepath, armatureObj): + boneNames = sorted([import_xnalara_pose.renameBoneToXps(name) for name in armatureObj.data.bones.keys()]) + boneDictList = '\n'.join(';'.join((name,) * 2) for name in boneNames) + write_ascii_xps.writeBoneDict(filepath, boneDictList) \ No newline at end of file diff --git a/read_ascii_xps.py b/read_ascii_xps.py index 12bcfb4..1e0163d 100644 --- a/read_ascii_xps.py +++ b/read_ascii_xps.py @@ -10,7 +10,11 @@ def readUvVert(file): line = ascii_ops.readline(file) + if not line: + return [0.0, 0.0] values = ascii_ops.splitValues(line) + if len(values) < 2: + return [0.0, 0.0] x = (ascii_ops.getFloat(values[0])) # X pos y = (ascii_ops.getFloat(values[1])) # Y pos coords = [x, y] @@ -19,7 +23,11 @@ def readUvVert(file): def readXYZ(file): line = ascii_ops.readline(file) + if not line: + return [0.0, 0.0, 0.0] values = ascii_ops.splitValues(line) + if len(values) < 3: + return [0.0, 0.0, 0.0] x = (ascii_ops.getFloat(values[0])) # X pos y = (ascii_ops.getFloat(values[1])) # Y pos z = (ascii_ops.getFloat(values[2])) # Z pos @@ -35,6 +43,8 @@ def fillArray(array, minLen, value): def read4Float(file): line = ascii_ops.readline(file) + if not line: + return [0.0, 0.0, 0.0, 0.0] values = ascii_ops.splitValues(line) values = fillArray(values, 4, 0) x = (ascii_ops.getFloat(values[0])) @@ -47,6 +57,8 @@ def read4Float(file): def readBoneWeight(file): line = ascii_ops.readline(file) + if not line: + return [0.0, 0.0, 0.0, 0.0] values = ascii_ops.splitValues(line) values = fillArray(values, 4, 0) weights = [ascii_ops.getFloat(val) for val in values] @@ -55,6 +67,8 @@ def readBoneWeight(file): def readBoneId(file): line = ascii_ops.readline(file) + if not line: + return [0, 0, 0, 0] values = ascii_ops.splitValues(line) values = fillArray(values, 4, 0) ids = [ascii_ops.getInt(val) for val in values] @@ -63,6 +77,8 @@ def readBoneId(file): def read4Int(file): line = ascii_ops.readline(file) + if not line: + return [255, 255, 255, 255] values = ascii_ops.splitValues(line) values = fillArray(values, 4, 0) r = ascii_ops.getInt(values[0]) @@ -75,7 +91,11 @@ def read4Int(file): def readTriIdxs(file): line = ascii_ops.readline(file) + if not line: + return [0, 0, 0] values = ascii_ops.splitValues(line) + if len(values) < 3: + return [0, 0, 0] face1 = ascii_ops.getInt(values[0]) face2 = ascii_ops.getInt(values[1]) face3 = ascii_ops.getInt(values[2]) @@ -85,167 +105,256 @@ def readTriIdxs(file): def readBones(file): bones = [] - # Bone Count - boneCount = ascii_ops.readInt(file) - for boneId in range(boneCount): - boneName = ascii_ops.readString(file) - parentId = ascii_ops.readInt(file) - coords = readXYZ(file) - - xpsBone = xps_types.XpsBone(boneId, boneName, coords, parentId) - bones.append(xpsBone) + try: + # Bone Count + boneCount = ascii_ops.readInt(file) + if boneCount is None or boneCount < 0: + return bones + + for boneId in range(boneCount): + boneName = ascii_ops.readString(file) + if boneName is None: + boneName = f"Bone_{boneId}" + parentId = ascii_ops.readInt(file) + if parentId is None: + parentId = -1 + coords = readXYZ(file) + + xpsBone = xps_types.XpsBone(boneId, boneName, coords, parentId) + bones.append(xpsBone) + except Exception as e: + print(f"Error reading bones: {e}") return bones def readMeshes(file, hasBones): meshes = [] - meshCount = ascii_ops.readInt(file) - - for meshId in range(meshCount): - # Name - meshName = ascii_ops.readString(file) - if not meshName: - meshName = 'xxx' - # print('Mesh Name:', meshName) - # uv Count - uvLayerCount = ascii_ops.readInt(file) - # Textures - textures = [] - textureCount = ascii_ops.readInt(file) - for texId in range(textureCount): - textureFile = ntpath.basename(ascii_ops.readString(file)) - # print('Texture file', textureFile) - uvLayerId = ascii_ops.readInt(file) - - xpsTexture = xps_types.XpsTexture(texId, textureFile, uvLayerId) - textures.append(xpsTexture) - - # Vertices - vertex = [] - vertexCount = ascii_ops.readInt(file) - for vertexId in range(vertexCount): - coord = readXYZ(file) - normal = readXYZ(file) - vertexColor = read4Int(file) - - uvs = [] - for uvLayerId in range(uvLayerCount): - uvVert = readUvVert(file) - uvs.append(uvVert) - # if ???? - # tangent???? - # tangent = read4float(file) - - boneWeights = [] - if hasBones: - # if cero bones dont have weights to read - boneIdx = readBoneId(file) - boneWeight = readBoneWeight(file) - - for idx in range(len(boneIdx)): - boneWeights.append( - xps_types.BoneWeight(boneIdx[idx], boneWeight[idx])) - xpsVertex = xps_types.XpsVertex( - vertexId, coord, normal, vertexColor, uvs, boneWeights) - vertex.append(xpsVertex) - - # Faces - faces = [] - triCount = ascii_ops.readInt(file) - for i in range(triCount): - triIdxs = readTriIdxs(file) - faces.append(triIdxs) - xpsMesh = xps_types.XpsMesh( - meshName, textures, vertex, faces, uvLayerCount) - meshes.append(xpsMesh) + try: + meshCount = ascii_ops.readInt(file) + if meshCount is None or meshCount < 0: + return meshes + + for meshId in range(meshCount): + # Name + meshName = ascii_ops.readString(file) + if not meshName: + meshName = f'Mesh_{meshId}' + # print('Mesh Name:', meshName) + + # uv Count + uvLayerCount = ascii_ops.readInt(file) + if uvLayerCount is None or uvLayerCount < 0: + uvLayerCount = 0 + + # Textures + textures = [] + textureCount = ascii_ops.readInt(file) + if textureCount is None or textureCount < 0: + textureCount = 0 + + for texId in range(textureCount): + try: + textureFile = ntpath.basename(ascii_ops.readString(file)) + if not textureFile: + textureFile = f"texture_{texId}.dds" + # print('Texture file', textureFile) + uvLayerId = ascii_ops.readInt(file) + if uvLayerId is None: + uvLayerId = 0 + + xpsTexture = xps_types.XpsTexture(texId, textureFile, uvLayerId) + textures.append(xpsTexture) + except Exception as e: + print(f"Error reading texture {texId}: {e}") + continue + + # Vertices + vertex = [] + vertexCount = ascii_ops.readInt(file) + if vertexCount is None or vertexCount < 0: + vertexCount = 0 + + for vertexId in range(vertexCount): + try: + coord = readXYZ(file) + normal = readXYZ(file) + vertexColor = read4Int(file) + + uvs = [] + for uvLayerId in range(uvLayerCount): + uvVert = readUvVert(file) + uvs.append(uvVert) + # if ???? + # tangent???? + # tangent = read4float(file) + + boneWeights = [] + if hasBones: + # if cero bones dont have weights to read + boneIdx = readBoneId(file) + boneWeight = readBoneWeight(file) + + for idx in range(len(boneIdx)): + boneWeights.append( + xps_types.BoneWeight(boneIdx[idx], boneWeight[idx])) + xpsVertex = xps_types.XpsVertex( + vertexId, coord, normal, vertexColor, uvs, boneWeights) + vertex.append(xpsVertex) + except Exception as e: + print(f"Error reading vertex {vertexId}: {e}") + continue + + # Faces + faces = [] + triCount = ascii_ops.readInt(file) + if triCount is None or triCount < 0: + triCount = 0 + + for i in range(triCount): + try: + triIdxs = readTriIdxs(file) + faces.append(triIdxs) + except Exception as e: + print(f"Error reading face {i}: {e}") + continue + + xpsMesh = xps_types.XpsMesh( + meshName, textures, vertex, faces, uvLayerCount) + meshes.append(xpsMesh) + except Exception as e: + print(f"Error reading meshes: {e}") return meshes def readPoseFile(file): - return file.read() + try: + return file.read() + except Exception as e: + print(f"Error reading pose file: {e}") + return "" def poseData(string): poseData = {} - poseList = string.split('\n') - for bonePose in poseList: - if bonePose: - pose = bonePose.split(':') - - boneName = pose[0] - dataList = fillArray(pose[1].split(), 9, 1) - rotDelta = Vector(( - ascii_ops.getFloat(dataList[0]), - ascii_ops.getFloat(dataList[1]), - ascii_ops.getFloat(dataList[2]))) - coordDelta = Vector(( - ascii_ops.getFloat(dataList[3]), - ascii_ops.getFloat(dataList[4]), - ascii_ops.getFloat(dataList[5]))) - scale = Vector(( - ascii_ops.getFloat(dataList[6]), - ascii_ops.getFloat(dataList[7]), - ascii_ops.getFloat(dataList[8]))) - - bonePose = xps_types.XpsBonePose( - boneName, coordDelta, rotDelta, scale) - poseData[boneName] = bonePose + try: + poseList = string.split('\n') + for bonePose in poseList: + if bonePose and ':' in bonePose: + pose = bonePose.split(':') + if len(pose) < 2: + continue + + boneName = pose[0].strip() + if not boneName: + continue + + dataList = fillArray(pose[1].split(), 9, 1) + try: + rotDelta = Vector(( + ascii_ops.getFloat(dataList[0]), + ascii_ops.getFloat(dataList[1]), + ascii_ops.getFloat(dataList[2]))) + coordDelta = Vector(( + ascii_ops.getFloat(dataList[3]), + ascii_ops.getFloat(dataList[4]), + ascii_ops.getFloat(dataList[5]))) + scale = Vector(( + ascii_ops.getFloat(dataList[6]), + ascii_ops.getFloat(dataList[7]), + ascii_ops.getFloat(dataList[8]))) + + bonePose = xps_types.XpsBonePose( + boneName, coordDelta, rotDelta, scale) + poseData[boneName] = bonePose + except Exception as e: + print(f"Error parsing pose data for bone {boneName}: {e}") + continue + except Exception as e: + print(f"Error processing pose data: {e}") return poseData def boneDictData(string): boneDictRename = {} boneDictRestore = {} - poseList = string.split('\n') - for bonePose in poseList: - if bonePose: - pose = bonePose.split(';') - if len(pose) == 2: - oldName, newName = pose - boneDictRename[oldName] = newName - boneDictRestore[newName] = oldName + try: + poseList = string.split('\n') + for bonePose in poseList: + if bonePose and ';' in bonePose: + pose = bonePose.split(';') + if len(pose) == 2: + oldName, newName = pose + oldName = oldName.strip() + newName = newName.strip() + if oldName and newName: + boneDictRename[oldName] = newName + boneDictRestore[newName] = oldName + except Exception as e: + print(f"Error processing bone dictionary: {e}") return boneDictRename, boneDictRestore def readIoStream(filename): - with open(filename, "r", encoding=xps_const.ENCODING_READ) as a_file: - ioStream = io.StringIO(a_file.read()) - return ioStream + try: + with open(filename, "r", encoding=xps_const.ENCODING_READ) as a_file: + content = a_file.read() + return io.StringIO(content) + except UnicodeDecodeError: + # Try with different encoding if default fails + try: + with open(filename, "r", encoding='utf-8') as a_file: + content = a_file.read() + return io.StringIO(content) + except Exception as e: + print(f"Error reading file {filename}: {e}") + return io.StringIO("") + except Exception as e: + print(f"Error opening file {filename}: {e}") + return io.StringIO("") def readXpsModel(filename): - ioStream = readIoStream(filename) - # print('Reading Header') - # xpsHeader = readHeader(ioStream) - print('Reading Bones') - bones = readBones(ioStream) - hasBones = bool(bones) - print('Reading Meshes') - meshes = readMeshes(ioStream, hasBones) - xpsModelData = xps_types.XpsData(bones=bones, meshes=meshes) - return xpsModelData + try: + ioStream = readIoStream(filename) + if not ioStream: + return xps_types.XpsData(bones=[], meshes=[]) + + # print('Reading Header') + # xpsHeader = readHeader(ioStream) + print('Reading Bones') + bones = readBones(ioStream) + hasBones = bool(bones) + print('Reading Meshes') + meshes = readMeshes(ioStream, hasBones) + xpsModelData = xps_types.XpsData(bones=bones, meshes=meshes) + return xpsModelData + except Exception as e: + print(f"Error reading XPS model {filename}: {e}") + return xps_types.XpsData(bones=[], meshes=[]) def readXpsPose(filename): - ioStream = readIoStream(filename) - # print('Import Pose') - poseString = readPoseFile(ioStream) - bonesPose = poseData(poseString) - return bonesPose + try: + ioStream = readIoStream(filename) + if not ioStream: + return {} + # print('Import Pose') + poseString = readPoseFile(ioStream) + bonesPose = poseData(poseString) + return bonesPose + except Exception as e: + print(f"Error reading XPS pose {filename}: {e}") + return {} def readBoneDict(filename): - ioStream = readIoStream(filename) - boneDictString = readPoseFile(ioStream) - boneDictRename, boneDictRestore = boneDictData(boneDictString) - return boneDictRename, boneDictRestore - - -if __name__ == "__main__": - readModelfilename = r'G:\3DModeling\XNALara\XNALara_XPS\data\TESTING2\Tekken\Tekken - Lili Bride\generic_item.mesh.ascii' - readPosefilename = r'G:\3DModeling\XNALara\XNALara_XPS\data\TESTING2\Tekken\Tekken - Lili Bride\Lili 1.pose' - - print('----READ START----') - xpsData = readXpsModel(readModelfilename) - xpsData = readXpsPose(readPosefilename) - print('----READ END----') + try: + ioStream = readIoStream(filename) + if not ioStream: + return {}, {} + boneDictString = readPoseFile(ioStream) + boneDictRename, boneDictRestore = boneDictData(boneDictString) + return boneDictRename, boneDictRestore + except Exception as e: + print(f"Error reading bone dictionary {filename}: {e}") + return {}, {} diff --git a/read_bin_xps.py b/read_bin_xps.py index 2883059..7f0f146 100644 --- a/read_bin_xps.py +++ b/read_bin_xps.py @@ -70,184 +70,245 @@ def printNormalMapSwizzel(tangentSpaceRed, tangentSpaceGreen, tangentSpaceBlue): def readFilesString(file): - lengthByte2 = 0 + try: + lengthByte2 = 0 - lengthByte1 = bin_ops.readByte(file) + lengthByte1 = bin_ops.readByte(file) + if lengthByte1 is None: + return "" - if (lengthByte1 >= xps_const.LIMIT): - lengthByte2 = bin_ops.readByte(file) - length = (lengthByte1 % xps_const.LIMIT) + (lengthByte2 * xps_const.LIMIT) + if (lengthByte1 >= xps_const.LIMIT): + lengthByte2 = bin_ops.readByte(file) + if lengthByte2 is None: + return "" + length = (lengthByte1 % xps_const.LIMIT) + (lengthByte2 * xps_const.LIMIT) - string = bin_ops.readString(file, length) - return string + string = bin_ops.readString(file, length) + return string or "" + except Exception as e: + print(f"Error reading string: {e}") + return "" def readVertexColor(file): - r = bin_ops.readByte(file) - g = bin_ops.readByte(file) - b = bin_ops.readByte(file) - a = bin_ops.readByte(file) - vertexColor = [r, g, b, a] - return vertexColor + try: + r = bin_ops.readByte(file) or 255 + g = bin_ops.readByte(file) or 255 + b = bin_ops.readByte(file) or 255 + a = bin_ops.readByte(file) or 255 + vertexColor = [r, g, b, a] + return vertexColor + except Exception as e: + print(f"Error reading vertex color: {e}") + return [255, 255, 255, 255] def readUvVert(file): - x = bin_ops.readSingle(file) # X pos - y = bin_ops.readSingle(file) # Y pos - coords = [x, y] - return coords + try: + x = bin_ops.readSingle(file) or 0.0 # X pos + y = bin_ops.readSingle(file) or 0.0 # Y pos + coords = [x, y] + return coords + except Exception as e: + print(f"Error reading UV vertex: {e}") + return [0.0, 0.0] def readXYZ(file): - x = bin_ops.readSingle(file) # X pos - y = bin_ops.readSingle(file) # Y pos - z = bin_ops.readSingle(file) # Z pos - coords = [x, y, z] - return coords + try: + x = bin_ops.readSingle(file) or 0.0 # X pos + y = bin_ops.readSingle(file) or 0.0 # Y pos + z = bin_ops.readSingle(file) or 0.0 # Z pos + coords = [x, y, z] + return coords + except Exception as e: + print(f"Error reading XYZ coordinates: {e}") + return [0.0, 0.0, 0.0] def read4Float(file): - x = bin_ops.readSingle(file) - y = bin_ops.readSingle(file) - z = bin_ops.readSingle(file) - w = bin_ops.readSingle(file) - coords = [x, y, z, w] - return coords + try: + x = bin_ops.readSingle(file) or 0.0 + y = bin_ops.readSingle(file) or 0.0 + z = bin_ops.readSingle(file) or 0.0 + w = bin_ops.readSingle(file) or 0.0 + coords = [x, y, z, w] + return coords + except Exception as e: + print(f"Error reading 4 floats: {e}") + return [0.0, 0.0, 0.0, 0.0] def read4Int16(file): - r = bin_ops.readInt16(file) - g = bin_ops.readInt16(file) - b = bin_ops.readInt16(file) - a = bin_ops.readInt16(file) - vertexColor = [r, g, b, a] - return vertexColor + try: + r = bin_ops.readInt16(file) or 0 + g = bin_ops.readInt16(file) or 0 + b = bin_ops.readInt16(file) or 0 + a = bin_ops.readInt16(file) or 0 + vertexColor = [r, g, b, a] + return vertexColor + except Exception as e: + print(f"Error reading 4 int16: {e}") + return [0, 0, 0, 0] def readTriIdxs(file): - face1 = bin_ops.readUInt32(file) - face2 = bin_ops.readUInt32(file) - face3 = bin_ops.readUInt32(file) - faceLoop = [face1, face2, face3] - return faceLoop + try: + face1 = bin_ops.readUInt32(file) or 0 + face2 = bin_ops.readUInt32(file) or 0 + face3 = bin_ops.readUInt32(file) or 0 + faceLoop = [face1, face2, face3] + return faceLoop + except Exception as e: + print(f"Error reading triangle indices: {e}") + return [0, 0, 0] def readHeader(file): xpsHeader = xps_types.XpsHeader() flags = flagsDefault() - # MagicNumber - magic_number = bin_ops.readUInt32(file) - # XPS Version - version_mayor = bin_ops.readUInt16(file) - version_minor = bin_ops.readUInt16(file) - # XNAaral Name - xna_aral = readFilesString(file) - # Settings Length - settingsLen = bin_ops.readUInt32(file) - # MachineName - machineName = readFilesString(file) - # UserName - userName = readFilesString(file) - # File-->File - filesString = readFilesString(file) - xpsPoseData = None - - # print('*'*80) - hasTangent = bin_ops.hasTangentVersion(version_mayor, version_minor) - if (hasTangent): - # print('OLD Format') - settingsStream = io.BytesIO(file.read(settingsLen * 4)) - else: - # print('NEW Format') - valuesRead = 0 - hash = bin_ops.readUInt32(file) - valuesRead += 1 * 4 - items = bin_ops.readUInt32(file) - valuesRead += 1 * 4 - # print('hash', hash) - # print('items', items) - for i in range(items): - # print('valuesRead', valuesRead) - optType = bin_ops.readUInt32(file) - valuesRead += 1 * 4 - optcount = bin_ops.readUInt32(file) + try: + # MagicNumber + magic_number = bin_ops.readUInt32(file) + if magic_number != xps_const.MAGIC_NUMBER: + print(f"Warning: Invalid magic number: {magic_number}") + return None + + # XPS Version + version_mayor = bin_ops.readUInt16(file) or 0 + version_minor = bin_ops.readUInt16(file) or 0 + # XNAaral Name + xna_aral = readFilesString(file) + # Settings Length + settingsLen = bin_ops.readUInt32(file) or 0 + # MachineName + machineName = readFilesString(file) + # UserName + userName = readFilesString(file) + # File-->File + filesString = readFilesString(file) + xpsPoseData = None + + # print('*'*80) + hasTangent = bin_ops.hasTangentVersion(version_mayor, version_minor) + if (hasTangent): + # print('OLD Format') + settingsStream = io.BytesIO(file.read(settingsLen * 4)) + else: + # print('NEW Format') + valuesRead = 0 + hash = bin_ops.readUInt32(file) or 0 valuesRead += 1 * 4 - optInfo = bin_ops.readUInt32(file) + items = bin_ops.readUInt32(file) or 0 valuesRead += 1 * 4 - - # print('------') - # print('count',i) - # print('optType',optType) - # print('optcount',optcount) - # print('optInfo',optInfo) - - if (optType == 0): - # print('Read None') - readNone(file, optcount) - valuesRead += optcount * 2 - elif (optType == 1): - # print('Read Pose') - xpsPoseData = readDefaultPose(file, optcount, optInfo) - readCount = bin_ops.roundToMultiple(optcount, xps_const.ROUND_MULTIPLE) - valuesRead += readCount - elif (optType == 2): - # print('Read Flags') - flags = readFlags(file, optcount) - valuesRead += optcount * 2 * 4 - else: - # print('Read Waste') - loopStart = valuesRead // 4 - loopFinish = settingsLen - # print (loopStart, loopFinish) - for j in range(loopStart, loopFinish): - # print('waste',j - loopStart) - waste = bin_ops.readUInt32(file) - - xpsHeader.magic_number = magic_number - xpsHeader.version_mayor = version_mayor - xpsHeader.version_minor = version_minor - xpsHeader.xna_aral = xna_aral - xpsHeader.settingsLen = settingsLen - xpsHeader.machine = machineName - xpsHeader.user = userName - xpsHeader.files = filesString - xpsHeader.pose = xpsPoseData - xpsHeader.flags = flags - return xpsHeader + # print('hash', hash) + # print('items', items) + for i in range(items): + # print('valuesRead', valuesRead) + optType = bin_ops.readUInt32(file) or 0 + valuesRead += 1 * 4 + optcount = bin_ops.readUInt32(file) or 0 + valuesRead += 1 * 4 + optInfo = bin_ops.readUInt32(file) or 0 + valuesRead += 1 * 4 + + # print('------') + # print('count',i) + # print('optType',optType) + # print('optcount',optcount) + # print('optInfo',optInfo) + + if (optType == 0): + # print('Read None') + readNone(file, optcount) + valuesRead += optcount * 2 + elif (optType == 1): + # print('Read Pose') + xpsPoseData = readDefaultPose(file, optcount, optInfo) + readCount = bin_ops.roundToMultiple(optcount, xps_const.ROUND_MULTIPLE) + valuesRead += readCount + elif (optType == 2): + # print('Read Flags') + flags = readFlags(file, optcount) + valuesRead += optcount * 2 * 4 + else: + # print('Read Waste') + loopStart = valuesRead // 4 + loopFinish = settingsLen + # print (loopStart, loopFinish) + for j in range(loopStart, loopFinish): + # print('waste',j - loopStart) + waste = bin_ops.readUInt32(file) + + xpsHeader.magic_number = magic_number + xpsHeader.version_mayor = version_mayor + xpsHeader.version_minor = version_minor + xpsHeader.xna_aral = xna_aral + xpsHeader.settingsLen = settingsLen + xpsHeader.machine = machineName + xpsHeader.user = userName + xpsHeader.files = filesString + xpsHeader.pose = xpsPoseData + xpsHeader.flags = flags + return xpsHeader + except Exception as e: + print(f"Error reading header: {e}") + return None def findHeader(file): header = None - # Check for MAGIC_NUMBER - number = bin_ops.readUInt32(file) - file.seek(0) + try: + # Check for MAGIC_NUMBER + number = bin_ops.readUInt32(file) + file.seek(0) - if (number == xps_const.MAGIC_NUMBER): - print('Header Found') - header = readHeader(file) + if (number == xps_const.MAGIC_NUMBER): + print('Header Found') + header = readHeader(file) + else: + print(f"Warning: Invalid magic number, expected {xps_const.MAGIC_NUMBER}, got {number}") - # logHeader(header) - return header + # logHeader(header) + return header + except Exception as e: + print(f"Error finding header: {e}") + return None def readNone(file, optcount): - for i in range(optcount): - waste = bin_ops.readUInt32(file) + try: + for i in range(optcount): + waste = bin_ops.readUInt32(file) + except Exception as e: + print(f"Error reading none data: {e}") def readFlags(file, optcount): - flags = {} - for i in range(optcount): - flag = bin_ops.readUInt32(file) - value = bin_ops.readUInt32(file) - flags[flagName(flag)] = flagValue(flag, value) - printNormalMapSwizzel(flags[flagName(3)], flags[flagName(4)], flags[flagName(5)]) - return flags + flags = flagsDefault() + try: + for i in range(optcount): + flag = bin_ops.readUInt32(file) or 0 + value = bin_ops.readUInt32(file) or 0 + flag_name = flagName(flag) + if flag_name in flags: + flags[flag_name] = flagValue(flag, value) + printNormalMapSwizzel(flags.get(xps_const.TANGENT_SPACE_RED, 0), + flags.get(xps_const.TANGENT_SPACE_GREEN, 1), + flags.get(xps_const.TANGENT_SPACE_BLUE, 0)) + return flags + except Exception as e: + print(f"Error reading flags: {e}") + return flagsDefault() def logHeader(xpsHeader): + if not xpsHeader: + print("No header found") + return + print("MAGIX:", xpsHeader.magic_number) print('VER MAYOR:', xpsHeader.version_mayor) print('VER MINOR:', xpsHeader.version_minor) @@ -262,144 +323,185 @@ def logHeader(xpsHeader): def readBones(file, header): bones = [] - # Bone Count - boneCount = bin_ops.readUInt32(file) + try: + # Bone Count + boneCount = bin_ops.readUInt32(file) or 0 - for boneId in range(boneCount): - boneName = readFilesString(file) - parentId = bin_ops.readInt16(file) - coords = readXYZ(file) + for boneId in range(boneCount): + boneName = readFilesString(file) + if not boneName: + boneName = f"Bone_{boneId}" + parentId = bin_ops.readInt16(file) or -1 + coords = readXYZ(file) - xpsBone = xps_types.XpsBone(boneId, boneName, coords, parentId) - bones.append(xpsBone) - return bones + xpsBone = xps_types.XpsBone(boneId, boneName, coords, parentId) + bones.append(xpsBone) + return bones + except Exception as e: + print(f"Error reading bones: {e}") + return [] def readMeshes(file, xpsHeader, hasBones): meshes = [] - meshCount = bin_ops.readUInt32(file) - - hasHeader = bool(xpsHeader) - - verMayor = xpsHeader.version_mayor if hasHeader else 0 - verMinor = xpsHeader.version_minor if hasHeader else 0 - - hasTangent = bin_ops.hasTangentVersion(verMayor, verMinor, hasHeader) - hasVariableWeights = bin_ops.hasVariableWeights(verMayor, verMinor, hasHeader) - - for meshId in range(meshCount): - # Name - meshName = readFilesString(file) - if not meshName: - meshName = 'unnamed' - # print('Mesh Name:', meshName) - # uv Count - uvLayerCount = bin_ops.readUInt32(file) - # Textures - textures = [] - textureCount = bin_ops.readUInt32(file) - for texId in range(textureCount): - textureFile = ntpath.basename(readFilesString(file)) - # print('Texture file', textureFile) - uvLayerId = bin_ops.readUInt32(file) - - xpsTexture = xps_types.XpsTexture(texId, textureFile, uvLayerId) - textures.append(xpsTexture) - - # Vertices - vertex = [] - vertexCount = bin_ops.readUInt32(file) - - for vertexId in range(vertexCount): - coord = readXYZ(file) - normal = readXYZ(file) - vertexColor = readVertexColor(file) - - uvs = [] - for uvLayerId in range(uvLayerCount): - uvVert = readUvVert(file) - uvs.append(uvVert) - if hasTangent: - tangent = read4Float(file) - - boneWeights = [] - if hasBones: - # if cero bones dont have weights to read - - boneIdx = [] - boneWeight = [] - if hasVariableWeights: - weightsCount = bin_ops.readInt16(file) - else: - weightsCount = 4 - - for x in range(weightsCount): - boneIdx.append(bin_ops.readInt16(file)) - for x in range(weightsCount): - boneWeight.append(bin_ops.readSingle(file)) - - for idx in range(len(boneIdx)): - boneWeights.append( - xps_types.BoneWeight(boneIdx[idx], boneWeight[idx])) - xpsVertex = xps_types.XpsVertex( - vertexId, coord, normal, vertexColor, uvs, boneWeights) - vertex.append(xpsVertex) - - # Faces - faces = [] - triCount = bin_ops.readUInt32(file) - for i in range(triCount): - triIdxs = readTriIdxs(file) - faces.append(triIdxs) - xpsMesh = xps_types.XpsMesh( - meshName, textures, vertex, faces, uvLayerCount) - meshes.append(xpsMesh) - return meshes + try: + meshCount = bin_ops.readUInt32(file) or 0 + + hasHeader = bool(xpsHeader) + + verMayor = xpsHeader.version_mayor if hasHeader else 0 + verMinor = xpsHeader.version_minor if hasHeader else 0 + + hasTangent = bin_ops.hasTangentVersion(verMayor, verMinor, hasHeader) + hasVariableWeights = bin_ops.hasVariableWeights(verMayor, verMinor, hasHeader) + + for meshId in range(meshCount): + try: + # Name + meshName = readFilesString(file) + if not meshName: + meshName = f'Mesh_{meshId}' + # print('Mesh Name:', meshName) + # uv Count + uvLayerCount = bin_ops.readUInt32(file) or 0 + # Textures + textures = [] + textureCount = bin_ops.readUInt32(file) or 0 + for texId in range(textureCount): + try: + textureFile = ntpath.basename(readFilesString(file)) + if not textureFile: + textureFile = f"texture_{texId}.dds" + # print('Texture file', textureFile) + uvLayerId = bin_ops.readUInt32(file) or 0 + + xpsTexture = xps_types.XpsTexture(texId, textureFile, uvLayerId) + textures.append(xpsTexture) + except Exception as e: + print(f"Error reading texture {texId}: {e}") + continue + + # Vertices + vertex = [] + vertexCount = bin_ops.readUInt32(file) or 0 + + for vertexId in range(vertexCount): + try: + coord = readXYZ(file) + normal = readXYZ(file) + vertexColor = readVertexColor(file) + + uvs = [] + for uvLayerId in range(uvLayerCount): + uvVert = readUvVert(file) + uvs.append(uvVert) + if hasTangent: + tangent = read4Float(file) + + boneWeights = [] + if hasBones: + # if cero bones dont have weights to read + + boneIdx = [] + boneWeight = [] + if hasVariableWeights: + weightsCount = bin_ops.readInt16(file) or 0 + else: + weightsCount = 4 + + for x in range(weightsCount): + bone_id = bin_ops.readInt16(file) or 0 + boneIdx.append(bone_id) + for x in range(weightsCount): + weight = bin_ops.readSingle(file) or 0.0 + boneWeight.append(weight) + + for idx in range(len(boneIdx)): + boneWeights.append( + xps_types.BoneWeight(boneIdx[idx], boneWeight[idx])) + xpsVertex = xps_types.XpsVertex( + vertexId, coord, normal, vertexColor, uvs, boneWeights) + vertex.append(xpsVertex) + except Exception as e: + print(f"Error reading vertex {vertexId}: {e}") + continue + + # Faces + faces = [] + triCount = bin_ops.readUInt32(file) or 0 + for i in range(triCount): + try: + triIdxs = readTriIdxs(file) + faces.append(triIdxs) + except Exception as e: + print(f"Error reading face {i}: {e}") + continue + + xpsMesh = xps_types.XpsMesh( + meshName, textures, vertex, faces, uvLayerCount) + meshes.append(xpsMesh) + except Exception as e: + print(f"Error reading mesh {meshId}: {e}") + continue + return meshes + except Exception as e: + print(f"Error reading meshes: {e}") + return [] def readIoStream(filename): - with open(filename, "rb") as a_file: - ioStream = io.BytesIO(a_file.read()) - return ioStream + try: + with open(filename, "rb") as a_file: + ioStream = io.BytesIO(a_file.read()) + return ioStream + except Exception as e: + print(f"Error opening file {filename}: {e}") + return None def readXpsModel(filename): print('File:', filename) - ioStream = readIoStream(filename) - print('Reading Header') - xpsHeader = findHeader(ioStream) - print('Reading Bones') - bones = readBones(ioStream, xpsHeader) - hasBones = bool(bones) - print('Read', len(bones), 'Bones') - print('Reading Meshes') - meshes = readMeshes(ioStream, xpsHeader, hasBones) - print('Read', len(meshes), 'Meshes') - - xpsData = xps_types.XpsData(xpsHeader, bones, meshes) - return xpsData + try: + ioStream = readIoStream(filename) + if not ioStream: + return xps_types.XpsData(None, [], []) + + print('Reading Header') + xpsHeader = findHeader(ioStream) + print('Reading Bones') + bones = readBones(ioStream, xpsHeader) + hasBones = bool(bones) + print('Read', len(bones), 'Bones') + print('Reading Meshes') + meshes = readMeshes(ioStream, xpsHeader, hasBones) + print('Read', len(meshes), 'Meshes') + + xpsData = xps_types.XpsData(xpsHeader, bones, meshes) + return xpsData + except Exception as e: + print(f"Error reading XPS model {filename}: {e}") + return xps_types.XpsData(None, [], []) def readDefaultPose(file, poseLenghtUnround, poseBones): - # print('Import Pose') - poseBytes = b'' - if poseLenghtUnround: - for i in range(0, poseBones): - poseBytes += file.readline() - - poseLenght = bin_ops.roundToMultiple( - poseLenghtUnround, xps_const.ROUND_MULTIPLE) - emptyBytes = poseLenght - poseLenghtUnround - file.read(emptyBytes) - poseString = bin_ops.decodeBytes(poseBytes) - bonesPose = read_ascii_xps.poseData(poseString) - return bonesPose - - -if __name__ == "__main__": - readfilename = r'G:\3DModeling\XNALara\XNALara_XPS\Young Samus\Generic_Item.mesh' - - print('----READ START----') - xpsData = readXpsModel(readfilename) - print('----READ END----') + try: + # print('Import Pose') + poseBytes = b'' + if poseLenghtUnround and poseBones > 0: + for i in range(0, poseBones): + line = file.readline() + if line: + poseBytes += line + + poseLenght = bin_ops.roundToMultiple( + poseLenghtUnround, xps_const.ROUND_MULTIPLE) + emptyBytes = poseLenght - poseLenghtUnround + if emptyBytes > 0: + file.read(emptyBytes) + poseString = bin_ops.decodeBytes(poseBytes) + bonesPose = read_ascii_xps.poseData(poseString) + return bonesPose + except Exception as e: + print(f"Error reading default pose: {e}") + return None diff --git a/write_ascii_xps.py b/write_ascii_xps.py index 4104da6..7829cd4 100644 --- a/write_ascii_xps.py +++ b/write_ascii_xps.py @@ -154,19 +154,3 @@ def writeXpsModel(xpsSettings, filename, xpsData): ioStream.write(writeMeshes(xpsSettings, xpsData.meshes).read()) ioStream.seek(0) writeIoStream(filename, ioStream) - - -if __name__ == "__main__": - readfilename = r'G:\3DModeling\XNALara\XNALara_XPS\data\TESTING\Alice Returns - Mods\Alice 001 Fetish Cat\generic_item2.mesh.ascii' - writefilename = r'G:\3DModeling\XNALara\XNALara_XPS\data\TESTING\Alice Returns - Mods\Alice 001 Fetish Cat\generic_item3.mesh.ascii' - - # Simulate XPS Data - # from . import mock_xps_data - # xpsData = mock_xps_data.mockData() - - # import XPS File - xpsData = read_ascii_xps.readXpsModel(readfilename) - - print('----WRITE START----') - writeXpsModel(writefilename, xpsData) - print('----WRITE END----') diff --git a/write_bin_xps.py b/write_bin_xps.py index bc62526..73da263 100644 --- a/write_bin_xps.py +++ b/write_bin_xps.py @@ -229,17 +229,3 @@ def writeXpsModel(xpsSettings, filename, xpsData): writeIoStream(filename, ioStream) -if __name__ == "__main__": - readfilename1 = r'G:\3DModeling\XNALara\XNALara_XPS\data\TESTING5\Drake\RECB DRAKE Pack_By DamianHandy\DRAKE Sneaking Suitxxz\Generic_Item - XPS pose.mesh' - writefilename1 = r'G:\3DModeling\XNALara\XNALara_XPS\data\TESTING5\Drake\RECB DRAKE Pack_By DamianHandy\DRAKE Sneaking Suitxxz\Generic_Item - BLENDER pose.mesh' - - # Simulate XPS Data - # from . import mock_xps_data - # xpsData = mock_xps_data.mockData() - - # import XPS File - xpsData = read_bin_xps.readXpsModel(readfilename1) - - print('----WRITE START----') - writeXpsModel(writefilename1, xpsData) - print('----WRITE END----') diff --git a/xps_material.py b/xps_material.py index 87c9a87..5ece150 100644 --- a/xps_material.py +++ b/xps_material.py @@ -1,24 +1,19 @@ import math - from . import ascii_ops from enum import Enum - -# All available texture types: class TextureType(Enum): - DIFFUSE = 'diffuse' # 1 - LIGHT = 'lightmap' # 2 - BUMP = 'bump' # 3 - MASK = 'mask' # 4 - BUMP1 = 'bump1' # 5 - BUMP2 = 'bump2' # 6 - SPECULAR = 'specular' # 7 - ENVIRONMENT = 'environment' # 8 - EMISSION = 'emission' # 9 - + DIFFUSE = 'diffuse' + LIGHT = 'lightmap' + BUMP = 'bump' + MASK = 'mask' + BUMP1 = 'bump1' + BUMP2 = 'bump2' + SPECULAR = 'specular' + ENVIRONMENT = 'environment' + EMISSION = 'emission' class RenderType(): - def __init__(self): self.renderGroupNum = None self.meshName = None @@ -27,9 +22,7 @@ def __init__(self): self.texRepeater2 = None self.val4 = None - class RenderGroup: - def __init__(self, renderType): self.renderType = renderType self.renderGroupNum = renderType.renderGroupNum @@ -495,11 +488,9 @@ def __init__(self, renderType): self.rgTexCount = 3 self.rgTexType = [TextureType.DIFFUSE, TextureType.BUMP, TextureType.SPECULAR] - def makeRenderType(meshFullName): mat = meshFullName.split("_") maxLen = 8 - # Complete the array with None mat = mat + [None] * (maxLen - len(mat)) renderType = RenderType() @@ -511,10 +502,6 @@ def makeRenderType(meshFullName): texRepeater2 = 0 renderGroupFloat = ascii_ops.getFloat(mat[0]) - # meshName = mat[1] - # specularityFloat = ascii_ops.getFloat(mat[2]) - # texRepeater1Float = ascii_ops.getFloat(mat[3]) - # texRepeater2Float = ascii_ops.getFloat(mat[4]) if math.isnan(renderGroupFloat): meshName = mat[0] @@ -545,7 +532,6 @@ def makeRenderType(meshFullName): return renderType - def makeRenderTypeName(renderType): nameList = [] @@ -565,20 +551,11 @@ def makeRenderTypeName(renderType): name = "_".join(nameList) return name - def texScaleOffset(scale): offset = (scale / 2.0) - ((int(scale) - 1) // 2) - .5 return offset - def scaleTex(textureSlot, texScale): textureSlot.scale = (texScale, texScale, 1) offset = texScaleOffset(texScale) - textureSlot.offset = (offset, -offset, 1) - - -if __name__ == "__main__": - rt = RenderType() - xx = RenderGroup(rt) - print(xx.__dict__) - print(xx.rgTexType) + textureSlot.offset = (offset, -offset, 1) \ No newline at end of file diff --git a/xps_panels.py b/xps_panels.py index 8ed8b84..31a431d 100644 --- a/xps_panels.py +++ b/xps_panels.py @@ -138,3 +138,12 @@ def draw(self, context): c = col.column(align=True) r = c.row(align=True) r.operator('xps_tools.export_frames_to_poses', text='Frames to Poses') +def register(): + bpy.utils.register_class(XPSToolsObjectPanel) + bpy.utils.register_class(XPSToolsBonesPanel) + bpy.utils.register_class(XPSToolsAnimPanel) + +def unregister(): + bpy.utils.unregister_class(XPSToolsObjectPanel) + bpy.utils.unregister_class(XPSToolsBonesPanel) + bpy.utils.unregister_class(XPSToolsAnimPanel) \ No newline at end of file diff --git a/xps_tools.py b/xps_tools.py index 15b8bf7..762d3d2 100644 --- a/xps_tools.py +++ b/xps_tools.py @@ -161,11 +161,6 @@ def execute(self, context): material_creator.create_group_nodes() status = import_xnalara_model.getInputFilename(xpsSettings) if status == '{NONE}': - # self.report({'DEBUG'}, "DEBUG File Format unrecognized") - # self.report({'INFO'}, "INFO File Format unrecognized") - # self.report({'OPERATOR'}, "OPERATOR File Format unrecognized") - # self.report({'WARNING'}, "WARNING File Format unrecognized") - # self.report({'ERROR'}, "ERROR File Format unrecognized") self.report({'ERROR'}, "ERROR File Format unrecognized") return {'FINISHED'} @@ -208,11 +203,11 @@ class Export_Xps_Model_Op(bpy.types.Operator, CustomExportHelper): name='Format', description='Choose Export Format', items=( - ('.xps', 'XPS', 'Export as XPS Binary format (.xps)'), + #('.xps', 'XPS', 'Export as XPS Binary format (.xps)'), ('.mesh', 'MESH', 'Export as XnaLara/XPS Binary format (.mesh)'), ('.ascii', 'ASCII', 'Export as XnaLara/XPS Ascii format (.ascii)'), ), - default='.xps', + default='.ascii', ) xps_version_mayor: bpy.props.EnumProperty( @@ -710,7 +705,6 @@ class ImportXpsNgff(bpy.types.Operator, ImportHelper): ) def execute(self, context): - # print("Selected: " + context.active_object.name) from . import import_obj if self.split_mode == 'OFF': @@ -730,7 +724,7 @@ def execute(self, context): ).to_4x4() keywords["global_matrix"] = global_matrix - if bpy.data.is_saved and context.user_preferences.filepaths.use_relative_paths: + if bpy.data.is_saved and context.preferences.filepaths.use_relative_paths: import os keywords["relpath"] = os.path.dirname(bpy.data.filepath) @@ -958,16 +952,39 @@ def unregisterCustomIcon(): def register(): + # 注册所有操作器类 + bpy.utils.register_class(Import_Xps_Model_Op) + bpy.utils.register_class(Export_Xps_Model_Op) + bpy.utils.register_class(Import_Xps_Pose_Op) + bpy.utils.register_class(Export_Xps_Pose_Op) + bpy.utils.register_class(Import_Poses_To_Keyframes_Op) + bpy.utils.register_class(Export_Frames_To_Poses_Op) + bpy.utils.register_class(ArmatureBoneDictGenerate_Op) + bpy.utils.register_class(ArmatureBoneDictRename_Op) + bpy.utils.register_class(ArmatureBoneDictRestore_Op) + bpy.utils.register_class(ImportXpsNgff) + bpy.utils.register_class(ExportXpsNgff) + bpy.utils.register_class(XpsImportSubMenu) + bpy.utils.register_class(XpsExportSubMenu) + registerCustomIcon() bpy.types.TOPBAR_MT_file_import.append(menu_func_import) bpy.types.TOPBAR_MT_file_export.append(menu_func_export) - registerCustomIcon() def unregister(): bpy.types.TOPBAR_MT_file_import.remove(menu_func_import) bpy.types.TOPBAR_MT_file_export.remove(menu_func_export) unregisterCustomIcon() - - -if __name__ == "__main__": - register() + bpy.utils.unregister_class(XpsExportSubMenu) + bpy.utils.unregister_class(XpsImportSubMenu) + bpy.utils.unregister_class(ExportXpsNgff) + bpy.utils.unregister_class(ImportXpsNgff) + bpy.utils.unregister_class(ArmatureBoneDictRestore_Op) + bpy.utils.unregister_class(ArmatureBoneDictRename_Op) + bpy.utils.unregister_class(ArmatureBoneDictGenerate_Op) + bpy.utils.unregister_class(Export_Frames_To_Poses_Op) + bpy.utils.unregister_class(Import_Poses_To_Keyframes_Op) + bpy.utils.unregister_class(Export_Xps_Pose_Op) + bpy.utils.unregister_class(Import_Xps_Pose_Op) + bpy.utils.unregister_class(Export_Xps_Model_Op) + bpy.utils.unregister_class(Import_Xps_Model_Op) \ No newline at end of file