diff --git a/.github/ISSUE_TEMPLATE/Asset-Submission.yaml b/.github/ISSUE_TEMPLATE/Asset-Submission.yaml index 799b7b2e..2ae79101 100644 --- a/.github/ISSUE_TEMPLATE/Asset-Submission.yaml +++ b/.github/ISSUE_TEMPLATE/Asset-Submission.yaml @@ -1,6 +1,6 @@ name: Asset contribution description: Submit an asset to include with MCprep -labels: ["enhancement", "asset-submission"] +labels: ["asset-submission"] body: - type: markdown attributes: @@ -21,13 +21,11 @@ body: label: Quality checks description: Please ensure all of the following are true for your asset. options: - - label: I have used only vanilla textures (or created images that are derived from vanilla textures) + - label: I have read, understood, and applied all the conventions of [this page](https://github.com/Moo-Ack-Productions/MCprep/blob/master/docs/asset_standards.md) required: true - - label: This is a vanilla minecraft asset/mob/effect + - label: This file was last saved in blender 2.80 (for backwards compatibility) required: true - - label: I have verified my asset has the correct scale (1 Blender unit = 1 meter), and the scale has been applied (control+a, scale) - required: true - - label: If my asset includes a rig, I certified there is a "root" or "main" bone, and the rig object name ends with the name ".arma" or ".rig" to work well with MCprep + - label: This is a vanilla minecraft asset/mob/effect, and uses only vanilla textures required: true - label: I am the sole creator of this rig, other than any textures by Mojang. Any custom textures I created myself. required: true diff --git a/.gitignore b/.gitignore index 51ed3dd7..0f6d212a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,14 @@ MCprep_addon/mcprep_addon_updater/MCprep_addon_updater_status.json blender_execs.txt blender_installs.txt build -test_results.tsv +test_results.csv debug_save_state.blend1 MCprep_addon/MCprep_resources/resourcepacks/mcprep_default/materials.blend1 mcprep_venv_* +.cache +.python-version +venv/ +MCprep_addon/MCprep_resources/ +*.sublime-* +MCprep_addon/import_bridge/conf +MCprep_addon/import_bridge/nbt diff --git a/BlenderChanges.md b/BlenderChanges.md new file mode 100644 index 00000000..a585c5fc --- /dev/null +++ b/BlenderChanges.md @@ -0,0 +1,106 @@ +This list contains all deprecations and removals in every Blender version starting with Blender 3.0. Since Blender 4.0's breaking changes invoked the want for a list of all deprecations and changes, this list is public for addon developers to use. + +Note that not all deprecations are listed, just the ones that may affect MCprep or changes that developers should be aware of in general, so please refer to the wiki entries for each version for more information. + +_For Developers_: The use of any deprecated feature is an automatic bug. Such features should be wrapped around if statements for backwards compatibility if absolutely necesary in older versions. + +_For MCprep maintainers_: Any use of a deprecated feature in a pull request should be questioned. If the feature is needed in older versions, then remind developers to use `min_bv`, `bv28` ([Deprecated in MCprep 3.5](https://github.com/TheDuckCow/MCprep/pull/401)), or `bv30`, whichever is more appropriate. + +# [Blender 3.0](https://wiki.blender.org/wiki/Reference/Release_Notes/3.0/Python_API) +## Deprecations +None that concern MCprep. + +## Breaking Changes +- Rigs made in Blender 3.0 are no longer compatible with older versions of Blender. + - A workaround would be to convert the rigs to FBX, then import in an older version of Blender. + +# [Blender 3.1](https://wiki.blender.org/wiki/Reference/Release_Notes/3.1/Python_API) +## Deprecations +None that affect MCprep + +## Breaking Changes +- Python 3.10 [no longer converts floats to integers](https://github.com/python/cpython/issues/82180). Code should therefore be checked and updated as needed + +# [Blender 3.2](https://wiki.blender.org/wiki/Reference/Release_Notes/3.2/Python_API) +## Deprecations +- Passing context to operators has been deprecated. **Removed in Blender 4.0** + - The Blender release notes give the following example for reference: + ```py + # Deprecated API + bpy.ops.object.delete({"selected_objects": objects_to_delete}) + + # New API + with context.temp_override(selected_objects=objects_to_delete): + bpy.ops.object.delete() + ``` + +## Breaking Changes +- `frame_still_start` and `frame_still_end` have been removed. The release notes suggest using a negative value for `frame_offset_start` and `frame_offset_end` + +# [Blender 3.3](https://wiki.blender.org/wiki/Reference/Release_Notes/3.3/Python_API) +## Deprecations +- Conext menu entries should be appended to `UI_MT_button_context_menu`. + - The Blender release notes give the following example for reference: + ```py + ### Old API ### + class WM_MT_button_context(Menu): + bl_label = "Unused" + + def draw(self, context): + layout = self.layout + layout.separator() + layout.operator("some.operator") + + def register(): + bpy.utils.register_class(WM_MT_button_context) + + def unregister(): + bpy.utils.unregister_class(WM_MT_button_context) + + ### New API ### + # Important! `UI_MT_button_context_menu` *must not* be manually registered. + def draw_menu(self, context): + layout = self.layout + layout.separator() + layout.operator("some.operator") + + def register(): + bpy.types.UI_MT_button_context_menu.append(draw_menu) + + def unregister(): + bpy.types.UI_MT_button_context_menu.remove(draw_menu) + ``` + +## Breaking Changes +- `frame_start`, `frame_offset_start`, and `frame_offset_end` are now floating point. + +# [Blender 3.4](https://wiki.blender.org/wiki/Reference/Release_Notes/3.4/Python_API) +## Deprecations +None that concern MCprep. + +## Breaking Changes +- The internal data structure for meshes has been changed significantly + - The old API remains and doesn't seem to be deprecated, but it will be slower then using the new API +- Nodes for new materials get their names translated + - The solution is to not refer to nodes by their names +- MixRGB has since been replaced with a new general Mix node. The wiki does not mention if the node name has been changed. + +# [Blender 3.5](https://wiki.blender.org/wiki/Reference/Release_Notes/3.5/Python_API) +## Breaking Changes +- Registering classes that have the same names as built-in types raises an error +- The internal mesh data structure has gone through more changes. + - `MeshUVLoop` is deprecated. **Removed in Blender 4.0** + - `data` remains emulated, but with a performance penalty + +# [Blender 3.6 (IN DEVELOPMENT)](https://wiki.blender.org/wiki/Reference/Release_Notes/3.6/Python_API) +Nothing that concerns MCprep for now. + +# [Blender 4.0 (IN DEVELOPMENT)](https://wiki.blender.org/wiki/Reference/Release_Notes/4.0/Python_API) +## Deprecated +Nothing that concerned MCprep for now. + +## Breaking Changes +- Glossy BSDF and Anisotrophic BSDF nodes have been merged. + - The node's Python name is `ShaderNodeBsdfAnisotropic` +- `MeshUVLoop` removed +- Passing context into operators removed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e1e025d..33a0fb74 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,13 +18,12 @@ When it comes to code being reviewed, expect some discussion! If you want to con ## Keeping MCprep compatible -MCprep is uniquely made stable and functional across a large number of versions of blender. As of April 2022, it still even supports releases of Blender 2.79 while simultaneously supporting Blender 3.1+, and everything in between. +MCprep is uniquely made stable and functional across a large number of versions of blender. As of April 2022, it still even supports releases of Blender 2.8 while simultaneously supporting Blender 3.5+, and everything in between. This is largely possible for a few reasons: 1. Automated tests plus an automated installer makes ensures that any changes that break older versions of blender will be caught automatically. 1. Abstracting API changes vs directly implementing changes. Instead of swapping "group" for "collection" in the change to blender 2.8, we create if/else statements and wrapper functions that fetch the attribute that exists based on the version of blender. Want more info about this? See [the article here](https://theduckcow.com/2019/update-addons-both-blender-28-and-27-support/). -1. No python annotations. This syntax wasn't supported in old versions of python that came with blender (namely, in Blender 2.7) and so we don't use annotations in this repository. Some workarounds are in place to avoid excessive printouts as a result. ## Internal Rewrites MCprep has a separate branch for internal rewrites based on the dev branch. Sometimes, internal tools are deprecated, and requires features to be changed to reflect those deprecations. @@ -45,11 +44,33 @@ As above, a critical component of maintaining support and ensuring the wide numb ### Compile MCprep using scripts -Scripts have been created for Mac OSX (`compile.sh`) and Windows (`compile.bat`) which make it fast to copy the entire addon structure the addon folders for multiple versions of blender. You need to use these scripts, or at the very least validate that they work, as running the automated tests depend on them. +MCprep uses the [bpy-addon-build](https://github.com/Moo-Ack-Productions/bpy-build) package to build the addon, which makes it fast to copy the entire addon structure the addon folders for multiple versions of blender. -The benefit? You don't have to manually navigate and install zip files in blender for each change you make - just run this script and restart blender. It *is* important you do restart blender after changes, as there can be unintended side effects of trying to reload a plugin. +The benefit? You don't have to manually navigate and install zip files in blender for each change you make - just run the command and restart blender. It *is* important you do restart blender after changes, as there can be unintended side effects of trying to reload a plugin. -Want to just quickly reload some files after only changing python code (no asset changes)? Mac only: Try running `compile.sh -fast` which will skip copying over the resources folder and skip zipping the addon. +As a quick start: + +```bash +# Highly recommended, create a local virtual environment (could also define globally) +python3 -m pip install --user virtualenv + +python3 -m pip install --upgrade pip # Install/upgrade pip +python3 -m venv ./venv # Add a local virtual env called `venv` + +# Activate that environment +## On windows: +.\venv\Scripts\activate +## On Mac/linux: +source venv/bin/activate + +# Now with the env active, do the pip install (or upgrade) +pip install --upgrade bpy-addon-build + +# Finally, you can compile MCprep using: +bpy-addon-build --during-build dev # Use dev to use non-prod related resources and tracking. +``` + +Moving forward, you can now build the addon for all intended supported versions using: `bpy-addon-build -b dev` ### Run tests @@ -107,7 +128,7 @@ At the moment, only the project lead (TheDuckCow) should ever mint new releases - Tag is in the form `3.3.1`, no leading `v`. - The title however is in the form `MCprep v3.3.0 | ShortName` where version has a leading `v`. - Copy the body fo the description from the prior release, and then update the text and splash screen (if a major release). Edit a prior release without making changes to get the raw markdown code, e.g. [from here](https://github.com/TheDuckCow/MCprep/releases/edit/3.3.0). -1. Run `compile.sh` or `compile.bat` with no fast flag, so it does the full build +1. Run `bpy-addon-build.py` to build the addon 1. Run all tests, ideally on two different operating systems. Use the `./run_tests.sh -all` flag to run on all versions of blender 1. If all tests pass, again DOUBLE CHECK that "dev" = false in conf.py, then 1. Drag and drop the generated updated zip file onto github. @@ -122,24 +143,7 @@ At the moment, only the project lead (TheDuckCow) should ever mint new releases -## Creating your blender_installs.txt and blender_exects.txt - - -Your `blender_installs.txt` defines where the `compile.sh` (Mac OSX) or `compile.bat` (Windows) script will install MCprep onto your system. It's a directly copy-paste of the folder. - -On a mac? The text file will be generated automatically for you if you haven't already created it, based on detected blender installs. Otherwise, just create it manually. It could look like: - -``` -/Users/your_username/Library/Application Support/Blender/3.1/scripts/addons -/Users/your_username/Library/Application Support/Blender/3.0/scripts/addons -/Users/your_username/Library/Application Support/Blender/2.93/scripts/addons -/Users/your_username/Library/Application Support/Blender/2.92/scripts/addons -/Users/your_username/Library/Application Support/Blender/2.90/scripts/addons -/Users/your_username/Library/Application Support/Blender/2.80/scripts/addons -/Users/your_username/Library/Application Support/Blender/2.79/scripts/addons -/Users/your_username/Library/Application Support/Blender/2.78/scripts/addons -/Users/your_username/Library/Application Support/Blender/2.72/scripts/addons -``` +## Creating your blender_execs.txt Your `blender_execs.txt` defines where to find the executables used in the automated testing scripts. Only these executables will be used during automated testing, noting that the testing system only supports blender version 2.8+ (sadly, only manual testing is possible in blender 2.7 with the current setup). It could look like: @@ -160,11 +164,9 @@ Also note that the first line indicates the only version of blender that will be Support for development and testing should work for both platforms, just be aware the primary development of MCprep is happening on a Mac OSX machine, so the mac-side utility scripts have a few more features than windows: -- Only the mac `compile.sh` script has the `-fast` option to quickly reload python files (it won't copy over the blends, textures, and won't create a new zip file, all of which can be slow) -- Only the mac `compile.sh` has the feature of auto-detecting local blender executable installs. This is because on windows, there is a lot of variability where blender executables may be placed, so it should just be manually created anyways. -- Only the mac `run_tests.sh` script has the `-all` optional flag. By default, the mac script will only install the +- Only the mac `run_tests.sh` script has the `-all` optional flag. By default, the mac script will only install the first line in the file. -One other detail: MCprep uses git lfs or Large File Storage, to avoid saving binary files in the git history. Some Windows users may run into trouble when first pulling. +One other detail: MCprep uses Git LFS or Large File Storage, to avoid saving binary files in the git history. Some Windows users may run into trouble when first pulling. - If using Powershell and you cloned your repo using SSH credentials, try running `start-ssh-agent` before running the clone/pull command (per [comment here](https://github.com/git-lfs/git-lfs/issues/3216#issuecomment-1018304297)) - Alternatively, try using Git for Windows and its console. @@ -206,12 +208,48 @@ Add this to a file called .gitmessage, and then execute the following command: To use for each commit, you can use `git config --local commit.verbose true` to tell Git to perform a verbose commit all the time for just the MCprep repo. -## IDE Support -If you're using an IDE, it's recommened to install `bpy` as a Python module. In my (StandingPad) experiance, the [fake-bpy package](https://github.com/nutti/fake-bpy-module) seems to be the best. +## Dependencies +If you're using an IDE, it's recommened to install `bpy` as a Python module. In our experience, the [fake-bpy package](https://github.com/nutti/fake-bpy-module) seems to be the best. It's also recommened to use a virtual environment (especially if you're on Linux) as to avoid issues with system wide packages and different versions of `bpy`. [See this for more details](https://realpython.com/python-virtual-environments-a-primer/) -### Creating a Virtual Environment and Setting up `bpy` +There are 2 methods to do this: +- Poetry +- Manualy + +Both are listed here. + +### With Poetry +[Poetry](https://python-poetry.org/) is a useful tool that allows easy dependency handling. To quote the website: + +> Python packaging and dependency management made easy + +If you decide to use Poetry, then simply run the following command: + +`poetry install` + +To enable the virtual environment, run `poetry shell`, then type `exit` when you're done. + +### Manual: Requirements.txt Edition +First create a virtual environment: + +`python3 -m venv mcprep_venv_2.80` + +We use the name `mcprep_venv_2.80` to follow MCprep convention. Check the next section if you're curious the why. + +To enable: + +Windows: `mcprep_venv_\Scripts\activate` + +MacOS and Linux: `source mcprep_venv_/bin/activate` + +To disable: `deactivate` + +Install dependencies: + +`python3 -m pip install -r requirements.txt` + +### Manual: Setting up `bpy` Manually Edition First, we need to come up with a name. For MCprep development, it's recommended to use the following convention: `mcprep_venv_` @@ -237,12 +275,4 @@ Next we need to install `fake-bpy`: If you use PyCharm, you should check the GitHub for [additional instructions](https://github.com/nutti/fake-bpy-module#install-via-pip-package) -### Pylint -MCprep mostly tries to follow the PEP8 guidelines, so it's also a good idea to install pylsp and flake8 for IDEs. - -First, install the 2: -`python3 -m pip install python-lsp-server flake8` - -Then set up your IDE to use pylsp as your Python LSP. This depends on the IDE, so look at the documentation to see how to set your Python LSP for your specific editor. - Now you're ready to do MCprep development diff --git a/MCprep_addon/MCprep_resources/mcprep_data_update.json b/MCprep_addon/MCprep_resources/mcprep_data_update.json index e0940e67..3cb59f32 100644 --- a/MCprep_addon/MCprep_resources/mcprep_data_update.json +++ b/MCprep_addon/MCprep_resources/mcprep_data_update.json @@ -854,9 +854,22 @@ "yellow_terracotta": "yellow_terracotta" }, "block_mapping_mc": { + "1": "gui/sprites/notification/1", + "2": "gui/sprites/notification/2", + "3": "gui/sprites/notification/3", + "4": "gui/sprites/notification/4", + "5": "gui/sprites/notification/5", "Campfire": "campfire_log", + "absorbing_full": "gui/sprites/hud/heart/absorbing_full", + "absorbing_full_blinking": "gui/sprites/hud/heart/absorbing_full_blinking", + "absorbing_half": "gui/sprites/hud/heart/absorbing_half", + "absorbing_half_blinking": "gui/sprites/hud/heart/absorbing_half_blinking", + "absorbing_hardcore_full": "gui/sprites/hud/heart/absorbing_hardcore_full", + "absorbing_hardcore_full_blinking": "gui/sprites/hud/heart/absorbing_hardcore_full_blinking", + "absorbing_hardcore_half": "gui/sprites/hud/heart/absorbing_hardcore_half", + "absorbing_hardcore_half_blinking": "gui/sprites/hud/heart/absorbing_hardcore_half_blinking", "absorption": "mob_effect/absorption", - "acacia": "entity/chest_boat/acacia", + "acacia": "entity/boat/acacia", "acacia_door_bottom": "acacia_door_bottom", "acacia_door_top": "acacia_door_top", "acacia_leaves": "acacia_leaves", @@ -866,14 +879,18 @@ "acacia_sapling": "acacia_sapling", "acacia_trapdoor": "acacia_trapdoor", "accented": "font/accented", - "accept_icon": null, - "accessibility": "gui/accessibility", + "accept": "gui/sprites/pending_invite/accept", + "accept_highlighted": "gui/sprites/pending_invite/accept_highlighted", + "accessibility": "gui/sprites/icon/accessibility", "activator_rail": "activator_rail", "activator_rail_on": "activator_rail_on", + "advancement": "gui/sprites/toast/advancement", "adventure": "gui/advancements/backgrounds/adventure", "aggressive_panda": "entity/panda/aggressive_panda", + "air": "gui/sprites/hud/air", + "air_bursting": "gui/sprites/hud/air_bursting", "alban": "painting/alban", - "alex": "entity/player/wide/alex", + "alex": "entity/player/slim/alex", "all_black": "entity/cat/all_black", "allay": "entity/allay/allay", "allium": "allium", @@ -888,7 +905,11 @@ "anvil": "anvil", "anvil_top": "anvil_top", "archer_pottery_pattern": "entity/decorated_pot/archer_pottery_pattern", - "ari": "entity/player/wide/ari", + "ari": "entity/player/slim/ari", + "armor_empty": "gui/sprites/hud/armor_empty", + "armor_full": "gui/sprites/hud/armor_full", + "armor_half": "gui/sprites/hud/armor_half", + "armor_slot": "gui/sprites/container/horse/armor_slot", "armorer": "entity/villager/profession/armorer", "arms_up_pottery_pattern": "entity/decorated_pot/arms_up_pottery_pattern", "arrow": "entity/projectiles/arrow", @@ -910,8 +931,9 @@ "aztec2": "painting/aztec2", "azure_bluet": "azure_bluet", "back": "painting/back", + "background": "gui/sprites/container/bundle/background", "bad_omen": "mob_effect/bad_omen", - "bamboo": "entity/chest_boat/bamboo", + "bamboo": "entity/boat/bamboo", "bamboo_block": "bamboo_block", "bamboo_block_top": "bamboo_block_top", "bamboo_door_bottom": "bamboo_door_bottom", @@ -929,11 +951,11 @@ "bamboo_stalk": "bamboo_stalk", "bamboo_trapdoor": "bamboo_trapdoor", "banner_base": "entity/banner_base", + "banner_slot": "gui/sprites/container/loom/banner_slot", "barrel_bottom": "barrel_bottom", "barrel_side": "barrel_side", "barrel_top": "barrel_top", "barrel_top_open": "barrel_top_open", - "bars": "gui/bars", "basalt_side": "basalt_side", "basalt_top": "basalt_top", "base": "entity/banner/base", @@ -980,7 +1002,7 @@ "big_smoke_7": "particle/big_smoke_7", "big_smoke_8": "particle/big_smoke_8", "big_smoke_9": "particle/big_smoke_9", - "birch": "entity/chest_boat/birch", + "birch": "entity/boat/birch", "birch_door_bottom": "birch_door_bottom", "birch_door_top": "birch_door_top", "birch_leaves": "birch_leaves", @@ -989,7 +1011,7 @@ "birch_planks": "birch_planks", "birch_sapling": "birch_sapling", "birch_trapdoor": "birch_trapdoor", - "black": "entity/rabbit/black", + "black": "entity/cat/black", "black_candle": "black_candle", "black_candle_lit": "black_candle_lit", "black_concrete": "black_concrete", @@ -1010,7 +1032,10 @@ "blast_furnace_top": "blast_furnace_top", "blaze": "entity/blaze", "blindness": "mob_effect/blindness", - "blue": "entity/bed/blue", + "block_mined": "gui/sprites/statistics/block_mined", + "blocked_slot": "gui/sprites/container/bundle/blocked_slot", + "blue": "entity/llama/decor/blue", + "blue_background": "gui/sprites/boss_bar/blue_background", "blue_candle": "blue_candle", "blue_candle_lit": "blue_candle_lit", "blue_concrete": "blue_concrete", @@ -1018,6 +1043,7 @@ "blue_glazed_terracotta": "blue_glazed_terracotta", "blue_ice": "blue_ice", "blue_orchid": "blue_orchid", + "blue_progress": "gui/sprites/boss_bar/blue_progress", "blue_shulker_box": "blue_shulker_box", "blue_stained_glass": "blue_stained_glass", "blue_stained_glass_pane_top": "blue_stained_glass_pane_top", @@ -1030,16 +1056,19 @@ "bookshelf": "bookshelf", "boots_trim": "trims/items/boots_trim", "border": "entity/banner/border", + "box_obtained": "gui/sprites/advancements/box_obtained", + "box_unobtained": "gui/sprites/advancements/box_unobtained", "brain_coral": "brain_coral", "brain_coral_block": "brain_coral_block", "brain_coral_fan": "brain_coral_fan", "break_particle": "entity/conduit/break_particle", + "brew_progress": "gui/sprites/container/brewing_stand/brew_progress", "brewer_pottery_pattern": "entity/decorated_pot/brewer_pottery_pattern", "brewing_stand": "brewing_stand", "brewing_stand_base": "brewing_stand_base", "bricks": "bricks", "british_shorthair": "entity/cat/british_shorthair", - "brown": "entity/rabbit/brown", + "brown": "entity/llama/brown", "brown_candle": "brown_candle", "brown_candle_lit": "brown_candle_lit", "brown_concrete": "brown_concrete", @@ -1063,12 +1092,17 @@ "bubble_pop_2": "particle/bubble_pop_2", "bubble_pop_3": "particle/bubble_pop_3", "bubble_pop_4": "particle/bubble_pop_4", + "bubbles": "gui/sprites/container/brewing_stand/bubbles", "budding_amethyst": "budding_amethyst", - "bundle": "gui/container/bundle", "burn_pottery_pattern": "entity/decorated_pot/burn_pottery_pattern", + "burn_progress": "gui/sprites/container/blast_furnace/burn_progress", "burning_skull": "painting/burning_skull", "bust": "painting/bust", "butcher": "entity/villager/profession/butcher", + "button": "gui/sprites/container/beacon/button", + "button_disabled": "gui/sprites/container/beacon/button_disabled", + "button_highlighted": "gui/sprites/container/beacon/button_highlighted", + "button_selected": "gui/sprites/container/beacon/button_selected", "cactus_bottom": "cactus_bottom", "cactus_side": "cactus_side", "cactus_top": "cactus_top", @@ -1087,6 +1121,7 @@ "campfire_fire": "campfire_fire", "campfire_log": "campfire_log", "campfire_log_lit": "campfire_log_lit", + "cancel": "gui/sprites/container/beacon/cancel", "candle": "candle", "candle_lit": "candle_lit", "carrots_stage0": "carrots_stage0", @@ -1117,10 +1152,17 @@ "chain_command_block_side": "chain_command_block_side", "chainmail_layer_1": "models/armor/chainmail_layer_1", "chainmail_layer_2": "models/armor/chainmail_layer_2", - "chat_tags": "gui/chat_tags", - "checkbox": "gui/checkbox", - "checkmark": "gui/checkmark", - "cherry": "entity/chest_boat/cherry", + "challenge_frame_obtained": "gui/sprites/advancements/challenge_frame_obtained", + "challenge_frame_unobtained": "gui/sprites/advancements/challenge_frame_unobtained", + "changes": "gui/sprites/backup/changes", + "changes_highlighted": "gui/sprites/backup/changes_highlighted", + "chat_modified": "gui/sprites/icon/chat_modified", + "checkbox": "gui/sprites/widget/checkbox", + "checkbox_highlighted": "gui/sprites/widget/checkbox_highlighted", + "checkbox_selected": "gui/sprites/widget/checkbox_selected", + "checkbox_selected_highlighted": "gui/sprites/widget/checkbox_selected_highlighted", + "checkmark": "gui/sprites/icon/checkmark", + "cherry": "entity/boat/cherry", "cherry_0": "particle/cherry_0", "cherry_1": "particle/cherry_1", "cherry_10": "particle/cherry_10", @@ -1141,6 +1183,7 @@ "cherry_planks": "cherry_planks", "cherry_sapling": "cherry_sapling", "cherry_trapdoor": "cherry_trapdoor", + "chest_slots": "gui/sprites/container/horse/chest_slots", "chestplate_trim": "trims/items/chestplate_trim", "chicken": "entity/chicken", "chipped_anvil_top": "chipped_anvil_top", @@ -1165,6 +1208,8 @@ "circle": "entity/banner/circle", "clay": "clay", "cleric": "entity/villager/profession/cleric", + "close": "gui/sprites/spectator/close", + "closed": "gui/sprites/realm_status/closed", "closed_eye": "entity/conduit/closed_eye", "clouds": "environment/clouds", "coal_block": "coal_block", @@ -1193,6 +1238,11 @@ "composter_top": "composter_top", "conduit": "conduit", "conduit_power": "mob_effect/conduit_power", + "confirm": "gui/sprites/container/beacon/confirm", + "container": "gui/sprites/hud/heart/container", + "container_blinking": "gui/sprites/hud/heart/container_blinking", + "container_hardcore": "gui/sprites/hud/heart/container_hardcore", + "container_hardcore_blinking": "gui/sprites/hud/heart/container_hardcore_blinking", "copper": "trims/color_palettes/copper", "copper_block": "copper_block", "copper_ore": "copper_ore", @@ -1204,6 +1254,10 @@ "cracked_nether_bricks": "cracked_nether_bricks", "cracked_polished_blackstone_bricks": "cracked_polished_blackstone_bricks", "cracked_stone_bricks": "cracked_stone_bricks", + "crafting_overlay": "gui/sprites/recipe_book/crafting_overlay", + "crafting_overlay_disabled": "gui/sprites/recipe_book/crafting_overlay_disabled", + "crafting_overlay_disabled_highlighted": "gui/sprites/recipe_book/crafting_overlay_disabled_highlighted", + "crafting_overlay_highlighted": "gui/sprites/recipe_book/crafting_overlay_highlighted", "crafting_table": "gui/container/crafting_table", "crafting_table_front": "crafting_table_front", "crafting_table_side": "crafting_table_side", @@ -1226,14 +1280,18 @@ "crimson_trapdoor": "crimson_trapdoor", "critical_hit": "particle/critical_hit", "cross": "entity/banner/cross", - "cross_icon": null, - "cross_player_icon": null, + "cross_button": "gui/sprites/widget/cross_button", + "cross_button_highlighted": "gui/sprites/widget/cross_button_highlighted", + "crosshair": "gui/sprites/hud/crosshair", + "crosshair_attack_indicator_background": "gui/sprites/hud/crosshair_attack_indicator_background", + "crosshair_attack_indicator_full": "gui/sprites/hud/crosshair_attack_indicator_full", + "crosshair_attack_indicator_progress": "gui/sprites/hud/crosshair_attack_indicator_progress", "crying_obsidian": "crying_obsidian", "curly_border": "entity/banner/curly_border", "cut_copper": "cut_copper", "cut_red_sandstone": "cut_red_sandstone", "cut_sandstone": "cut_sandstone", - "cyan": "entity/bed/cyan", + "cyan": "entity/llama/decor/cyan", "cyan_candle": "cyan_candle", "cyan_candle_lit": "cyan_candle_lit", "cyan_concrete": "cyan_concrete", @@ -1248,7 +1306,7 @@ "damaged_anvil_top": "damaged_anvil_top", "dandelion": "dandelion", "danger_pottery_pattern": "entity/decorated_pot/danger_pottery_pattern", - "dark_oak": "entity/chest_boat/dark_oak", + "dark_oak": "entity/boat/dark_oak", "dark_oak_door_bottom": "dark_oak_door_bottom", "dark_oak_door_top": "dark_oak_door_top", "dark_oak_leaves": "dark_oak_leaves", @@ -1258,7 +1316,6 @@ "dark_oak_sapling": "dark_oak_sapling", "dark_oak_trapdoor": "dark_oak_trapdoor", "dark_prismarine": "dark_prismarine", - "darken": null, "darkness": "mob_effect/darkness", "daylight_detector_inverted_top": "daylight_detector_inverted_top", "daylight_detector_side": "daylight_detector_side", @@ -1313,7 +1370,7 @@ "diagonal_right": "entity/banner/diagonal_right", "diagonal_up_left": "entity/banner/diagonal_up_left", "diagonal_up_right": "entity/banner/diagonal_up_right", - "diamond": "entity/villager/profession_level/diamond", + "diamond": "trims/color_palettes/diamond", "diamond_block": "diamond_block", "diamond_darker": "trims/color_palettes/diamond_darker", "diamond_layer_1": "models/armor/diamond_layer_1", @@ -1323,6 +1380,7 @@ "dirt": "dirt", "dirt_path_side": "dirt_path_side", "dirt_path_top": "dirt_path_top", + "discount_strikethrough": "gui/sprites/container/villager/discount_strikethrough", "dispenser": "gui/container/dispenser", "dispenser_front": "dispenser_front", "dispenser_front_vertical": "dispenser_front_vertical", @@ -1331,6 +1389,7 @@ "dolphins_grace": "mob_effect/dolphins_grace", "donkey": "entity/horse/donkey", "donkey_kong": "painting/donkey_kong", + "draft_report": "gui/sprites/icon/draft_report", "dragon": "entity/enderdragon/dragon", "dragon_egg": "dragon_egg", "dragon_exploding": "entity/enderdragon/dragon_exploding", @@ -1349,9 +1408,11 @@ "drowned_outer_layer": "entity/zombie/drowned_outer_layer", "dune": "trims/models/armor/dune", "dune_leggings": "trims/models/armor/dune_leggings", + "duplicated_map": "gui/sprites/container/cartography_table/duplicated_map", + "dye_slot": "gui/sprites/container/loom/dye_slot", "earth": "painting/earth", "edition": "gui/title/edition", - "efe": "entity/player/wide/efe", + "efe": "entity/player/slim/efe", "effect_0": "particle/effect_0", "effect_1": "particle/effect_1", "effect_2": "particle/effect_2", @@ -1360,11 +1421,15 @@ "effect_5": "particle/effect_5", "effect_6": "particle/effect_6", "effect_7": "particle/effect_7", + "effect_background": "gui/sprites/hud/effect_background", + "effect_background_ambient": "gui/sprites/hud/effect_background_ambient", + "effect_background_large": "gui/sprites/container/inventory/effect_background_large", + "effect_background_small": "gui/sprites/container/inventory/effect_background_small", "elytra": "entity/elytra", - "emerald": "entity/villager/profession_level/emerald", + "emerald": "trims/color_palettes/emerald", "emerald_block": "emerald_block", "emerald_ore": "emerald_ore", - "empty_frame": null, + "empty_frame": "gui/realms/empty_frame", "enchanted_glint_entity": "misc/enchanted_glint_entity", "enchanted_glint_item": "misc/enchanted_glint_item", "enchanted_hit": "particle/enchanted_hit", @@ -1373,6 +1438,9 @@ "enchanting_table_bottom": "enchanting_table_bottom", "enchanting_table_side": "enchanting_table_side", "enchanting_table_top": "enchanting_table_top", + "enchantment_slot": "gui/sprites/container/enchanting_table/enchantment_slot", + "enchantment_slot_disabled": "gui/sprites/container/enchanting_table/enchantment_slot_disabled", + "enchantment_slot_highlighted": "gui/sprites/container/enchanting_table/enchantment_slot_highlighted", "end": "gui/advancements/backgrounds/end", "end_crystal": "entity/end_crystal/end_crystal", "end_crystal_beam": "entity/end_crystal/end_crystal_beam", @@ -1389,12 +1457,18 @@ "enderman": "entity/enderman/enderman", "enderman_eyes": "entity/enderman/enderman_eyes", "endermite": "entity/endermite", + "error": "gui/sprites/container/grindstone/error", + "error_highlighted": "gui/sprites/world_list/error_highlighted", "evoker": "entity/illager/evoker", "evoker_fangs": "entity/illager/evoker_fangs", - "experience": null, + "experience": "gui/realms/experience", + "experience_bar_background": "gui/sprites/container/villager/experience_bar_background", + "experience_bar_current": "gui/sprites/container/villager/experience_bar_current", + "experience_bar_progress": "gui/sprites/hud/experience_bar_progress", + "experience_bar_result": "gui/sprites/container/villager/experience_bar_result", "experience_orb": "entity/experience_orb", - "expired_icon": null, - "expires_soon_icon": null, + "expired": "gui/sprites/realm_status/expired", + "expires_soon": "gui/sprites/realm_status/expires_soon", "explorer_pottery_pattern": "entity/decorated_pot/explorer_pottery_pattern", "explosion_0": "particle/explosion_0", "explosion_1": "particle/explosion_1", @@ -1421,6 +1495,10 @@ "farmland_moist": "farmland_moist", "fern": "fern", "fighters": "painting/fighters", + "filter_disabled": "gui/sprites/recipe_book/filter_disabled", + "filter_disabled_highlighted": "gui/sprites/recipe_book/filter_disabled_highlighted", + "filter_enabled": "gui/sprites/recipe_book/filter_enabled", + "filter_enabled_highlighted": "gui/sprites/recipe_book/filter_enabled_highlighted", "fire": "fire_0", "fire_0": "fire_0", "fire_1": "fire_1", @@ -1442,6 +1520,12 @@ "flowering_azalea_side": "flowering_azalea_side", "flowering_azalea_top": "flowering_azalea_top", "foliage": "colormap/foliage", + "food_empty": "gui/sprites/hud/food_empty", + "food_empty_hunger": "gui/sprites/hud/food_empty_hunger", + "food_full": "gui/sprites/hud/food_full", + "food_full_hunger": "gui/sprites/hud/food_full_hunger", + "food_half": "gui/sprites/hud/food_half", + "food_half_hunger": "gui/sprites/hud/food_half_hunger", "footer_separator": "gui/footer_separator", "forcefield": "misc/forcefield", "fox": "entity/fox/fox", @@ -1452,9 +1536,28 @@ "frosted_ice_1": "frosted_ice_1", "frosted_ice_2": "frosted_ice_2", "frosted_ice_3": "frosted_ice_3", + "frozen_full": "gui/sprites/hud/heart/frozen_full", + "frozen_full_blinking": "gui/sprites/hud/heart/frozen_full_blinking", + "frozen_half": "gui/sprites/hud/heart/frozen_half", + "frozen_half_blinking": "gui/sprites/hud/heart/frozen_half_blinking", + "frozen_hardcore_full": "gui/sprites/hud/heart/frozen_hardcore_full", + "frozen_hardcore_full_blinking": "gui/sprites/hud/heart/frozen_hardcore_full_blinking", + "frozen_hardcore_half": "gui/sprites/hud/heart/frozen_hardcore_half", + "frozen_hardcore_half_blinking": "gui/sprites/hud/heart/frozen_hardcore_half_blinking", + "fuel_length": "gui/sprites/container/brewing_stand/fuel_length", + "full": "gui/sprites/hud/heart/full", + "full_blinking": "gui/sprites/hud/heart/full_blinking", "furnace": "gui/container/furnace", + "furnace_filter_disabled": "gui/sprites/recipe_book/furnace_filter_disabled", + "furnace_filter_disabled_highlighted": "gui/sprites/recipe_book/furnace_filter_disabled_highlighted", + "furnace_filter_enabled": "gui/sprites/recipe_book/furnace_filter_enabled", + "furnace_filter_enabled_highlighted": "gui/sprites/recipe_book/furnace_filter_enabled_highlighted", "furnace_front": "furnace_front", "furnace_front_on": "furnace_front_on", + "furnace_overlay": "gui/sprites/recipe_book/furnace_overlay", + "furnace_overlay_disabled": "gui/sprites/recipe_book/furnace_overlay_disabled", + "furnace_overlay_disabled_highlighted": "gui/sprites/recipe_book/furnace_overlay_disabled_highlighted", + "furnace_overlay_highlighted": "gui/sprites/recipe_book/furnace_overlay_highlighted", "furnace_side": "furnace_side", "furnace_top": "furnace_top", "gamemode_switcher": "gui/container/gamemode_switcher", @@ -1488,8 +1591,10 @@ "glow_squid": "entity/squid/glow_squid", "glowing": "mob_effect/glowing", "glowstone": "glowstone", + "goal_frame_obtained": "gui/sprites/advancements/goal_frame_obtained", + "goal_frame_unobtained": "gui/sprites/advancements/goal_frame_unobtained", "goat": "entity/goat/goat", - "gold": "entity/villager/profession_level/gold", + "gold": "trims/color_palettes/gold", "gold_block": "gold_block", "gold_darker": "trims/color_palettes/gold_darker", "gold_layer_1": "models/armor/gold_layer_1", @@ -1508,7 +1613,7 @@ "grass_block_snow": "grass_block_snow", "grass_block_top": "grass_block_top", "gravel": "gravel", - "gray": "entity/bed/gray", + "gray": "entity/llama/gray", "gray_candle": "gray_candle", "gray_candle_lit": "gray_candle_lit", "gray_concrete": "gray_concrete", @@ -1519,12 +1624,14 @@ "gray_stained_glass_pane_top": "gray_stained_glass_pane_top", "gray_terracotta": "gray_terracotta", "gray_wool": "gray_wool", - "green": "entity/bed/green", + "green": "entity/llama/decor/green", + "green_background": "gui/sprites/boss_bar/green_background", "green_candle": "green_candle", "green_candle_lit": "green_candle_lit", "green_concrete": "green_concrete", "green_concrete_powder": "green_concrete_powder", "green_glazed_terracotta": "green_glazed_terracotta", + "green_progress": "gui/sprites/boss_bar/green_progress", "green_shulker_box": "green_shulker_box", "green_stained_glass": "green_stained_glass", "green_stained_glass_pane_top": "green_stained_glass_pane_top", @@ -1537,14 +1644,21 @@ "guardian": "entity/guardian", "guardian_beam": "entity/guardian_beam", "guardian_elder": "entity/guardian_elder", + "half": "gui/sprites/hud/heart/half", + "half_blinking": "gui/sprites/hud/heart/half_blinking", "half_horizontal": "entity/banner/half_horizontal", "half_horizontal_bottom": "entity/banner/half_horizontal_bottom", "half_vertical": "entity/banner/half_vertical", "half_vertical_right": "entity/banner/half_vertical_right", "hanging_roots": "hanging_roots", + "hardcore_full": "gui/sprites/hud/heart/hardcore_full", + "hardcore_full_blinking": "gui/sprites/hud/heart/hardcore_full_blinking", + "hardcore_half": "gui/sprites/hud/heart/hardcore_half", + "hardcore_half_blinking": "gui/sprites/hud/heart/hardcore_half_blinking", "haste": "mob_effect/haste", "hay_block_side": "hay_block_side", "hay_block_top": "hay_block_top", + "header": "gui/sprites/statistics/header", "header_separator": "gui/header_separator", "health_boost": "mob_effect/health_boost", "heart": "particle/heart", @@ -1584,22 +1698,27 @@ "horse_zombie": "entity/horse/horse_zombie", "host": "trims/models/armor/host", "host_leggings": "trims/models/armor/host_leggings", + "hotbar": "gui/sprites/hud/hotbar", + "hotbar_attack_indicator_background": "gui/sprites/hud/hotbar_attack_indicator_background", + "hotbar_attack_indicator_progress": "gui/sprites/hud/hotbar_attack_indicator_progress", + "hotbar_offhand_left": "gui/sprites/hud/hotbar_offhand_left", + "hotbar_offhand_right": "gui/sprites/hud/hotbar_offhand_right", + "hotbar_selection": "gui/sprites/hud/hotbar_selection", "howl_pottery_pattern": "entity/decorated_pot/howl_pottery_pattern", "hunger": "mob_effect/hunger", "husbandry": "gui/advancements/backgrounds/husbandry", "husk": "entity/zombie/husk", "ice": "ice", - "icons": "gui/icons", "illusioner": "entity/illager/illusioner", - "info_icon": "gui/info_icon", - "inspiration": null, + "incompatible": "gui/sprites/server_list/incompatible", + "info": "gui/sprites/icon/info", + "inspiration": "gui/realms/inspiration", "instant_damage": "mob_effect/instant_damage", "instant_health": "mob_effect/instant_health", "inventory": "gui/container/inventory", "invisibility": "mob_effect/invisibility", - "invitation_icons": null, - "invite_icon": null, - "iron": "entity/villager/profession_level/iron", + "invite": "gui/sprites/icon/invite", + "iron": "trims/color_palettes/iron", "iron_bars": "iron_bars", "iron_block": "iron_block", "iron_darker": "trims/color_palettes/iron_darker", @@ -1614,17 +1733,27 @@ "iron_ore": "iron_ore", "iron_trapdoor": "iron_trapdoor", "isles": "gui/presets/isles", + "item_broken": "gui/sprites/statistics/item_broken", + "item_crafted": "gui/sprites/statistics/item_crafted", + "item_dropped": "gui/sprites/statistics/item_dropped", "item_frame": "item_frame", + "item_picked_up": "gui/sprites/statistics/item_picked_up", + "item_used": "gui/sprites/statistics/item_used", "jack_o_lantern": "jack_o_lantern", "jellie": "entity/cat/jellie", "jigsaw_bottom": "jigsaw_bottom", "jigsaw_lock": "jigsaw_lock", "jigsaw_side": "jigsaw_side", "jigsaw_top": "jigsaw_top", + "join": "gui/sprites/server_list/join", + "join_highlighted": "gui/sprites/server_list/join_highlighted", "jukebox_side": "jukebox_side", "jukebox_top": "jukebox_top", + "jump_bar_background": "gui/sprites/hud/jump_bar_background", + "jump_bar_cooldown": "gui/sprites/hud/jump_bar_cooldown", + "jump_bar_progress": "gui/sprites/hud/jump_bar_progress", "jump_boost": "mob_effect/jump_boost", - "jungle": "entity/chest_boat/jungle", + "jungle": "entity/villager/type/jungle", "jungle_door_bottom": "jungle_door_bottom", "jungle_door_top": "jungle_door_top", "jungle_leaves": "jungle_leaves", @@ -1633,11 +1762,12 @@ "jungle_planks": "jungle_planks", "jungle_sapling": "jungle_sapling", "jungle_trapdoor": "jungle_trapdoor", - "kai": "entity/player/wide/kai", + "kai": "entity/player/slim/kai", "kebab": "painting/kebab", "kelp": "kelp", "kelp_plant": "kelp_plant", "ladder": "ladder", + "language": "gui/sprites/icon/language", "lantern": "lantern", "lapis": "trims/color_palettes/lapis", "lapis_block": "lapis_block", @@ -1658,12 +1788,17 @@ "lectern_front": "lectern_front", "lectern_sides": "lectern_sides", "lectern_top": "lectern_top", - "legacy_smithing": "gui/container/legacy_smithing", "leggings_trim": "trims/items/leggings_trim", + "level_1": "gui/sprites/container/enchanting_table/level_1", + "level_1_disabled": "gui/sprites/container/enchanting_table/level_1_disabled", + "level_2": "gui/sprites/container/enchanting_table/level_2", + "level_2_disabled": "gui/sprites/container/enchanting_table/level_2_disabled", + "level_3": "gui/sprites/container/enchanting_table/level_3", + "level_3_disabled": "gui/sprites/container/enchanting_table/level_3_disabled", "lever": "lever", "levitation": "mob_effect/levitation", "librarian": "entity/villager/profession/librarian", - "light_blue": "entity/bed/light_blue", + "light_blue": "entity/llama/decor/light_blue", "light_blue_candle": "light_blue_candle", "light_blue_candle_lit": "light_blue_candle_lit", "light_blue_concrete": "light_blue_concrete", @@ -1675,7 +1810,7 @@ "light_blue_terracotta": "light_blue_terracotta", "light_blue_wool": "light_blue_wool", "light_dirt_background": "gui/light_dirt_background", - "light_gray": "entity/bed/light_gray", + "light_gray": "entity/llama/decor/light_gray", "light_gray_candle": "light_gray_candle", "light_gray_candle_lit": "light_gray_candle_lit", "light_gray_concrete": "light_gray_concrete", @@ -1692,7 +1827,7 @@ "lilac_top": "lilac_top", "lily_of_the_valley": "lily_of_the_valley", "lily_pad": "lily_pad", - "lime": "entity/bed/lime", + "lime": "entity/llama/decor/lime", "lime_candle": "lime_candle", "lime_candle_lit": "lime_candle_lit", "lime_concrete": "lime_concrete", @@ -1703,7 +1838,14 @@ "lime_stained_glass_pane_top": "lime_stained_glass_pane_top", "lime_terracotta": "lime_terracotta", "lime_wool": "lime_wool", - "link_icons": null, + "link": "gui/sprites/icon/link", + "link_highlighted": "gui/sprites/icon/link_highlighted", + "lit_progress": "gui/sprites/container/blast_furnace/lit_progress", + "llama_armor_slot": "gui/sprites/container/horse/llama_armor_slot", + "locked": "gui/sprites/container/cartography_table/locked", + "locked_button": "gui/sprites/widget/locked_button", + "locked_button_disabled": "gui/sprites/widget/locked_button_disabled", + "locked_button_highlighted": "gui/sprites/widget/locked_button_highlighted", "lodestone_side": "lodestone_side", "lodestone_top": "lodestone_top", "loom": "gui/container/loom", @@ -1712,7 +1854,7 @@ "loom_side": "loom_side", "loom_top": "loom_top", "luck": "mob_effect/luck", - "magenta": "entity/bed/magenta", + "magenta": "entity/llama/decor/magenta", "magenta_candle": "magenta_candle", "magenta_candle_lit": "magenta_candle_lit", "magenta_concrete": "magenta_concrete", @@ -1725,8 +1867,10 @@ "magenta_wool": "magenta_wool", "magma": "magma", "magmacube": "entity/slime/magmacube", - "makena": "entity/player/wide/makena", - "mangrove": "entity/chest_boat/mangrove", + "make_operator": "gui/sprites/player_list/make_operator", + "make_operator_highlighted": "gui/sprites/player_list/make_operator_highlighted", + "makena": "entity/player/slim/makena", + "mangrove": "entity/boat/mangrove", "mangrove_door_bottom": "mangrove_door_bottom", "mangrove_door_top": "mangrove_door_top", "mangrove_leaves": "mangrove_leaves", @@ -1738,15 +1882,19 @@ "mangrove_roots_side": "mangrove_roots_side", "mangrove_roots_top": "mangrove_roots_top", "mangrove_trapdoor": "mangrove_trapdoor", + "map": "gui/sprites/container/cartography_table/map", "map_background": "map/map_background", "map_background_checkerboard": "map/map_background_checkerboard", "map_icons": "map/map_icons", + "marked_join": "gui/sprites/world_list/marked_join", + "marked_join_highlighted": "gui/sprites/world_list/marked_join_highlighted", "mason": "entity/villager/profession/mason", "match": "painting/match", "medium_amethyst_bud": "medium_amethyst_bud", "melon_side": "melon_side", "melon_stem": "melon_stem", "melon_top": "melon_top", + "minceraft": "gui/title/minceraft", "minecart": "entity/minecart", "minecraft": "gui/title/minecraft", "miner_pottery_pattern": "entity/decorated_pot/miner_pottery_pattern", @@ -1754,10 +1902,17 @@ "mojang": "entity/banner/mojang", "mojangstudios": "gui/title/mojangstudios", "moon_phases": "environment/moon_phases", + "more": "gui/sprites/notification/more", "moss_block": "moss_block", "mossy_cobblestone": "mossy_cobblestone", "mossy_stone_bricks": "mossy_stone_bricks", "mourner_pottery_pattern": "entity/decorated_pot/mourner_pottery_pattern", + "mouse": "gui/sprites/toast/mouse", + "move_down": "gui/sprites/transferable_list/move_down", + "move_down_highlighted": "gui/sprites/transferable_list/move_down_highlighted", + "move_up": "gui/sprites/transferable_list/move_up", + "move_up_highlighted": "gui/sprites/transferable_list/move_up_highlighted", + "movement_keys": "gui/sprites/toast/movement_keys", "mud": "mud", "mud_bricks": "mud_bricks", "muddy_mangrove_roots_side": "muddy_mangrove_roots_side", @@ -1765,9 +1920,11 @@ "mule": "entity/horse/mule", "mushroom_block_inside": "mushroom_block_inside", "mushroom_stem": "mushroom_stem", + "mute_button": "gui/sprites/social_interactions/mute_button", + "mute_button_highlighted": "gui/sprites/social_interactions/mute_button_highlighted", "mycelium_side": "mycelium_side", "mycelium_top": "mycelium_top", - "nausea": "misc/nausea", + "nausea": "mob_effect/nausea", "nautilus": "particle/nautilus", "nether": "gui/advancements/backgrounds/nether", "nether_bricks": "nether_bricks", @@ -1785,19 +1942,28 @@ "netherite_layer_1": "models/armor/netherite_layer_1", "netherite_layer_2": "models/armor/netherite_layer_2", "netherrack": "netherrack", - "new_world": null, - "news_icon": null, - "news_notification_mainscreen": null, + "new_realm": "gui/sprites/icon/new_realm", + "new_world": "gui/realms/new_world", + "news": "gui/sprites/icon/news", "night_vision": "mob_effect/night_vision", "nitwit": "entity/villager/profession/nitwit", + "no_realms": "gui/realms/no_realms", "nonlatin_european": "font/nonlatin_european", - "noor": "entity/player/wide/noor", + "noor": "entity/player/slim/noor", "normal": "entity/chest/normal", "normal_left": "entity/chest/normal_left", "normal_right": "entity/chest/normal_right", + "notched_10_background": "gui/sprites/boss_bar/notched_10_background", + "notched_10_progress": "gui/sprites/boss_bar/notched_10_progress", + "notched_12_background": "gui/sprites/boss_bar/notched_12_background", + "notched_12_progress": "gui/sprites/boss_bar/notched_12_progress", + "notched_20_background": "gui/sprites/boss_bar/notched_20_background", + "notched_20_progress": "gui/sprites/boss_bar/notched_20_progress", + "notched_6_background": "gui/sprites/boss_bar/notched_6_background", + "notched_6_progress": "gui/sprites/boss_bar/notched_6_progress", "note": "particle/note", "note_block": "note_block", - "oak": "entity/chest_boat/oak", + "oak": "entity/boat/oak", "oak_door_bottom": "oak_door_bottom", "oak_door_top": "oak_door_top", "oak_leaves": "oak_leaves", @@ -1815,12 +1981,10 @@ "ocelot": "entity/cat/ocelot", "ochre_froglight_side": "ochre_froglight_side", "ochre_froglight_top": "ochre_froglight_top", - "off_icon": null, - "on_icon": null, - "op_icon": null, + "open": "gui/sprites/realm_status/open", "open_eye": "entity/conduit/open_eye", "options_background": "gui/options_background", - "orange": "entity/bed/orange", + "orange": "entity/llama/decor/orange", "orange_candle": "orange_candle", "orange_candle_lit": "orange_candle_lit", "orange_concrete": "orange_concrete", @@ -1832,12 +1996,18 @@ "orange_terracotta": "orange_terracotta", "orange_tulip": "orange_tulip", "orange_wool": "orange_wool", + "out_of_stock": "gui/sprites/container/villager/out_of_stock", + "overlay_recipe": "gui/sprites/recipe_book/overlay_recipe", "oxeye_daisy": "oxeye_daisy", "oxidized_copper": "oxidized_copper", "oxidized_cut_copper": "oxidized_cut_copper", "pack": null, "packed_ice": "packed_ice", "packed_mud": "packed_mud", + "page_backward": "gui/sprites/recipe_book/page_backward", + "page_backward_highlighted": "gui/sprites/recipe_book/page_backward_highlighted", + "page_forward": "gui/sprites/recipe_book/page_forward", + "page_forward_highlighted": "gui/sprites/recipe_book/page_forward_highlighted", "panda": "entity/panda/panda", "panorama_0": "gui/title/background/panorama_0", "panorama_1": "gui/title/background/panorama_1", @@ -1851,6 +2021,10 @@ "parrot_grey": "entity/parrot/parrot_grey", "parrot_red_blue": "entity/parrot/parrot_red_blue", "parrot_yellow_blue": "entity/parrot/parrot_yellow_blue", + "pattern": "gui/sprites/container/loom/pattern", + "pattern_highlighted": "gui/sprites/container/loom/pattern_highlighted", + "pattern_selected": "gui/sprites/container/loom/pattern_selected", + "pattern_slot": "gui/sprites/container/loom/pattern_slot", "pearlescent_froglight_side": "pearlescent_froglight_side", "pearlescent_froglight_top": "pearlescent_froglight_top", "peony_bottom": "peony_bottom", @@ -1864,7 +2038,19 @@ "piglin_brute": "entity/piglin/piglin_brute", "pigscene": "painting/pigscene", "pillager": "entity/illager/pillager", - "pink": "entity/bed/pink", + "ping_1": "gui/sprites/server_list/ping_1", + "ping_2": "gui/sprites/server_list/ping_2", + "ping_3": "gui/sprites/server_list/ping_3", + "ping_4": "gui/sprites/server_list/ping_4", + "ping_5": "gui/sprites/server_list/ping_5", + "ping_unknown": "gui/sprites/icon/ping_unknown", + "pinging_1": "gui/sprites/server_list/pinging_1", + "pinging_2": "gui/sprites/server_list/pinging_2", + "pinging_3": "gui/sprites/server_list/pinging_3", + "pinging_4": "gui/sprites/server_list/pinging_4", + "pinging_5": "gui/sprites/server_list/pinging_5", + "pink": "entity/llama/decor/pink", + "pink_background": "gui/sprites/boss_bar/pink_background", "pink_candle": "pink_candle", "pink_candle_lit": "pink_candle_lit", "pink_concrete": "pink_concrete", @@ -1872,6 +2058,7 @@ "pink_glazed_terracotta": "pink_glazed_terracotta", "pink_petals": "pink_petals", "pink_petals_stem": "pink_petals_stem", + "pink_progress": "gui/sprites/boss_bar/pink_progress", "pink_shulker_box": "pink_shulker_box", "pink_stained_glass": "pink_stained_glass", "pink_stained_glass_pane_top": "pink_stained_glass_pane_top", @@ -1896,7 +2083,6 @@ "plant": "painting/plant", "playful_panda": "entity/panda/playful_panda", "plenty_pottery_pattern": "entity/decorated_pot/plenty_pottery_pattern", - "plus_icon": null, "podzol_side": "podzol_side", "podzol_top": "podzol_top", "pointed_dripstone_down_base": "pointed_dripstone_down_base", @@ -1911,6 +2097,14 @@ "pointed_dripstone_up_tip_merge": "pointed_dripstone_up_tip_merge", "pointer": "painting/pointer", "poison": "mob_effect/poison", + "poisoned_full": "gui/sprites/hud/heart/poisoned_full", + "poisoned_full_blinking": "gui/sprites/hud/heart/poisoned_full_blinking", + "poisoned_half": "gui/sprites/hud/heart/poisoned_half", + "poisoned_half_blinking": "gui/sprites/hud/heart/poisoned_half_blinking", + "poisoned_hardcore_full": "gui/sprites/hud/heart/poisoned_hardcore_full", + "poisoned_hardcore_full_blinking": "gui/sprites/hud/heart/poisoned_hardcore_full_blinking", + "poisoned_hardcore_half": "gui/sprites/hud/heart/poisoned_hardcore_half", + "poisoned_hardcore_half_blinking": "gui/sprites/hud/heart/poisoned_hardcore_half_blinking", "polarbear": "entity/bear/polarbear", "polished_andesite": "polished_andesite", "polished_basalt_side": "polished_basalt_side", @@ -1922,7 +2116,6 @@ "polished_granite": "polished_granite", "pool": "painting/pool", "poppy": "poppy", - "popup": null, "potatoes_stage0": "potatoes_stage0", "potatoes_stage1": "potatoes_stage1", "potatoes_stage2": "potatoes_stage2", @@ -1945,12 +2138,14 @@ "pumpkin_stem": "pumpkin_stem", "pumpkin_top": "pumpkin_top", "pumpkinblur": "misc/pumpkinblur", - "purple": "entity/bed/purple", + "purple": "entity/llama/decor/purple", + "purple_background": "gui/sprites/boss_bar/purple_background", "purple_candle": "purple_candle", "purple_candle_lit": "purple_candle_lit", "purple_concrete": "purple_concrete", "purple_concrete_powder": "purple_concrete_powder", "purple_glazed_terracotta": "purple_glazed_terracotta", + "purple_progress": "gui/sprites/boss_bar/purple_progress", "purple_shulker_box": "purple_shulker_box", "purple_stained_glass": "purple_stained_glass", "purple_stained_glass_pane_top": "purple_stained_glass_pane_top", @@ -1976,10 +2171,13 @@ "raw_copper_block": "raw_copper_block", "raw_gold_block": "raw_gold_block", "raw_iron_block": "raw_iron_block", - "realms": null, - "recipe_book": "gui/recipe_book", - "recipe_button": "gui/recipe_button", - "red": "entity/bed/red", + "realms": "gui/title/realms", + "recipe": "gui/sprites/toast/recipe", + "recipe_book": "gui/sprites/toast/recipe_book", + "recipe_highlighted": "gui/sprites/container/stonecutter/recipe_highlighted", + "recipe_selected": "gui/sprites/container/stonecutter/recipe_selected", + "red": "entity/cat/red", + "red_background": "gui/sprites/boss_bar/red_background", "red_candle": "red_candle", "red_candle_lit": "red_candle_lit", "red_concrete": "red_concrete", @@ -1989,6 +2187,7 @@ "red_mushroom": "red_mushroom", "red_mushroom_block": "red_mushroom_block", "red_nether_bricks": "red_nether_bricks", + "red_progress": "gui/sprites/boss_bar/red_progress", "red_sand": "red_sand", "red_sandstone": "red_sandstone", "red_sandstone_bottom": "red_sandstone_bottom", @@ -2014,16 +2213,22 @@ "reinforced_deepslate_bottom": "reinforced_deepslate_bottom", "reinforced_deepslate_side": "reinforced_deepslate_side", "reinforced_deepslate_top": "reinforced_deepslate_top", - "reject_icon": null, + "reject": "gui/sprites/pending_invite/reject", + "reject_highlighted": "gui/sprites/pending_invite/reject_highlighted", + "remove_operator": "gui/sprites/player_list/remove_operator", + "remove_operator_highlighted": "gui/sprites/player_list/remove_operator_highlighted", + "remove_player": "gui/sprites/player_list/remove_player", + "remove_player_highlighted": "gui/sprites/player_list/remove_player_highlighted", "repeater": "repeater", "repeater_on": "repeater_on", "repeating_command_block_back": "repeating_command_block_back", "repeating_command_block_conditional": "repeating_command_block_conditional", "repeating_command_block_front": "repeating_command_block_front", "repeating_command_block_side": "repeating_command_block_side", - "report_button": "gui/report_button", + "report_button": "gui/sprites/social_interactions/report_button", + "report_button_disabled": "gui/sprites/social_interactions/report_button_disabled", + "report_button_highlighted": "gui/sprites/social_interactions/report_button_highlighted", "resistance": "mob_effect/resistance", - "resource_packs": "gui/resource_packs", "respawn_anchor_bottom": "respawn_anchor_bottom", "respawn_anchor_side0": "respawn_anchor_side0", "respawn_anchor_side1": "respawn_anchor_side1", @@ -2032,13 +2237,16 @@ "respawn_anchor_side4": "respawn_anchor_side4", "respawn_anchor_top": "respawn_anchor_top", "respawn_anchor_top_off": "respawn_anchor_top_off", - "restore_icon": null, + "restore": "gui/sprites/backup/restore", + "restore_highlighted": "gui/sprites/backup/restore_highlighted", "rhombus": "entity/banner/rhombus", "rib": "trims/models/armor/rib", "rib_leggings": "trims/models/armor/rib_leggings", + "right_click": "gui/sprites/toast/right_click", "rooted_dirt": "rooted_dirt", "rose_bush_bottom": "rose_bush_bottom", "rose_bush_top": "rose_bush_top", + "saddle_slot": "gui/sprites/container/horse/saddle_slot", "salmon": "entity/fish/salmon", "salt": "entity/rabbit/salt", "sand": "sand", @@ -2050,6 +2258,11 @@ "scaffolding_bottom": "scaffolding_bottom", "scaffolding_side": "scaffolding_side", "scaffolding_top": "scaffolding_top", + "scaled_map": "gui/sprites/container/cartography_table/scaled_map", + "scroll_left": "gui/sprites/spectator/scroll_left", + "scroll_right": "gui/sprites/spectator/scroll_right", + "scroller": "gui/sprites/container/villager/scroller", + "scroller_disabled": "gui/sprites/container/villager/scroller_disabled", "sculk": "sculk", "sculk_catalyst_bottom": "sculk_catalyst_bottom", "sculk_catalyst_side": "sculk_catalyst_side", @@ -2093,9 +2306,12 @@ "sea_lantern": "sea_lantern", "sea_pickle": "sea_pickle", "seagrass": "seagrass", + "search": "gui/sprites/icon/search", + "select": "gui/sprites/transferable_list/select", + "select_highlighted": "gui/sprites/transferable_list/select_highlighted", + "selection": "gui/sprites/gamemode_switcher/selection", "sentry": "trims/models/armor/sentry", "sentry_leggings": "trims/models/armor/sentry_leggings", - "server_selection": "gui/server_selection", "sga_a": "particle/sga_a", "sga_b": "particle/sga_b", "sga_c": "particle/sga_c", @@ -2160,10 +2376,18 @@ "skull": "entity/banner/skull", "skull_and_roses": "painting/skull_and_roses", "skull_pottery_pattern": "entity/decorated_pot/skull_pottery_pattern", - "slider": "gui/slider", + "slider": "gui/sprites/widget/slider", + "slider_handle": "gui/sprites/widget/slider_handle", + "slider_handle_highlighted": "gui/sprites/widget/slider_handle_highlighted", + "slider_highlighted": "gui/sprites/widget/slider_highlighted", "slime": "entity/slime/slime", "slime_block": "slime_block", - "slot_frame": null, + "slot": "gui/sprites/container/bundle/slot", + "slot_craftable": "gui/sprites/recipe_book/slot_craftable", + "slot_frame": "gui/sprites/widget/slot_frame", + "slot_many_craftable": "gui/sprites/recipe_book/slot_many_craftable", + "slot_many_uncraftable": "gui/sprites/recipe_book/slot_many_uncraftable", + "slot_uncraftable": "gui/sprites/recipe_book/slot_uncraftable", "slow_falling": "mob_effect/slow_falling", "slowness": "mob_effect/slowness", "small_amethyst_bud": "small_amethyst_bud", @@ -2187,9 +2411,24 @@ "smooth_stone": "smooth_stone", "smooth_stone_slab_side": "smooth_stone_slab_side", "sniffer": "entity/sniffer/sniffer", - "sniffer_egg_not_cracked": "sniffer_egg_not_cracked", - "sniffer_egg_slightly_cracked": "sniffer_egg_slightly_cracked", - "sniffer_egg_very_cracked": "sniffer_egg_very_cracked", + "sniffer_egg_not_cracked_bottom": "sniffer_egg_not_cracked_bottom", + "sniffer_egg_not_cracked_east": "sniffer_egg_not_cracked_east", + "sniffer_egg_not_cracked_north": "sniffer_egg_not_cracked_north", + "sniffer_egg_not_cracked_south": "sniffer_egg_not_cracked_south", + "sniffer_egg_not_cracked_top": "sniffer_egg_not_cracked_top", + "sniffer_egg_not_cracked_west": "sniffer_egg_not_cracked_west", + "sniffer_egg_slightly_cracked_bottom": "sniffer_egg_slightly_cracked_bottom", + "sniffer_egg_slightly_cracked_east": "sniffer_egg_slightly_cracked_east", + "sniffer_egg_slightly_cracked_north": "sniffer_egg_slightly_cracked_north", + "sniffer_egg_slightly_cracked_south": "sniffer_egg_slightly_cracked_south", + "sniffer_egg_slightly_cracked_top": "sniffer_egg_slightly_cracked_top", + "sniffer_egg_slightly_cracked_west": "sniffer_egg_slightly_cracked_west", + "sniffer_egg_very_cracked_bottom": "sniffer_egg_very_cracked_bottom", + "sniffer_egg_very_cracked_east": "sniffer_egg_very_cracked_east", + "sniffer_egg_very_cracked_north": "sniffer_egg_very_cracked_north", + "sniffer_egg_very_cracked_south": "sniffer_egg_very_cracked_south", + "sniffer_egg_very_cracked_top": "sniffer_egg_very_cracked_top", + "sniffer_egg_very_cracked_west": "sniffer_egg_very_cracked_west", "snort_pottery_pattern": "entity/decorated_pot/snort_pottery_pattern", "snout": "trims/models/armor/snout", "snout_leggings": "trims/models/armor/snout_leggings", @@ -2197,7 +2436,7 @@ "snow_fox": "entity/fox/snow_fox", "snow_fox_sleep": "entity/fox/snow_fox_sleep", "snow_golem": "entity/snow_golem", - "social_interactions": "gui/social_interactions", + "social_interactions": "gui/sprites/toast/social_interactions", "sonic_boom_0": "particle/sonic_boom_0", "sonic_boom_1": "particle/sonic_boom_1", "sonic_boom_10": "particle/sonic_boom_10", @@ -2214,6 +2453,8 @@ "sonic_boom_7": "particle/sonic_boom_7", "sonic_boom_8": "particle/sonic_boom_8", "sonic_boom_9": "particle/sonic_boom_9", + "sort_down": "gui/sprites/statistics/sort_down", + "sort_up": "gui/sprites/statistics/sort_up", "soul_0": "particle/soul_0", "soul_1": "particle/soul_1", "soul_10": "particle/soul_10", @@ -2244,7 +2485,6 @@ "spark_6": "particle/spark_6", "spark_7": "particle/spark_7", "spawner": "spawner", - "spectator_widgets": "gui/spectator_widgets", "spectral_arrow": "entity/projectiles/spectral_arrow", "speed": "mob_effect/speed", "spell_0": "particle/spell_0", @@ -2267,7 +2507,7 @@ "sponge": "sponge", "spore_blossom": "spore_blossom", "spore_blossom_base": "spore_blossom_base", - "spruce": "entity/chest_boat/spruce", + "spruce": "entity/boat/spruce", "spruce_door_bottom": "spruce_door_bottom", "spruce_door_top": "spruce_door_top", "spruce_leaves": "spruce_leaves", @@ -2283,8 +2523,7 @@ "square_top_right": "entity/banner/square_top_right", "squid": "entity/squid/squid", "stage": "painting/stage", - "stats_icons": "gui/container/stats_icons", - "steve": "entity/player/wide/steve", + "steve": "entity/player/slim/steve", "stone": "stone", "stone_bricks": "stone_bricks", "stonecutter": "gui/container/stonecutter", @@ -2295,7 +2534,6 @@ "straight_cross": "entity/banner/straight_cross", "stray": "entity/skeleton/stray", "stray_overlay": "entity/skeleton/stray_overlay", - "stream_indicator": "gui/stream_indicator", "strength": "mob_effect/strength", "strider": "entity/strider/strider", "strider_cold": "entity/strider/strider_cold", @@ -2341,9 +2579,9 @@ "sunflower_bottom": "sunflower_bottom", "sunflower_front": "sunflower_front", "sunflower_top": "sunflower_top", - "sunny": "entity/player/wide/sunny", + "sunny": "entity/player/slim/sunny", "sunset": "painting/sunset", - "survival_spawn": null, + "survival_spawn": "gui/realms/survival_spawn", "suspicious_gravel_0": "suspicious_gravel_0", "suspicious_gravel_1": "suspicious_gravel_1", "suspicious_gravel_2": "suspicious_gravel_2", @@ -2365,12 +2603,67 @@ "sweet_berry_bush_stage1": "sweet_berry_bush_stage1", "sweet_berry_bush_stage2": "sweet_berry_bush_stage2", "sweet_berry_bush_stage3": "sweet_berry_bush_stage3", - "tab_button": "gui/tab_button", + "system": "gui/sprites/toast/system", + "tab": "gui/sprites/recipe_book/tab", + "tab_above_left": "gui/sprites/advancements/tab_above_left", + "tab_above_left_selected": "gui/sprites/advancements/tab_above_left_selected", + "tab_above_middle": "gui/sprites/advancements/tab_above_middle", + "tab_above_middle_selected": "gui/sprites/advancements/tab_above_middle_selected", + "tab_above_right": "gui/sprites/advancements/tab_above_right", + "tab_above_right_selected": "gui/sprites/advancements/tab_above_right_selected", + "tab_below_left": "gui/sprites/advancements/tab_below_left", + "tab_below_left_selected": "gui/sprites/advancements/tab_below_left_selected", + "tab_below_middle": "gui/sprites/advancements/tab_below_middle", + "tab_below_middle_selected": "gui/sprites/advancements/tab_below_middle_selected", + "tab_below_right": "gui/sprites/advancements/tab_below_right", + "tab_below_right_selected": "gui/sprites/advancements/tab_below_right_selected", + "tab_bottom_selected_1": "gui/sprites/container/creative_inventory/tab_bottom_selected_1", + "tab_bottom_selected_2": "gui/sprites/container/creative_inventory/tab_bottom_selected_2", + "tab_bottom_selected_3": "gui/sprites/container/creative_inventory/tab_bottom_selected_3", + "tab_bottom_selected_4": "gui/sprites/container/creative_inventory/tab_bottom_selected_4", + "tab_bottom_selected_5": "gui/sprites/container/creative_inventory/tab_bottom_selected_5", + "tab_bottom_selected_6": "gui/sprites/container/creative_inventory/tab_bottom_selected_6", + "tab_bottom_selected_7": "gui/sprites/container/creative_inventory/tab_bottom_selected_7", + "tab_bottom_unselected_1": "gui/sprites/container/creative_inventory/tab_bottom_unselected_1", + "tab_bottom_unselected_2": "gui/sprites/container/creative_inventory/tab_bottom_unselected_2", + "tab_bottom_unselected_3": "gui/sprites/container/creative_inventory/tab_bottom_unselected_3", + "tab_bottom_unselected_4": "gui/sprites/container/creative_inventory/tab_bottom_unselected_4", + "tab_bottom_unselected_5": "gui/sprites/container/creative_inventory/tab_bottom_unselected_5", + "tab_bottom_unselected_6": "gui/sprites/container/creative_inventory/tab_bottom_unselected_6", + "tab_bottom_unselected_7": "gui/sprites/container/creative_inventory/tab_bottom_unselected_7", + "tab_highlighted": "gui/sprites/widget/tab_highlighted", "tab_inventory": "gui/container/creative_inventory/tab_inventory", "tab_item_search": "gui/container/creative_inventory/tab_item_search", "tab_items": "gui/container/creative_inventory/tab_items", + "tab_left_bottom": "gui/sprites/advancements/tab_left_bottom", + "tab_left_bottom_selected": "gui/sprites/advancements/tab_left_bottom_selected", + "tab_left_middle": "gui/sprites/advancements/tab_left_middle", + "tab_left_middle_selected": "gui/sprites/advancements/tab_left_middle_selected", + "tab_left_top": "gui/sprites/advancements/tab_left_top", + "tab_left_top_selected": "gui/sprites/advancements/tab_left_top_selected", + "tab_right_bottom": "gui/sprites/advancements/tab_right_bottom", + "tab_right_bottom_selected": "gui/sprites/advancements/tab_right_bottom_selected", + "tab_right_middle": "gui/sprites/advancements/tab_right_middle", + "tab_right_middle_selected": "gui/sprites/advancements/tab_right_middle_selected", + "tab_right_top": "gui/sprites/advancements/tab_right_top", + "tab_right_top_selected": "gui/sprites/advancements/tab_right_top_selected", + "tab_selected": "gui/sprites/recipe_book/tab_selected", + "tab_selected_highlighted": "gui/sprites/widget/tab_selected_highlighted", + "tab_top_selected_1": "gui/sprites/container/creative_inventory/tab_top_selected_1", + "tab_top_selected_2": "gui/sprites/container/creative_inventory/tab_top_selected_2", + "tab_top_selected_3": "gui/sprites/container/creative_inventory/tab_top_selected_3", + "tab_top_selected_4": "gui/sprites/container/creative_inventory/tab_top_selected_4", + "tab_top_selected_5": "gui/sprites/container/creative_inventory/tab_top_selected_5", + "tab_top_selected_6": "gui/sprites/container/creative_inventory/tab_top_selected_6", + "tab_top_selected_7": "gui/sprites/container/creative_inventory/tab_top_selected_7", + "tab_top_unselected_1": "gui/sprites/container/creative_inventory/tab_top_unselected_1", + "tab_top_unselected_2": "gui/sprites/container/creative_inventory/tab_top_unselected_2", + "tab_top_unselected_3": "gui/sprites/container/creative_inventory/tab_top_unselected_3", + "tab_top_unselected_4": "gui/sprites/container/creative_inventory/tab_top_unselected_4", + "tab_top_unselected_5": "gui/sprites/container/creative_inventory/tab_top_unselected_5", + "tab_top_unselected_6": "gui/sprites/container/creative_inventory/tab_top_unselected_6", + "tab_top_unselected_7": "gui/sprites/container/creative_inventory/tab_top_unselected_7", "tabby": "entity/cat/tabby", - "tabs": "gui/advancements/tabs", "tadpole": "entity/tadpole/tadpole", "taiga": "entity/villager/type/taiga", "tall_grass_bottom": "tall_grass_bottom", @@ -2379,29 +2672,37 @@ "tall_seagrass_top": "tall_seagrass_top", "target_side": "target_side", "target_top": "target_top", + "task_frame_obtained": "gui/sprites/advancements/task_frame_obtained", + "task_frame_unobtained": "gui/sprites/advancements/task_frame_unobtained", + "teleport_to_player": "gui/sprites/spectator/teleport_to_player", + "teleport_to_team": "gui/sprites/spectator/teleport_to_team", "temperate_frog": "entity/frog/temperate_frog", "terracotta": "terracotta", + "text_field": "gui/sprites/container/anvil/text_field", + "text_field_disabled": "gui/sprites/container/anvil/text_field_disabled", + "text_field_highlighted": "gui/sprites/widget/text_field_highlighted", "tide": "trims/models/armor/tide", "tide_leggings": "trims/models/armor/tide_leggings", "tinted_glass": "tinted_glass", "tipped_arrow": "entity/projectiles/tipped_arrow", + "title_box": "gui/sprites/advancements/title_box", "tnt_bottom": "tnt_bottom", "tnt_side": "tnt_side", "tnt_top": "tnt_top", "toast": "entity/rabbit/toast", - "toasts": "gui/toasts", "toolsmith": "entity/villager/profession/toolsmith", "torch": "torch", "torchflower": "torchflower", "torchflower_crop_stage0": "torchflower_crop_stage0", "torchflower_crop_stage1": "torchflower_crop_stage1", - "torchflower_crop_stage2": "torchflower_crop_stage2", + "trade_arrow": "gui/sprites/container/villager/trade_arrow", + "trade_arrow_out_of_stock": "gui/sprites/container/villager/trade_arrow_out_of_stock", "trader_llama": "entity/llama/decor/trader_llama", - "trailer_icons": null, "trapped": "entity/chest/trapped", "trapped_left": "entity/chest/trapped_left", "trapped_right": "entity/chest/trapped_right", - "trial_icon": null, + "tree": "gui/sprites/toast/tree", + "trial_available": "gui/sprites/icon/trial_available", "triangle_bottom": "entity/banner/triangle_bottom", "triangle_top": "entity/banner/triangle_top", "triangles_bottom": "entity/banner/triangles_bottom", @@ -2433,246 +2734,36 @@ "turtle_egg_slightly_cracked": "turtle_egg_slightly_cracked", "turtle_egg_very_cracked": "turtle_egg_very_cracked", "turtle_layer_1": "models/armor/turtle_layer_1", + "tutorial": "gui/sprites/toast/tutorial", "twisting_vines": "twisting_vines", "twisting_vines_plant": "twisting_vines_plant", "underwater": "misc/underwater", - "unicode_page_00": "font/unicode_page_00", - "unicode_page_01": "font/unicode_page_01", - "unicode_page_02": "font/unicode_page_02", - "unicode_page_03": "font/unicode_page_03", - "unicode_page_04": "font/unicode_page_04", - "unicode_page_05": "font/unicode_page_05", - "unicode_page_06": "font/unicode_page_06", - "unicode_page_07": "font/unicode_page_07", - "unicode_page_09": "font/unicode_page_09", - "unicode_page_0a": "font/unicode_page_0a", - "unicode_page_0b": "font/unicode_page_0b", - "unicode_page_0c": "font/unicode_page_0c", - "unicode_page_0d": "font/unicode_page_0d", - "unicode_page_0e": "font/unicode_page_0e", - "unicode_page_0f": "font/unicode_page_0f", - "unicode_page_10": "font/unicode_page_10", - "unicode_page_11": "font/unicode_page_11", - "unicode_page_12": "font/unicode_page_12", - "unicode_page_13": "font/unicode_page_13", - "unicode_page_14": "font/unicode_page_14", - "unicode_page_15": "font/unicode_page_15", - "unicode_page_16": "font/unicode_page_16", - "unicode_page_17": "font/unicode_page_17", - "unicode_page_18": "font/unicode_page_18", - "unicode_page_19": "font/unicode_page_19", - "unicode_page_1a": "font/unicode_page_1a", - "unicode_page_1b": "font/unicode_page_1b", - "unicode_page_1c": "font/unicode_page_1c", - "unicode_page_1d": "font/unicode_page_1d", - "unicode_page_1e": "font/unicode_page_1e", - "unicode_page_1f": "font/unicode_page_1f", - "unicode_page_20": "font/unicode_page_20", - "unicode_page_21": "font/unicode_page_21", - "unicode_page_22": "font/unicode_page_22", - "unicode_page_23": "font/unicode_page_23", - "unicode_page_24": "font/unicode_page_24", - "unicode_page_25": "font/unicode_page_25", - "unicode_page_26": "font/unicode_page_26", - "unicode_page_27": "font/unicode_page_27", - "unicode_page_28": "font/unicode_page_28", - "unicode_page_29": "font/unicode_page_29", - "unicode_page_2a": "font/unicode_page_2a", - "unicode_page_2b": "font/unicode_page_2b", - "unicode_page_2c": "font/unicode_page_2c", - "unicode_page_2d": "font/unicode_page_2d", - "unicode_page_2e": "font/unicode_page_2e", - "unicode_page_2f": "font/unicode_page_2f", - "unicode_page_30": "font/unicode_page_30", - "unicode_page_31": "font/unicode_page_31", - "unicode_page_32": "font/unicode_page_32", - "unicode_page_33": "font/unicode_page_33", - "unicode_page_34": "font/unicode_page_34", - "unicode_page_35": "font/unicode_page_35", - "unicode_page_36": "font/unicode_page_36", - "unicode_page_37": "font/unicode_page_37", - "unicode_page_38": "font/unicode_page_38", - "unicode_page_39": "font/unicode_page_39", - "unicode_page_3a": "font/unicode_page_3a", - "unicode_page_3b": "font/unicode_page_3b", - "unicode_page_3c": "font/unicode_page_3c", - "unicode_page_3d": "font/unicode_page_3d", - "unicode_page_3e": "font/unicode_page_3e", - "unicode_page_3f": "font/unicode_page_3f", - "unicode_page_40": "font/unicode_page_40", - "unicode_page_41": "font/unicode_page_41", - "unicode_page_42": "font/unicode_page_42", - "unicode_page_43": "font/unicode_page_43", - "unicode_page_44": "font/unicode_page_44", - "unicode_page_45": "font/unicode_page_45", - "unicode_page_46": "font/unicode_page_46", - "unicode_page_47": "font/unicode_page_47", - "unicode_page_48": "font/unicode_page_48", - "unicode_page_49": "font/unicode_page_49", - "unicode_page_4a": "font/unicode_page_4a", - "unicode_page_4b": "font/unicode_page_4b", - "unicode_page_4c": "font/unicode_page_4c", - "unicode_page_4d": "font/unicode_page_4d", - "unicode_page_4e": "font/unicode_page_4e", - "unicode_page_4f": "font/unicode_page_4f", - "unicode_page_50": "font/unicode_page_50", - "unicode_page_51": "font/unicode_page_51", - "unicode_page_52": "font/unicode_page_52", - "unicode_page_53": "font/unicode_page_53", - "unicode_page_54": "font/unicode_page_54", - "unicode_page_55": "font/unicode_page_55", - "unicode_page_56": "font/unicode_page_56", - "unicode_page_57": "font/unicode_page_57", - "unicode_page_58": "font/unicode_page_58", - "unicode_page_59": "font/unicode_page_59", - "unicode_page_5a": "font/unicode_page_5a", - "unicode_page_5b": "font/unicode_page_5b", - "unicode_page_5c": "font/unicode_page_5c", - "unicode_page_5d": "font/unicode_page_5d", - "unicode_page_5e": "font/unicode_page_5e", - "unicode_page_5f": "font/unicode_page_5f", - "unicode_page_60": "font/unicode_page_60", - "unicode_page_61": "font/unicode_page_61", - "unicode_page_62": "font/unicode_page_62", - "unicode_page_63": "font/unicode_page_63", - "unicode_page_64": "font/unicode_page_64", - "unicode_page_65": "font/unicode_page_65", - "unicode_page_66": "font/unicode_page_66", - "unicode_page_67": "font/unicode_page_67", - "unicode_page_68": "font/unicode_page_68", - "unicode_page_69": "font/unicode_page_69", - "unicode_page_6a": "font/unicode_page_6a", - "unicode_page_6b": "font/unicode_page_6b", - "unicode_page_6c": "font/unicode_page_6c", - "unicode_page_6d": "font/unicode_page_6d", - "unicode_page_6e": "font/unicode_page_6e", - "unicode_page_6f": "font/unicode_page_6f", - "unicode_page_70": "font/unicode_page_70", - "unicode_page_71": "font/unicode_page_71", - "unicode_page_72": "font/unicode_page_72", - "unicode_page_73": "font/unicode_page_73", - "unicode_page_74": "font/unicode_page_74", - "unicode_page_75": "font/unicode_page_75", - "unicode_page_76": "font/unicode_page_76", - "unicode_page_77": "font/unicode_page_77", - "unicode_page_78": "font/unicode_page_78", - "unicode_page_79": "font/unicode_page_79", - "unicode_page_7a": "font/unicode_page_7a", - "unicode_page_7b": "font/unicode_page_7b", - "unicode_page_7c": "font/unicode_page_7c", - "unicode_page_7d": "font/unicode_page_7d", - "unicode_page_7e": "font/unicode_page_7e", - "unicode_page_7f": "font/unicode_page_7f", - "unicode_page_80": "font/unicode_page_80", - "unicode_page_81": "font/unicode_page_81", - "unicode_page_82": "font/unicode_page_82", - "unicode_page_83": "font/unicode_page_83", - "unicode_page_84": "font/unicode_page_84", - "unicode_page_85": "font/unicode_page_85", - "unicode_page_86": "font/unicode_page_86", - "unicode_page_87": "font/unicode_page_87", - "unicode_page_88": "font/unicode_page_88", - "unicode_page_89": "font/unicode_page_89", - "unicode_page_8a": "font/unicode_page_8a", - "unicode_page_8b": "font/unicode_page_8b", - "unicode_page_8c": "font/unicode_page_8c", - "unicode_page_8d": "font/unicode_page_8d", - "unicode_page_8e": "font/unicode_page_8e", - "unicode_page_8f": "font/unicode_page_8f", - "unicode_page_90": "font/unicode_page_90", - "unicode_page_91": "font/unicode_page_91", - "unicode_page_92": "font/unicode_page_92", - "unicode_page_93": "font/unicode_page_93", - "unicode_page_94": "font/unicode_page_94", - "unicode_page_95": "font/unicode_page_95", - "unicode_page_96": "font/unicode_page_96", - "unicode_page_97": "font/unicode_page_97", - "unicode_page_98": "font/unicode_page_98", - "unicode_page_99": "font/unicode_page_99", - "unicode_page_9a": "font/unicode_page_9a", - "unicode_page_9b": "font/unicode_page_9b", - "unicode_page_9c": "font/unicode_page_9c", - "unicode_page_9d": "font/unicode_page_9d", - "unicode_page_9e": "font/unicode_page_9e", - "unicode_page_9f": "font/unicode_page_9f", - "unicode_page_a0": "font/unicode_page_a0", - "unicode_page_a1": "font/unicode_page_a1", - "unicode_page_a2": "font/unicode_page_a2", - "unicode_page_a3": "font/unicode_page_a3", - "unicode_page_a4": "font/unicode_page_a4", - "unicode_page_a5": "font/unicode_page_a5", - "unicode_page_a6": "font/unicode_page_a6", - "unicode_page_a7": "font/unicode_page_a7", - "unicode_page_a8": "font/unicode_page_a8", - "unicode_page_a9": "font/unicode_page_a9", - "unicode_page_aa": "font/unicode_page_aa", - "unicode_page_ab": "font/unicode_page_ab", - "unicode_page_ac": "font/unicode_page_ac", - "unicode_page_ad": "font/unicode_page_ad", - "unicode_page_ae": "font/unicode_page_ae", - "unicode_page_af": "font/unicode_page_af", - "unicode_page_b0": "font/unicode_page_b0", - "unicode_page_b1": "font/unicode_page_b1", - "unicode_page_b2": "font/unicode_page_b2", - "unicode_page_b3": "font/unicode_page_b3", - "unicode_page_b4": "font/unicode_page_b4", - "unicode_page_b5": "font/unicode_page_b5", - "unicode_page_b6": "font/unicode_page_b6", - "unicode_page_b7": "font/unicode_page_b7", - "unicode_page_b8": "font/unicode_page_b8", - "unicode_page_b9": "font/unicode_page_b9", - "unicode_page_ba": "font/unicode_page_ba", - "unicode_page_bb": "font/unicode_page_bb", - "unicode_page_bc": "font/unicode_page_bc", - "unicode_page_bd": "font/unicode_page_bd", - "unicode_page_be": "font/unicode_page_be", - "unicode_page_bf": "font/unicode_page_bf", - "unicode_page_c0": "font/unicode_page_c0", - "unicode_page_c1": "font/unicode_page_c1", - "unicode_page_c2": "font/unicode_page_c2", - "unicode_page_c3": "font/unicode_page_c3", - "unicode_page_c4": "font/unicode_page_c4", - "unicode_page_c5": "font/unicode_page_c5", - "unicode_page_c6": "font/unicode_page_c6", - "unicode_page_c7": "font/unicode_page_c7", - "unicode_page_c8": "font/unicode_page_c8", - "unicode_page_c9": "font/unicode_page_c9", - "unicode_page_ca": "font/unicode_page_ca", - "unicode_page_cb": "font/unicode_page_cb", - "unicode_page_cc": "font/unicode_page_cc", - "unicode_page_cd": "font/unicode_page_cd", - "unicode_page_ce": "font/unicode_page_ce", - "unicode_page_cf": "font/unicode_page_cf", - "unicode_page_d0": "font/unicode_page_d0", - "unicode_page_d1": "font/unicode_page_d1", - "unicode_page_d2": "font/unicode_page_d2", - "unicode_page_d3": "font/unicode_page_d3", - "unicode_page_d4": "font/unicode_page_d4", - "unicode_page_d5": "font/unicode_page_d5", - "unicode_page_d6": "font/unicode_page_d6", - "unicode_page_d7": "font/unicode_page_d7", - "unicode_page_f9": "font/unicode_page_f9", - "unicode_page_fa": "font/unicode_page_fa", - "unicode_page_fb": "font/unicode_page_fb", - "unicode_page_fc": "font/unicode_page_fc", - "unicode_page_fd": "font/unicode_page_fd", - "unicode_page_fe": "font/unicode_page_fe", - "unicode_page_ff": "font/unicode_page_ff", "unknown_pack": "misc/unknown_pack", "unknown_server": "misc/unknown_server", + "unlocked_button": "gui/sprites/widget/unlocked_button", + "unlocked_button_disabled": "gui/sprites/widget/unlocked_button_disabled", + "unlocked_button_highlighted": "gui/sprites/widget/unlocked_button_highlighted", "unluck": "mob_effect/unluck", - "unseen_notification": "gui/unseen_notification", - "upload": null, - "user_icon": null, + "unmute_button": "gui/sprites/social_interactions/unmute_button", + "unmute_button_highlighted": "gui/sprites/social_interactions/unmute_button_highlighted", + "unreachable": "gui/sprites/server_list/unreachable", + "unseen_notification": "gui/sprites/icon/unseen_notification", + "unselect": "gui/sprites/transferable_list/unselect", + "unselect_highlighted": "gui/sprites/transferable_list/unselect_highlighted", + "upload": "gui/realms/upload", + "vehicle_container": "gui/sprites/hud/heart/vehicle_container", + "vehicle_full": "gui/sprites/hud/heart/vehicle_full", + "vehicle_half": "gui/sprites/hud/heart/vehicle_half", "verdant_froglight_side": "verdant_froglight_side", "verdant_froglight_top": "verdant_froglight_top", - "vex": "entity/illager/vex", + "vex": "trims/models/armor/vex", "vex_charging": "entity/illager/vex_charging", "vex_leggings": "trims/models/armor/vex_leggings", "vibration": "particle/vibration", + "video_link": "gui/sprites/icon/video_link", + "video_link_highlighted": "gui/sprites/icon/video_link_highlighted", "vignette": "misc/vignette", "villager": "entity/villager/villager", - "villager2": "gui/container/villager2", "vindicator": "entity/illager/vindicator", "vine": "vine", "void": "painting/void", @@ -2686,7 +2777,9 @@ "warden_pulsating_spots_1": "entity/warden/warden_pulsating_spots_1", "warden_pulsating_spots_2": "entity/warden/warden_pulsating_spots_2", "warm_frog": "entity/frog/warm_frog", - "warped": "entity/signs/warped", + "warning": "gui/sprites/world_list/warning", + "warning_highlighted": "gui/sprites/world_list/warning_highlighted", + "warped": "entity/signs/hanging/warped", "warped_door_bottom": "warped_door_bottom", "warped_door_top": "warped_door_top", "warped_fungus": "warped_fungus", @@ -2723,12 +2816,14 @@ "wheat_stage5": "wheat_stage5", "wheat_stage6": "wheat_stage6", "wheat_stage7": "wheat_stage7", - "white": "entity/rabbit/white", + "white": "entity/cat/white", + "white_background": "gui/sprites/boss_bar/white_background", "white_candle": "white_candle", "white_candle_lit": "white_candle_lit", "white_concrete": "white_concrete", "white_concrete_powder": "white_concrete_powder", "white_glazed_terracotta": "white_glazed_terracotta", + "white_progress": "gui/sprites/boss_bar/white_progress", "white_shulker_box": "white_shulker_box", "white_splotched": "entity/rabbit/white_splotched", "white_stained_glass": "white_stained_glass", @@ -2736,7 +2831,6 @@ "white_terracotta": "white_terracotta", "white_tulip": "white_tulip", "white_wool": "white_wool", - "widgets": "gui/advancements/widgets", "wild": "trims/models/armor/wild", "wild_leggings": "trims/models/armor/wild_leggings", "wind": "entity/conduit/wind", @@ -2748,20 +2842,29 @@ "wither_invulnerable": "entity/wither/wither_invulnerable", "wither_rose": "wither_rose", "wither_skeleton": "entity/skeleton/wither_skeleton", + "withered_full": "gui/sprites/hud/heart/withered_full", + "withered_full_blinking": "gui/sprites/hud/heart/withered_full_blinking", + "withered_half": "gui/sprites/hud/heart/withered_half", + "withered_half_blinking": "gui/sprites/hud/heart/withered_half_blinking", + "withered_hardcore_full": "gui/sprites/hud/heart/withered_hardcore_full", + "withered_hardcore_full_blinking": "gui/sprites/hud/heart/withered_hardcore_full_blinking", + "withered_hardcore_half": "gui/sprites/hud/heart/withered_hardcore_half", + "withered_hardcore_half_blinking": "gui/sprites/hud/heart/withered_hardcore_half_blinking", "wolf": "entity/wolf/wolf", "wolf_angry": "entity/wolf/wolf_angry", "wolf_collar": "entity/wolf/wolf_collar", "wolf_tame": "entity/wolf/wolf_tame", "wood": "entity/armorstand/wood", - "world_icon": null, - "world_selection": "gui/world_selection", + "wooden_planks": "gui/sprites/toast/wooden_planks", "worried_panda": "entity/panda/worried_panda", - "yellow": "entity/bed/yellow", + "yellow": "entity/llama/decor/yellow", + "yellow_background": "gui/sprites/boss_bar/yellow_background", "yellow_candle": "yellow_candle", "yellow_candle_lit": "yellow_candle_lit", "yellow_concrete": "yellow_concrete", "yellow_concrete_powder": "yellow_concrete_powder", "yellow_glazed_terracotta": "yellow_glazed_terracotta", + "yellow_progress": "gui/sprites/boss_bar/yellow_progress", "yellow_shulker_box": "yellow_shulker_box", "yellow_stained_glass": "yellow_stained_glass", "yellow_stained_glass_pane_top": "yellow_stained_glass_pane_top", @@ -2771,9 +2874,10 @@ "zombie": "entity/zombie/zombie", "zombie_villager": "entity/zombie_villager/zombie_villager", "zombified_piglin": "entity/piglin/zombified_piglin", - "zuri": "entity/player/wide/zuri" + "zuri": "entity/player/slim/zuri" }, "block_mapping_mineways": { + "": "", "1": "", "2": "", "3": "large_fern_bottom", @@ -2914,11 +3018,22 @@ "azalea_side": "azalea_side", "azalea_top": "azalea_top", "azure_bluet": "azure_bluet", + "bamboo_block": "bamboo_block", + "bamboo_block_top": "bamboo_block_top", + "bamboo_door_bottom": "bamboo_door_bottom", + "bamboo_door_top": "bamboo_door_top", + "bamboo_fence": "bamboo_fence", + "bamboo_fence_gate": "bamboo_fence_gate", + "bamboo_fence_gate_particle": "bamboo_fence_gate_particle", + "bamboo_fence_particle": "bamboo_fence_particle", "bamboo_large_leaves": "bamboo_large_leaves", + "bamboo_mosaic": "bamboo_mosaic", + "bamboo_planks": "bamboo_planks", "bamboo_singleleaf": "bamboo_singleleaf", "bamboo_small_leaves": "bamboo_small_leaves", "bamboo_stage0": "bamboo_stage0", "bamboo_stalk": "bamboo_stalk", + "bamboo_trapdoor": "bamboo_trapdoor", "barrel_bottom": "barrel_bottom", "barrel_side": "barrel_side", "barrel_top": "barrel_top", @@ -3017,6 +3132,9 @@ "cake_side": "cake_side", "cake_top": "cake_top", "calcite": "calcite", + "calibrated_sculk_sensor_amethyst": "calibrated_sculk_sensor_amethyst", + "calibrated_sculk_sensor_input_side": "calibrated_sculk_sensor_input_side", + "calibrated_sculk_sensor_top": "calibrated_sculk_sensor_top", "campfire_fire": "campfire_fire", "campfire_log": "campfire_log", "campfire_log_lit": "campfire_log_lit", @@ -3044,7 +3162,19 @@ "chain_command_block_conditional": "chain_command_block_conditional", "chain_command_block_front": "chain_command_block_front", "chain_command_block_side": "chain_command_block_side", + "cherry_door_bottom": "cherry_door_bottom", + "cherry_door_top": "cherry_door_top", + "cherry_leaves": "cherry_leaves", + "cherry_log": "cherry_log", + "cherry_log_top": "cherry_log_top", + "cherry_planks": "cherry_planks", + "cherry_sapling": "cherry_sapling", + "cherry_trapdoor": "cherry_trapdoor", "chipped_anvil_top": "chipped_anvil_top", + "chiseled_bookshelf_empty": "chiseled_bookshelf_empty", + "chiseled_bookshelf_occupied": "chiseled_bookshelf_occupied", + "chiseled_bookshelf_side": "chiseled_bookshelf_side", + "chiseled_bookshelf_top": "chiseled_bookshelf_top", "chiseled_deepslate": "chiseled_deepslate", "chiseled_nether_bricks": "chiseled_nether_bricks", "chiseled_polished_blackstone": "chiseled_polished_blackstone", @@ -3144,6 +3274,7 @@ "dead_tube_coral": "dead_tube_coral", "dead_tube_coral_block": "dead_tube_coral_block", "dead_tube_coral_fan": "dead_tube_coral_fan", + "decorated_pot_side": "decorated_pot_side", "deepslate": "deepslate", "deepslate_bricks": "deepslate_bricks", "deepslate_coal_ore": "deepslate_coal_ore", @@ -3429,6 +3560,8 @@ "pink_concrete": "pink_concrete", "pink_concrete_powder": "pink_concrete_powder", "pink_glazed_terracotta": "pink_glazed_terracotta", + "pink_petals": "pink_petals", + "pink_petals_stem": "pink_petals_stem", "pink_shulker_box": "pink_shulker_box", "pink_stained_glass": "pink_stained_glass", "pink_stained_glass_pane_top": "pink_stained_glass_pane_top", @@ -3440,6 +3573,15 @@ "piston_side": "piston_side", "piston_top": "piston_top", "piston_top_sticky": "piston_top_sticky", + "pitcher_crop_bottom": "pitcher_crop_bottom", + "pitcher_crop_bottom_stage_1": "pitcher_crop_bottom_stage_1", + "pitcher_crop_bottom_stage_2": "pitcher_crop_bottom_stage_2", + "pitcher_crop_bottom_stage_3": "pitcher_crop_bottom_stage_3", + "pitcher_crop_bottom_stage_4": "pitcher_crop_bottom_stage_4", + "pitcher_crop_side": "pitcher_crop_side", + "pitcher_crop_top": "pitcher_crop_top", + "pitcher_crop_top_stage_3": "pitcher_crop_top_stage_3", + "pitcher_crop_top_stage_4": "pitcher_crop_top_stage_4", "podzol_side": "podzol_side", "podzol_top": "podzol_top", "pointed_dripstone_down_base": "pointed_dripstone_down_base", @@ -3629,6 +3771,24 @@ "smooth_basalt": "smooth_basalt", "smooth_stone": "smooth_stone", "smooth_stone_slab_side": "smooth_stone_slab_side", + "sniffer_egg_not_cracked_bottom": "sniffer_egg_not_cracked_bottom", + "sniffer_egg_not_cracked_east": "sniffer_egg_not_cracked_east", + "sniffer_egg_not_cracked_north": "sniffer_egg_not_cracked_north", + "sniffer_egg_not_cracked_south": "sniffer_egg_not_cracked_south", + "sniffer_egg_not_cracked_top": "sniffer_egg_not_cracked_top", + "sniffer_egg_not_cracked_west": "sniffer_egg_not_cracked_west", + "sniffer_egg_slightly_cracked_bottom": "sniffer_egg_slightly_cracked_bottom", + "sniffer_egg_slightly_cracked_east": "sniffer_egg_slightly_cracked_east", + "sniffer_egg_slightly_cracked_north": "sniffer_egg_slightly_cracked_north", + "sniffer_egg_slightly_cracked_south": "sniffer_egg_slightly_cracked_south", + "sniffer_egg_slightly_cracked_top": "sniffer_egg_slightly_cracked_top", + "sniffer_egg_slightly_cracked_west": "sniffer_egg_slightly_cracked_west", + "sniffer_egg_very_cracked_bottom": "sniffer_egg_very_cracked_bottom", + "sniffer_egg_very_cracked_east": "sniffer_egg_very_cracked_east", + "sniffer_egg_very_cracked_north": "sniffer_egg_very_cracked_north", + "sniffer_egg_very_cracked_south": "sniffer_egg_very_cracked_south", + "sniffer_egg_very_cracked_top": "sniffer_egg_very_cracked_top", + "sniffer_egg_very_cracked_west": "sniffer_egg_very_cracked_west", "snow": "snow", "soul_campfire_fire": "soul_campfire_fire", "soul_campfire_log_lit": "soul_campfire_log_lit", @@ -3659,8 +3819,12 @@ "stonecutter_top": "stonecutter_top", "stripped_acacia_log": "stripped_acacia_log", "stripped_acacia_log_top": "stripped_acacia_log_top", + "stripped_bamboo_block": "stripped_bamboo_block", + "stripped_bamboo_block_top": "stripped_bamboo_block_top", "stripped_birch_log": "stripped_birch_log", "stripped_birch_log_top": "stripped_birch_log_top", + "stripped_cherry_log": "stripped_cherry_log", + "stripped_cherry_log_top": "stripped_cherry_log_top", "stripped_crimson_stem": "stripped_crimson_stem", "stripped_crimson_stem_top": "stripped_crimson_stem_top", "stripped_dark_oak_log": "stripped_dark_oak_log", @@ -3684,6 +3848,14 @@ "sunflower_bottom": "sunflower_bottom", "sunflower_front": "sunflower_front", "sunflower_top": "sunflower_top", + "suspicious_gravel_0": "suspicious_gravel_0", + "suspicious_gravel_1": "suspicious_gravel_1", + "suspicious_gravel_2": "suspicious_gravel_2", + "suspicious_gravel_3": "suspicious_gravel_3", + "suspicious_sand_0": "suspicious_sand_0", + "suspicious_sand_1": "suspicious_sand_1", + "suspicious_sand_2": "suspicious_sand_2", + "suspicious_sand_3": "suspicious_sand_3", "sweet_berry_bush_stage0": "sweet_berry_bush_stage0", "sweet_berry_bush_stage1": "sweet_berry_bush_stage1", "sweet_berry_bush_stage2": "sweet_berry_bush_stage2", @@ -3700,6 +3872,9 @@ "tnt_side": "tnt_side", "tnt_top": "tnt_top", "torch": "torch", + "torchflower": "torchflower", + "torchflower_crop_stage0": "torchflower_crop_stage0", + "torchflower_crop_stage1": "torchflower_crop_stage1", "tripwire": "tripwire", "tripwire_hook": "tripwire_hook", "tube_coral": "tube_coral", @@ -4114,5 +4289,25 @@ "Glow Squid", "Strider", "Warden" + ], + "unspawnable_for_now": [ + "banner", + "bed", + "bell", + "brewing_stand", + "cauldron", + "chest", + "double_chest", + "ender_chest", + "enchanting_table", + "end_portal", + "hopper", + "moving_piston", + "piston", + "scaffolding", + "shulker_box", + "sunflower", + "water_still", + "water" ] } \ No newline at end of file diff --git a/MCprep_addon/__init__.py b/MCprep_addon/__init__.py index 1982f630..f79dc6cb 100755 --- a/MCprep_addon/__init__.py +++ b/MCprep_addon/__init__.py @@ -41,7 +41,7 @@ bl_info = { "name": "MCprep", "category": "Object", - "version": (3, 4, 3), + "version": (3, 5, 0), "blender": (2, 80, 0), "location": "3D window toolshelf > MCprep tab", "description": "Minecraft workflow addon for rendering and animation", diff --git a/MCprep_addon/addon_updater_ops.py b/MCprep_addon/addon_updater_ops.py index ba1c39d8..8af5b8a1 100644 --- a/MCprep_addon/addon_updater_ops.py +++ b/MCprep_addon/addon_updater_ops.py @@ -81,7 +81,11 @@ def check_for_update(self, now): # Blender version utils # ----------------------------------------------------------------------------- def make_annotations(cls): - """Add annotation attribute to fields to avoid Blender 2.8+ warnings""" + """Add annotation attribute to fields to avoid Blender 2.8+ warnings. + + Deprecated operator as MCprep 3.5 moves to support 2.8+ only and using + native python annotations. + """ if not hasattr(bpy.app, "version") or bpy.app.version < (2, 80): return cls if bpy.app.version < (2, 93, 0): @@ -1281,6 +1285,12 @@ def skip_tag_function(self, tag): # return True # ---- write any custom code above, return true to disallow version --- # + # Ignore release candidates + skip_if_present = ['rc', 'alpha', 'beta'] + for skip_name in skip_if_present: + if skip_name in tag.get("tag_name", "").lower(): + return True + if self.include_branches: for branch in self.include_branch_list: if tag["name"].lower() == branch: @@ -1357,7 +1367,7 @@ def select_link_function(self, tag): def register(bl_info): """Registering the operators in this module""" from . import conf - updater.verbose = conf.v + updater.verbose = conf.env.verbose # safer failure in case of issue loading module if updater.error != None: diff --git a/MCprep_addon/conf.py b/MCprep_addon/conf.py index 11a0b60e..02fca07b 100644 --- a/MCprep_addon/conf.py +++ b/MCprep_addon/conf.py @@ -16,9 +16,41 @@ # # ##### END GPL LICENSE BLOCK ##### +from mathutils import Vector +from pathlib import Path +from typing import Union, Tuple, List, Dict +import enum import os import bpy +from bpy.utils.previews import ImagePreviewCollection + + +# ----------------------------------------------------------------------------- +# TYPING UTILITIES +# ----------------------------------------------------------------------------- + + +class Form(enum.Enum): + """Texture or world import interpretation, for mapping or other needs.""" + MC = "mc" + MINEWAYS = "mineways" + JMC2OBJ = "jmc2obj" + + +class Engine(enum.Enum): + """String exact match to output from blender itself for branching.""" + CYCLES = "CYCLES" + BLENDER_EEVEE = "BLENDER_EEVEE" + # EEVEE Next is the next generation EEVEE. So in preperation for that, + # we've added "BLENDER_EEVEE_NEXT" as an Engine option + BLENDER_EEVEE_NEXT = "BLENDER_EEVEE_NEXT" + + +VectorType = Union[Tuple[float, float, float], Vector] + +Skin = Tuple[str, Path] +Entity = Tuple[str, str, str] # check if custom preview icons available try: @@ -27,23 +59,174 @@ print("MCprep: No custom icons in this blender instance") pass + # ----------------------------------------------------------------------------- # ADDON GLOBAL VARIABLES AND INITIAL SETTINGS # ----------------------------------------------------------------------------- -def init(): +class MCprepEnv: + def __init__(self): + self.data = None + self.json_data = None + self.json_path: Path = Path(os.path.dirname(__file__), "MCprep_resources", "mcprep_data.json") + self.json_path_update: Path = Path(os.path.dirname(__file__), "MCprep_resources", "mcprep_data_update.json") + + self.dev_file: Path = Path(os.path.dirname(__file__), "mcprep_dev.txt") + + self.last_check_for_updated = 0 + + # Check to see if there's a text file for a dev build. If so, + if self.dev_file.exists(): + self.dev_build = True + self.verbose = True + self.very_verbose = True + self.log("Dev Build!") + + else: + self.dev_build = False + self.verbose = False + self.very_verbose = False + + # lazy load json, ie only load it when needed (util function defined) + + # ----------------------------------------------- + # For preview icons + # ----------------------------------------------- + + self.use_icons: bool = True + self.preview_collections: Dict[str, ImagePreviewCollection] = {} + + # ----------------------------------------------- + # For initializing the custom icons + # ----------------------------------------------- + self.icons_init() + + # ----------------------------------------------- + # For cross-addon lists + # ----------------------------------------------- + + # To ensure shift-A starts drawing sub menus after pressing load all spawns + # as without this, if any one of the spawners loads nothing (invalid folder, + # no blend files etc), then it would continue to ask to reload spanwers. + self.loaded_all_spawners: bool = False + + self.skin_list: List[Skin] = [] # each is: [ basename, path ] + self.rig_categories: List[str] = [] # simple list of directory names + self.entity_list: List[Entity] = [] + + # ----------------------------------------------- + # Matieral sync cahce, to avoid repeat lib reads + # ----------------------------------------------- + + # list of material names, each is a string. None by default to indicate + # that no reading has occurred. If lib not found, will update to []. + # If ever changing the resource pack, should also reset to None. + self.material_sync_cache = [] + + def update_json_dat_path(self): + """If new update file found from install, replace old one with new. + + Should be called as part of register, as otherwise this renaming will + trigger the renaming of the source file in git history when running + tests. + """ + if self.json_path_update.exists(): + self.json_path_update.replace(self.json_path) + + # ----------------------------------------------------------------------------- + # ICONS INIT + # ----------------------------------------------------------------------------- + + def icons_init(self): + self.clear_previews() + + collection_sets = [ + "main", "skins", "mobs", "entities", "blocks", "items", "effects", "materials"] + + try: + for iconset in collection_sets: + self.preview_collections[iconset] = bpy.utils.previews.new() + + script_path = bpy.path.abspath(os.path.dirname(__file__)) + icons_dir = os.path.join(script_path, 'icons') + self.preview_collections["main"].load( + "crafting_icon", + os.path.join(icons_dir, "crafting_icon.png"), + 'IMAGE') + self.preview_collections["main"].load( + "meshswap_icon", + os.path.join(icons_dir, "meshswap_icon.png"), + 'IMAGE') + self.preview_collections["main"].load( + "spawner_icon", + os.path.join(icons_dir, "spawner_icon.png"), + 'IMAGE') + self.preview_collections["main"].load( + "sword_icon", + os.path.join(icons_dir, "sword_icon.png"), + 'IMAGE') + self.preview_collections["main"].load( + "effects_icon", + os.path.join(icons_dir, "effects_icon.png"), + 'IMAGE') + self.preview_collections["main"].load( + "entity_icon", + os.path.join(icons_dir, "entity_icon.png"), + 'IMAGE') + self.preview_collections["main"].load( + "model_icon", + os.path.join(icons_dir, "model_icon.png"), + 'IMAGE') + except Exception as e: + self.log("Old verison of blender, no custom icons available") + self.log(e) + global use_icons + self.use_icons = False + for iconset in collection_sets: + self.preview_collections[iconset] = "" + + def clear_previews(self): + for pcoll in self.preview_collections.values(): + try: + bpy.utils.previews.remove(pcoll) + except Exception as e: + self.log('Issue clearing preview set ' + str(pcoll)) + print(e) + self.preview_collections.clear() + + def log(self, statement: str, vv_only: bool = False): + if self.verbose and vv_only and self.very_verbose: + print(statement) + elif self.verbose: + print(statement) + + def deprecation_warning(self): + if self.dev_build: + import traceback + self.log("Deprecation Warning: This will be removed in MCprep 3.5.1!") + traceback.print_stack() + +env = MCprepEnv() + + +# ! Deprecated as of MCprep 3.5 +def init(): + env.deprecation_warning() # ----------------------------------------------- - # Verbose, use as conf.v + # Verbose, use as env.verbose # Used to print out extra information, set false with distribution # ----------------------------------------------- + # ! Deprecated as of MCprep 3.5 global dev dev = False + # ! Deprecated as of MCprep 3.5 global v v = True # $VERBOSE, UI setting + # ! Deprecated as of MCprep 3.5 global vv vv = dev # $VERYVERBOSE @@ -53,13 +236,16 @@ def init(): # shouldn't load here, just globalize any json data? + # ! Deprecated as of MCprep 3.5 global data # import json + # ! Deprecated as of MCprep 3.5 global json_data # mcprep_data.json json_data = None # later will load addon information etc # if existing json_data_update exists, overwrite it + # ! Deprecated as of MCprep 3.5 global json_path json_path = os.path.join( os.path.dirname(__file__), @@ -87,8 +273,10 @@ def init(): # For preview icons # ----------------------------------------------- + # ! Deprecated as of MCprep 3.5 global use_icons use_icons = True + # ! Deprecated as of MCprep 3.5 global preview_collections preview_collections = {} @@ -104,15 +292,19 @@ def init(): # To ensure shift-A starts drawing sub menus after pressing load all spawns # as without this, if any one of the spawners loads nothing (invalid folder, # no blend files etc), then it would continue to ask to reload spanwers. + # ! Deprecated as of MCprep 3.5 global loaded_all_spawners loaded_all_spawners = False + # ! Deprecated as of MCprep 3.5 global skin_list skin_list = [] # each is: [ basename, path ] + # ! Deprecated as of MCprep 3.5 global rig_categories rig_categories = [] # simple list of directory names + # ! Deprecated as of MCprep 3.5 global entity_list entity_list = [] @@ -123,16 +315,18 @@ def init(): # list of material names, each is a string. None by default to indicate # that no reading has occurred. If lib not found, will update to []. # If ever changing the resource pack, should also reset to None. + # ! Deprecated as of MCprep 3.5 global material_sync_cache material_sync_cache = None +# ! Deprecated as of MCprep 3.5 # ----------------------------------------------------------------------------- # ICONS INIT # ----------------------------------------------------------------------------- - def icons_init(): + env.deprecation_warning() # start with custom icons # put into a try statement in case older blender version! global preview_collections @@ -184,13 +378,12 @@ def icons_init(): preview_collections[iconset] = "" +# ! Deprecated as of MCprep 3.5 def log(statement, vv_only=False): - """General purpose simple logging function.""" - global v - global vv - if v and vv_only and vv: + env.deprecation_warning() + if env.verbose and vv_only and env.very_verbose: print(statement) - elif v: + elif env.verbose: print(statement) @@ -213,27 +406,20 @@ def updater_select_link_function(self, tag): def register(): - init() + global env + if not env.json_data: + # Enforce re-creation of data structures, to ensure populated after + # the addon was disabled once (or more) and then re-enabled, while + # avoiding a double call to init() on the first time load. + env = MCprepEnv() + env.update_json_dat_path() def unregister(): - global preview_collections - if use_icons: - for pcoll in preview_collections.values(): - try: - bpy.utils.previews.remove(pcoll) - except: - log('Issue clearing preview set ' + str(pcoll)) - preview_collections.clear() + env.clear_previews() + env.json_data = None # actively clearing out json data for next open - global json_data - json_data = None # actively clearing out json data for next open - - global loaded_all_spawners - loaded_all_spawners = False - global skin_list - skin_list = [] - global rig_categories - rig_categories = [] - global material_sync_cache - material_sync_cache = [] + env.loaded_all_spawners = False + env.skin_list = [] + env.rig_categories = [] + env.material_sync_cache = [] diff --git a/MCprep_addon/import_bridge/bridge.py b/MCprep_addon/import_bridge/bridge.py index c86657dc..857156a9 100644 --- a/MCprep_addon/import_bridge/bridge.py +++ b/MCprep_addon/import_bridge/bridge.py @@ -25,7 +25,7 @@ from .mineways_connector import MinewaysConnector from .jmc_connector import JmcConnector -from .. import conf +from ..conf import env from .. import util from .. import tracking @@ -131,14 +131,14 @@ class MCPREP_OT_import_world_from_objmeta(bpy.types.Operator, ImportHelper): bl_label = "Import World from Reference" bl_options = {'REGISTER', 'UNDO'} - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default=".obj", # verify this works options={'HIDDEN'}) fileselectparams = "use_filter_blender" - files = bpy.props.CollectionProperty( + files: bpy.props.CollectionProperty( type=bpy.types.PropertyGroup, options={'HIDDEN', 'SKIP_SAVE'}) - # filter_image = bpy.props.BoolProperty( + # filter_image: bpy.props.BoolProperty( # default=True, # options={'HIDDEN', 'SKIP_SAVE'}) @@ -165,75 +165,75 @@ class MCPREP_OT_import_new_world(bpy.types.Operator): bl_label = "Import World" bl_options = {'REGISTER', 'UNDO'} - world_center = bpy.props.IntVectorProperty( + world_center: bpy.props.IntVectorProperty( name = "World center", description = "Select the X-Z center of import from the Minecraft save", default = (0, 0), subtype = 'XZ', size = 2, ) - import_center = bpy.props.IntVectorProperty( + import_center: bpy.props.IntVectorProperty( name = "Import location", description = "Move the center of the imported world to this location", default = (0, 0, 0), subtype = 'XYZ', size = 3, ) - import_height_offset = bpy.props.IntProperty( + import_height_offset: bpy.props.IntProperty( name = "Height offset", description = "Lower or raise the imported world by this amount", default = 0, ) - block_radius = bpy.props.IntProperty( + block_radius: bpy.props.IntProperty( name = "Block radius", description = "Radius of export, from World Center to all directions around", default = 100, min = 1 ) - ceiling = bpy.props.IntProperty( + ceiling: bpy.props.IntProperty( name = "Height/ceiling", description = "The top level of the world to import", default = 256, min = 1, max = 512 ) - floor = bpy.props.IntProperty( + floor: bpy.props.IntProperty( name = "Depth/floor", description = "The bottom level of the world to import", default = 0, min = 1, max = 512 ) - use_chunks = bpy.props.BoolProperty( + use_chunks: bpy.props.BoolProperty( name = "Use chunks", description = "Export the world with separate sections per chunk. In the background, each chunk is one export from Mineways", default = True ) - chunk_size = bpy.props.IntProperty( + chunk_size: bpy.props.IntProperty( name = "Chunk size", description = "Custom size of chunks to import", default = 16, min = 8 ) - separate_blocks = bpy.props.BoolProperty( + separate_blocks: bpy.props.BoolProperty( name = "Object per block", description = "Create one object per each block in the world (warning: will be slow, make exports larger, and make Blender slower!!)", default = False ) - prep_materials = bpy.props.BoolProperty( + prep_materials: bpy.props.BoolProperty( name = "Prep materials", default = True ) - animate_textures = bpy.props.BoolProperty( + animate_textures: bpy.props.BoolProperty( name = "Animate textures", default = False ) - single_texture = bpy.props.BoolProperty( + single_texture: bpy.props.BoolProperty( name = "Single texture file", description = "Export the world with a single texture. Cannot use with animate textures", default = False ) - skipUsage = bpy.props.BoolProperty( + skipUsage: bpy.props.BoolProperty( default = False, options={'HIDDEN'} ) @@ -286,7 +286,7 @@ def execute(self, context): "_" + connector.world + "_exp.mtl" ) - conf.log("Running Mineways bridge to import world "+ connector.world) + env.log("Running Mineways bridge to import world "+ connector.world) connector.run_export_single( obj_path, list(self.first_corner), @@ -297,14 +297,14 @@ def execute(self, context): # TODO: Implement check/connector class check for success, not just file existing if not os.path.isfile(obj_path): self.report({"ERROR"}, "OBJ file not exported, try using Mineways on its own to export OBJ") - conf.log("OBJ file not found to import: "+obj_path) + env.log("OBJ file not found to import: "+obj_path) return {"CANCELLED"} - conf.log("Now importing the exported obj into blender") + env.log("Now importing the exported obj into blender") bpy.ops.import_scene.obj(filepath=obj_path) # consider removing old world obj's? t2 = time.time() - conf.log("Mineways bridge completed in: {}s (Mineways: {}s, obj import: {}s".format( + env.log("Mineways bridge completed in: {}s (Mineways: {}s, obj import: {}s".format( int(t2-t0), int(t1-t0), int(t2-t1))) self.report({'INFO'}, "Bridge completed finished") @@ -317,7 +317,7 @@ class MCPREP_OT_refresh_world(bpy.types.Operator): bl_label = "Refresh World" bl_options = {'REGISTER', 'UNDO'} - skipUsage = bpy.props.BoolProperty( + skipUsage: bpy.props.BoolProperty( default = False, options={'HIDDEN'} ) @@ -334,7 +334,7 @@ class MCPREP_OT_extend_world(bpy.types.Operator): bl_idname = "mcprep.bridge_world_extend" bl_label = "Extend World" - skipUsage = bpy.props.BoolProperty( + skipUsage: bpy.props.BoolProperty( default = False, options={'HIDDEN'} ) @@ -361,7 +361,6 @@ def execute(self, context): def register(): for cls in classes: - util.make_annotations(cls) bpy.utils.register_class(cls) diff --git a/MCprep_addon/load_modules.py b/MCprep_addon/load_modules.py index f000c7a1..5fc71c79 100644 --- a/MCprep_addon/load_modules.py +++ b/MCprep_addon/load_modules.py @@ -150,6 +150,7 @@ # Only include those with a register function, which is not all module_list = ( + conf, util_operators, material_manager, prep, @@ -172,7 +173,6 @@ def register(bl_info): - conf.register() tracking.register(bl_info) for mod in module_list: mod.register() @@ -183,8 +183,8 @@ def register(bl_info): # Inject the custom updater function, to use release zip instead src. addon_updater_ops.updater.select_link = conf.updater_select_link_function - conf.log("MCprep: Verbose is enabled") - conf.log("MCprep: Very Verbose is enabled", vv_only=True) + conf.env.log("MCprep: Verbose is enabled") + conf.env.log("MCprep: Very Verbose is enabled", vv_only=True) def unregister(bl_info): diff --git a/MCprep_addon/materials/default_materials.py b/MCprep_addon/materials/default_materials.py index d1b737dc..7b2a6909 100644 --- a/MCprep_addon/materials/default_materials.py +++ b/MCprep_addon/materials/default_materials.py @@ -18,31 +18,34 @@ import os +from typing import Union, Optional import bpy +from bpy.types import Context, Material -from .. import conf from .. import tracking from .. import util from . import sync +from ..conf import env, Engine -def default_material_in_sync_library(default_material, context): + +def default_material_in_sync_library(default_material: str, context: Context) -> bool: """Returns true if the material is in the sync mat library blend file.""" - if conf.material_sync_cache is None: + if env.material_sync_cache is None: sync.reload_material_sync_library(context) - if util.nameGeneralize(default_material) in conf.material_sync_cache: + if util.nameGeneralize(default_material) in env.material_sync_cache: return True - elif default_material in conf.material_sync_cache: + elif default_material in env.material_sync_cache: return True return False -def sync_default_material(context, material, default_material, engine): +def sync_default_material(context: Context, material: Material, default_material: str, engine: Engine) -> Optional[Union[Material, str]]: """Normal sync material method but with duplication and name change.""" - if default_material in conf.material_sync_cache: + if default_material in env.material_sync_cache: import_name = default_material - elif util.nameGeneralize(default_material) in conf.material_sync_cache: + elif util.nameGeneralize(default_material) in env.material_sync_cache: import_name = util.nameGeneralize(default_material) # If link is true, check library material not already linked. @@ -54,7 +57,7 @@ def sync_default_material(context, material, default_material, engine): imported = set(list(bpy.data.materials)) - set(init_mats) if not imported: - return "Could not import " + str(material.name) + return f"Could not import {material.name}" new_default_material = list(imported)[0] # Checking if there's a node with the label Texture. @@ -77,14 +80,10 @@ def sync_default_material(context, material, default_material, engine): texture_file = bpy.data.images.get(image_texture) default_texture_node.image = texture_file - if engine == "cycles" or engine == "blender_eevee": + if engine == "CYCLES" or engine == "BLENDER_EEVEE": default_texture_node.interpolation = 'Closest' - # 2.78+ only, else silent failure. - res = util.remap_users(material, new_default_material) - if res != 0: - # Try a fallback where we at least go over the selected objects. - return res + material.user_remap(new_default_material) # remove the old material since we're changing the default and we don't # want to overwhelm users @@ -98,15 +97,15 @@ class MCPREP_OT_default_material(bpy.types.Operator): bl_label = "Sync Default Materials" bl_options = {'REGISTER', 'UNDO'} - use_pbr = bpy.props.BoolProperty( + use_pbr: bpy.props.BoolProperty( name="Use PBR", description="Use PBR or not", default=False) - engine = bpy.props.StringProperty( + engine: bpy.props.StringProperty( name="engine To Use", description="Defines the engine to use", - default="cycles") + default="CYCLES") SIMPLE = "simple" PBR = "pbr" @@ -118,7 +117,7 @@ def execute(self, context): # Sync file stuff. sync_file = sync.get_sync_blend(context) if not os.path.isfile(sync_file): - self.report({'ERROR'}, "Sync file not found: " + sync_file) + self.report({'ERROR'}, f"Sync file not found: {sync_file}") return {'CANCELLED'} if sync_file == bpy.data.filepath: @@ -126,7 +125,7 @@ def execute(self, context): # Find the default material. workflow = self.SIMPLE if not self.use_pbr else self.PBR - material_name = material_name = f"default_{workflow}_{self.engine}" + material_name = material_name = f"default_{workflow}_{self.engine.lower()}" if not default_material_in_sync_library(material_name, context): self.report({'ERROR'}, "No default material found") return {'CANCELLED'} @@ -135,9 +134,9 @@ def execute(self, context): mat_list = list(bpy.data.materials) for mat in mat_list: try: - err = sync_default_material(context, mat, material_name, self.engine) # no linking + err = sync_default_material(context, mat, material_name, self.engine.upper()) # no linking if err: - conf.log(err) + env.log(err) except Exception as e: print(e) @@ -154,14 +153,14 @@ class MCPREP_OT_create_default_material(bpy.types.Operator): def execute(self, context): engine = context.scene.render.engine - self.create_default_material(context, engine.lower(), "simple") + self.create_default_material(context, engine, "simple") return {'FINISHED'} def create_default_material(self, context, engine, type): """ create_default_material: takes 3 arguments and returns nothing context: Blender Context - engine: the render engine in lowercase + engine: the render engine type: the type of texture that's being dealt with """ if not len(bpy.context.selected_objects): @@ -169,10 +168,7 @@ def create_default_material(self, context, engine, type): self.report({'ERROR'}, "Select an object to create the material") return - material_name = "default_{type}_{engine}".format( - type=type, - engine=engine - ) + material_name = f"default_{type}_{engine.lower()}" default_material = bpy.data.materials.new(name=material_name) default_material.use_nodes = True nodes = default_material.node_tree.nodes @@ -195,7 +191,7 @@ def create_default_material(self, context, engine, type): links.new(default_texture_node.outputs[0], principled.inputs[0]) links.new(principled.outputs["BSDF"], nodeOut.inputs[0]) - if engine == "eevee": + if engine == "EEVEE": if hasattr(default_material, "blend_method"): default_material.blend_method = 'HASHED' if hasattr(default_material, "shadow_method"): @@ -210,7 +206,6 @@ def create_default_material(self, context, engine, type): def register(): for cls in classes: - util.make_annotations(cls) bpy.utils.register_class(cls) bpy.app.handlers.load_post.append(sync.clear_sync_cache) diff --git a/MCprep_addon/materials/generate.py b/MCprep_addon/materials/generate.py index 9021e599..f17b33d9 100644 --- a/MCprep_addon/materials/generate.py +++ b/MCprep_addon/materials/generate.py @@ -17,30 +17,42 @@ # ##### END GPL LICENSE BLOCK ##### import os +from typing import Dict, Optional, List, Any, Tuple, Union +from pathlib import Path +from dataclasses import dataclass +from enum import Enum + import bpy +from bpy.types import Context, Material, Image, Texture, Nodes, NodeLinks, Node -from .. import conf from .. import util +from ..conf import env, Form + +AnimatedTex = Dict[str, int] +class PackFormat(Enum): + SIMPLE = 0 + SEUS = 1 + SPECULAR = 2 # ----------------------------------------------------------------------------- # Material prep and generation functions (no registration) # ----------------------------------------------------------------------------- -def update_mcprep_texturepack_path(self, context): +def update_mcprep_texturepack_path(self, context: Context) -> None: """Triggered if the scene-level resource pack path is updated.""" bpy.ops.mcprep.reload_items() bpy.ops.mcprep.reload_materials() bpy.ops.mcprep.reload_models() - conf.material_sync_cache = None + env.material_sync_cache = None # Forces particle plane emitter to now use the newly set resource pack # the first time, but the value gets saved again after. context.scene.mcprep_particle_plane_file = '' -def get_mc_canonical_name(name): +def get_mc_canonical_name(name: str) -> Tuple[str, Optional[Form]]: """Convert a material name to standard MC name. Returns: @@ -48,7 +60,7 @@ def get_mc_canonical_name(name): form (mc, jmc, or mineways) """ general_name = util.nameGeneralize(name) - if not conf.json_data: + if not env.json_data: res = util.load_mcprep_json() if not res: return general_name, None @@ -59,13 +71,13 @@ def get_mc_canonical_name(name): if ".emit" in general_name and general_name != ".emit": general_name = general_name.replace(".emit", "") - no_missing = "blocks" in conf.json_data - no_missing &= "block_mapping_mc" in conf.json_data["blocks"] - no_missing &= "block_mapping_jmc" in conf.json_data["blocks"] - no_missing &= "block_mapping_mineways" in conf.json_data["blocks"] + no_missing = "blocks" in env.json_data + no_missing &= "block_mapping_mc" in env.json_data["blocks"] + no_missing &= "block_mapping_jmc" in env.json_data["blocks"] + no_missing &= "block_mapping_mineways" in env.json_data["blocks"] if no_missing is False: - conf.log("Missing key values in json") + env.log("Missing key values in json") return general_name, None # The below workaround is to account for the jmc2obj v113+ which changes @@ -84,36 +96,36 @@ def get_mc_canonical_name(name): # mixed up with the new "water": "painting/water" texture. general_name = "water_still" - if general_name in conf.json_data["blocks"]["block_mapping_mc"]: - canon = conf.json_data["blocks"]["block_mapping_mc"][general_name] + if general_name in env.json_data["blocks"]["block_mapping_mc"]: + canon = env.json_data["blocks"]["block_mapping_mc"][general_name] form = "mc" if not jmc_prefix else "jmc2obj" - elif general_name in conf.json_data["blocks"]["block_mapping_jmc"]: - canon = conf.json_data["blocks"]["block_mapping_jmc"][general_name] + elif general_name in env.json_data["blocks"]["block_mapping_jmc"]: + canon = env.json_data["blocks"]["block_mapping_jmc"][general_name] form = "jmc2obj" - elif general_name in conf.json_data["blocks"]["block_mapping_mineways"]: - canon = conf.json_data["blocks"]["block_mapping_mineways"][general_name] + elif general_name in env.json_data["blocks"]["block_mapping_mineways"]: + canon = env.json_data["blocks"]["block_mapping_mineways"][general_name] form = "mineways" - elif general_name.lower() in conf.json_data["blocks"]["block_mapping_jmc"]: - canon = conf.json_data["blocks"]["block_mapping_jmc"][ + elif general_name.lower() in env.json_data["blocks"]["block_mapping_jmc"]: + canon = env.json_data["blocks"]["block_mapping_jmc"][ general_name.lower()] form = "jmc2obj" - elif general_name.lower() in conf.json_data["blocks"]["block_mapping_mineways"]: - canon = conf.json_data["blocks"]["block_mapping_mineways"][ + elif general_name.lower() in env.json_data["blocks"]["block_mapping_mineways"]: + canon = env.json_data["blocks"]["block_mapping_mineways"][ general_name.lower()] form = "mineways" else: - conf.log("Canonical name not matched: " + general_name, vv_only=True) + env.log(f"Canonical name not matched: {general_name}", vv_only=True) canon = general_name form = None if canon is None or canon == '': - conf.log("Error: Encountered None canon value with " + str(general_name)) + env.log(f"Error: Encountered None canon value with {general_name}") canon = general_name return canon, form -def find_from_texturepack(blockname, resource_folder=None): +def find_from_texturepack(blockname: str, resource_folder: Optional[Path]=None) -> Path: """Given a blockname (and resource folder), find image filepath. Finds textures following any pack which should have this structure, and @@ -126,7 +138,7 @@ def find_from_texturepack(blockname, resource_folder=None): resource_folder = bpy.path.abspath(bpy.context.scene.mcprep_texturepack_path) if not os.path.isdir(resource_folder): - conf.log("Error, resource folder does not exist") + env.log("Error, resource folder does not exist") return # Check multiple paths, picking the first match (order is important), @@ -180,14 +192,14 @@ def find_from_texturepack(blockname, resource_folder=None): for suffix in ["-Alpha", "-RGB", "-RGBA"]: if blockname.endswith(suffix): res = os.path.join( - resource_folder, "mineways_assets", "mineways" + suffix + ".png") + resource_folder, "mineways_assets", f"mineways{suffix}.png") if os.path.isfile(res): return res return res -def detect_form(materials): +def detect_form(materials: List[Material]) -> Optional[Form]: """Function which, given the input materials, guesses the exporter form. Useful for pre-determining elibibility of a function and also for tracking @@ -225,17 +237,17 @@ def detect_form(materials): return res # one of jmc2obj, mineways, or None -def checklist(matName, listName): +def checklist(matName: str, listName: str) -> bool: """Helper to expand single wildcard within generalized material names""" - if not conf.json_data: - conf.log("No json_data for checklist to call from!") - if "blocks" not in conf.json_data or listName not in conf.json_data["blocks"]: - conf.log( - "conf.json_data is missing blocks or listName " + str(listName)) + if not env.json_data: + env.log("No json_data for checklist to call from!") + if "blocks" not in env.json_data or listName not in env.json_data["blocks"]: + env.log( + f"env.json_data is missing blocks or listName {listName}") return False - if matName in conf.json_data["blocks"][listName]: + if matName in env.json_data["blocks"][listName]: return True - for name in conf.json_data["blocks"][listName]: + for name in env.json_data["blocks"][listName]: if '*' not in name: continue x = name.split('*') @@ -246,167 +258,84 @@ def checklist(matName, listName): return False -def matprep_internal(mat, passes, use_reflections, only_solid): - """Update existing internal materials with improved settings. - Will not necessarily properly convert cycles materials into internal.""" - - if not conf.json_data: - _ = util.load_mcprep_json() - - newName = mat.name + '_tex' - texList = mat.texture_slots.values() - try: - bpy.data.textures[texList[0].name].name = newName - except: - conf.log( - '\twarning: material ' + mat.name + ' has no texture slot. skipping...') - return - - # disable all but first slot, ensure first slot enabled - mat.use_textures[0] = True - diff_layer = 0 - spec_layer = None - norm_layer = None - disp_layer = None - saturate_layer = None - first_unused = None - for index in range(1, len(texList)): - if not mat.texture_slots[index] or not mat.texture_slots[index].texture: - mat.use_textures[index] = False - if not first_unused: - first_unused = index - elif "MCPREP_diffuse" in mat.texture_slots[index].texture: - diff_layer = index - mat.use_textures[index] = True - elif "MCPREP_specular" in mat.texture_slots[index].texture: - spec_layer = index - mat.use_textures[index] = True - elif "MCPREP_normal" in mat.texture_slots[index].texture: - norm_layer = index - mat.use_textures[index] = True - elif "SATURATE" in mat.texture_slots[index].texture: - saturate_layer = index - mat.use_textures[index] = True - else: - mat.use_textures[index] = False - - if mat.texture_slots[diff_layer].texture.type != "IMAGE": - conf.log("No diffuse-detected texture, skipping material: " + mat.name) - return 1 - - # strip out the .00# - canon, _ = get_mc_canonical_name(util.nameGeneralize(mat.name)) - mat.use_nodes = False - - mat.use_transparent_shadows = True # all materials receive trans - mat.specular_intensity = 0 - mat.texture_slots[diff_layer].texture.use_interpolation = False - mat.texture_slots[diff_layer].texture.filter_type = 'BOX' - mat.texture_slots[diff_layer].texture.filter_size = 0 - mat.texture_slots[diff_layer].use_map_color_diffuse = True - mat.texture_slots[diff_layer].diffuse_color_factor = 1 - - if only_solid is False and not checklist(canon, "solid"): # alpha default on - bpy.data.textures[newName].use_alpha = True - mat.texture_slots[diff_layer].use_map_alpha = True - mat.use_transparency = True - mat.alpha = 0 - mat.texture_slots[diff_layer].alpha_factor = 1 - for index in [spec_layer, norm_layer, disp_layer]: - if index: - mat.texture_slots[index].use_map_alpha = False - - if use_reflections and checklist(canon, "reflective"): - mat.alpha = 0 - mat.raytrace_mirror.use = True - mat.raytrace_mirror.reflect_factor = 0.15 - else: - mat.raytrace_mirror.use = False - mat.alpha = 0 - - if checklist(canon, "emit") or "emit" in mat.name.lower(): - mat.emit = 1 - else: - mat.emit = 0 - - # cycle through and see if the layer exists to enable/disable blend - if not checklist(canon, "desaturated"): - pass - else: - diff_img = mat.texture_slots[diff_layer].texture.image - is_grayscale = is_image_grayscale(diff_img) - - # TODO: code is duplicative to below, consolidate later - if mat.name + "_saturate" in bpy.data.textures: - new_tex = bpy.data.textures[mat.name + "_saturate"] - else: - new_tex = bpy.data.textures.new(name=mat.name + "_saturate", type="BLEND") - if not saturate_layer: - if not first_unused: - first_unused = len(mat.texture_slots) - 1 # force reuse last at worst - sl = mat.texture_slots.create(first_unused) - else: - sl = mat.texture_slots[saturate_layer] - sl.texture = new_tex - sl.texture["SATURATE"] = True - sl.use_map_normal = False - sl.use_map_color_diffuse = True - sl.use_map_specular = False - sl.use_map_alpha = False - sl.blend_type = 'MULTIPLY' # changed from OVERLAY - sl.use = bool(is_grayscale) # turns off if not grayscale (or None) - new_tex.use_color_ramp = True - for _ in range(len(new_tex.color_ramp.elements) - 1): - new_tex.color_ramp.elements.remove(new_tex.color_ramp.elements[0]) - desat_color = conf.json_data['blocks']['desaturated'][canon] - if len(desat_color) < len(new_tex.color_ramp.elements[0].color): - desat_color.append(1.0) - new_tex.color_ramp.elements[0].color = desat_color - - return 0 - - -def matprep_cycles( - mat, passes, use_reflections, use_principled, only_solid, pack_format, use_emission_nodes): +# Dataclass representing all options +# for prep materials +# +# We use __slots__ since __slots__ prevents the +# following bug: +# p = PrepOptions(...) +# p.psses["..."] = "..." +# +# Where a non-existant variable is used. In +# Python, this would create a new variable +# "psses" on p. To prevent this, we use __slots__. +# +# In addition, access to objects in __slots__ is +# faster then it would be normally +# +# Python dataclasses have native support for __slots__ +# in 3.10, but since 2.8 uses 3.7, we have to use +# __slots__ directly + +@dataclass +class PrepOptions: + """Class defining structure for prepping or generating materials + + passes: dictionary struc of all found pass names + use_reflections: whether to turn reflections on + use_principled: if available and cycles, use principled node + saturate: if a desaturated texture (by canonical resource), add color + pack_format: which format of PBR, string ("Simple", Specular", "SEUS") + """ + __slots__ = ( + "passes", + "use_reflections", + "use_principled", + "only_solid", + "pack_format", + "use_emission_nodes", + "use_emission") + passes: Dict[str, bpy.types.Image] + use_reflections: bool + use_principled: bool + only_solid: bool + pack_format: PackFormat + use_emission_nodes: bool + use_emission: bool + + +def matprep_cycles(mat: Material, options: PrepOptions) -> Optional[bool]: """Determine how to prep or generate the cycles materials. Args: mat: the existing material - passes: dictionary struc of all found pass names - use_reflections: whether to turn reflections on - use_principled: if available and cycles, use principled node - saturate: if a desaturated texture (by canonical resource), add color - pack_format: which format of PBR, string ("Simple", Specular", "SEUS") + options: All PrepOptions for this configuration, see class definition Returns: int: 0 only if successful, otherwise None or other """ - if util.bv28(): - # ensure nodes are enabled esp. after importing from BI scenes - mat.use_nodes = True + # ensure nodes are enabled + mat.use_nodes = True matGen = util.nameGeneralize(mat.name) - canon, form = get_mc_canonical_name(matGen) - use_emission = checklist(canon, "emit") or "emit" in mat.name.lower() + canon, _ = get_mc_canonical_name(matGen) + options.use_emission = checklist(canon, "emit") or "emit" in mat.name.lower() # TODO: Update different options for water before enabling this # if use_reflections and checklist(canon, "water"): - # res = matgen_special_water(mat, passes) + # res = matgen_special_water(mat, passes) # if use_reflections and checklist(canon, "glass"): # res = matgen_special_glass(mat, passes) - if pack_format == "simple" and util.bv28(): - res = matgen_cycles_simple( - mat, passes, use_reflections, use_emission, only_solid, use_principled, use_emission_nodes) - elif use_principled and hasattr(bpy.types, 'ShaderNodeBsdfPrincipled'): - res = matgen_cycles_principled( - mat, passes, use_reflections, use_emission, only_solid, pack_format, use_emission_nodes) + if options.pack_format == PackFormat.SIMPLE: + res = matgen_cycles_simple(mat, options) + elif options.use_principled: + res = matgen_cycles_principled(mat, options) else: - res = matgen_cycles_original( - mat, passes, use_reflections, use_emission, only_solid, pack_format, use_emission_nodes) + res = matgen_cycles_original(mat, options) return res -def set_texture_pack(material, folder, use_extra_passes): +def set_texture_pack(material: Material, folder: Path, use_extra_passes: bool) -> bool: """Replace existing material's image with texture pack's. Run through and check for each if counterpart material exists, then @@ -418,36 +347,23 @@ def set_texture_pack(material, folder, use_extra_passes): return 0 image_data = util.loadTexture(image) - engine = bpy.context.scene.render.engine - - if engine == 'CYCLES' or engine == 'BLENDER_EEVEE': - _ = set_cycles_texture(image_data, material, True) - elif engine == 'BLENDER_RENDER' or engine == 'BLENDER_GAME': - _ = set_internal_texture(image_data, material, use_extra_passes) + _ = set_cycles_texture(image_data, material, True) return 1 -def assert_textures_on_materials(image, materials): +def assert_textures_on_materials(image: Image, materials: List[Material]) -> int: """Called for any texture changing, e.g. skin, input a list of material and an already loaded image datablock.""" # TODO: Add option to search for or ignore/remove extra maps (normal, etc) - engine = bpy.context.scene.render.engine count = 0 - - if engine == 'BLENDER_RENDER' or engine == 'BLENDER_GAME': - for mat in materials: - status = set_internal_texture(image, mat) - if status: - count += 1 - elif engine == 'CYCLES' or engine == 'BLENDER_EEVEE': - for mat in materials: - status = set_cycles_texture(image, mat) - if status: - count += 1 + for mat in materials: + status = set_cycles_texture(image, mat) + if status: + count += 1 return count -def set_cycles_texture(image, material, extra_passes=False): +def set_cycles_texture(image: Image, material: Material, extra_passes: bool=False) -> bool: """ Used by skin swap and assiging missing textures or tex swapping. Args: @@ -455,8 +371,7 @@ def set_cycles_texture(image, material, extra_passes=False): material: existing material datablock extra_passes: whether to include or hard exclude non diffuse passes """ - conf.log("Setting cycles texture for img: {} mat: {}".format( - image.name, material.name)) + env.log(f"Setting cycles texture for img: {image.name} mat: {material.name}") if material.node_tree is None: return False # check if there is more data to see pass types @@ -475,7 +390,7 @@ def set_cycles_texture(image, material, extra_passes=False): if node.type == "MIX_RGB" and "SATURATE" in node: node.mute = not is_grayscale node.hide = not is_grayscale - conf.log(" mix_rgb to saturate texture") + env.log(" mix_rgb to saturate texture") # if node.type != "TEX_IMAGE": continue @@ -525,135 +440,7 @@ def set_cycles_texture(image, material, extra_passes=False): return changed -def set_internal_texture(image, material, extra_passes=False): - """Set texture for internal engine. Input is image datablock.""" - # TODO: when going through layers, see if enabled already for normal / - # spec or not and enabled/disable accordingly (e.g. if was resource - # with normal, now is not) - - # check if there is more data to see pass types - canon, _ = get_mc_canonical_name(util.nameGeneralize(material.name)) - - is_grayscale = False - if checklist(canon, "desaturated"): - is_grayscale = is_image_grayscale(image) - - img_sets = {} - if extra_passes: - img_sets = find_additional_passes(image.filepath) - if is_grayscale is True: - img_sets["saturate"] = True - - base = None - tex = None - - # set primary diffuse color as the first image found - for i, sl in enumerate(material.texture_slots): - if sl is None or sl.texture is None or sl.texture.type != 'IMAGE': - continue - sl.texture.image = image - sl.use = True - tex = sl.texture - base = i - sl.use_map_normal = False - sl.use_map_color_diffuse = True - sl.use_map_specular = False - sl.blend_type = 'MIX' - break - - # if no textures found, assert adding this one as the first - if tex is None: - conf.log("Found no textures, asserting texture onto material") - name = material.name + "_tex" - if name not in bpy.data.textures: - tex = bpy.data.textures.new(name=name, type="IMAGE") - else: - tex = bpy.data.textures[name] - tex.image = image - if material.texture_slots[0] is None: - material.texture_slots.create(0) - material.texture_slots[0].texture = tex - material.texture_slots[0].texture["MCPREP_diffuse"] = True - material.texture_slots[0].use = True - base = 0 - - # go through and turn off any previous passes not in img_sets - for i, sl in enumerate(material.texture_slots): - if i == base: - continue # skip primary texture set - if "normal" in img_sets and img_sets["normal"]: # pop item each time - if tex and tex.name + "_n" in bpy.data.textures: - new_tex = bpy.data.textures[tex.name + "_n"] - else: - new_tex = bpy.data.textures.new( - name=tex.name + "_n", type="IMAGE") - print(sl) - if not sl: - sl = material.texture_slots.create(i) - f = img_sets.pop("normal") - new_img = util.loadTexture(f) - new_tex.image = new_img - sl.texture = new_tex - sl.texture["MCPREP_normal"] = True - sl.use_map_normal = True - sl.normal_factor = 0.1 - sl.use_map_color_diffuse = False - sl.use_map_specular = False - sl.use_map_alpha = False - sl.blend_type = 'MIX' - sl.use = True - elif "spec" in img_sets and img_sets["spec"]: - if tex and tex.name + "_s" in bpy.data.textures: - new_tex = bpy.data.textures[tex.name + "_s"] - else: - new_tex = bpy.data.textures.new( - name=tex.name + "_s", type="IMAGE") - if not sl: - sl = material.texture_slots.create(i) - f = img_sets.pop("specular") - new_img = util.loadTexture(f) - new_img.use_alpha = False # would mess up material - new_tex.image = new_img - sl.texture = new_tex - sl.texture["MCPREP_specular"] = True - sl.use_map_normal = False - sl.use_map_color_diffuse = False - sl.use_map_specular = True - sl.use_map_alpha = False - sl.blend_type = 'MIX' - sl.use = True - elif "saturate" in img_sets: - img_sets.pop("saturate") - print("Running saturate") - if not checklist(canon, "desaturated"): - continue - if tex and tex.name + "_saturate" in bpy.data.textures: - new_tex = bpy.data.textures[tex.name + "_saturate"] - else: - new_tex = bpy.data.textures.new( - name=tex.name + "_saturate", type="BLEND") - if not sl: - sl = material.texture_slots.create(i) - sl.texture = new_tex - sl.texture["SATURATE"] = True - sl.use_map_normal = False - sl.use_map_color_diffuse = True - sl.use_map_specular = False - sl.use_map_alpha = False - sl.blend_type = 'OVERLAY' - sl.use = True - new_tex.use_color_ramp = True - for _ in range(len(new_tex.color_ramp.elements) - 1): - new_tex.color_ramp.elements.remove(new_tex.color_ramp.elements[0]) - desat_color = conf.json_data['blocks']['desaturated'][canon] - if len(desat_color) < len(new_tex.color_ramp.elements[0].color): - desat_color.append(1.0) - new_tex.color_ramp.elements[0].color = desat_color - - return True - - -def get_node_for_pass(material, pass_name): +def get_node_for_pass(material: Material, pass_name: str) -> Optional[Node]: """Assumes cycles material, returns texture node for given pass in mat.""" if pass_name not in ["diffuse", "specular", "normal", "displace"]: return None @@ -677,7 +464,7 @@ def get_node_for_pass(material, pass_name): return return_node -def get_texlayer_for_pass(material, pass_name): +def get_texlayer_for_pass(material: Material, pass_name:str) -> Optional[Texture]: """Assumes BI material, returns texture layer for given pass in mat.""" if pass_name not in ["diffuse", "specular", "normal", "displace"]: return None @@ -700,7 +487,7 @@ def get_texlayer_for_pass(material, pass_name): return sl.texture -def get_textures(material): +def get_textures(material: Material) -> Dict[str, Image]: """Extract the image datablocks for a given material (prefer cycles). Returns {"diffuse":texture.image, "normal":node.image "spec":None, ...} @@ -746,10 +533,10 @@ def get_textures(material): return passes -def find_additional_passes(image_file): +def find_additional_passes(image_file: Path) -> Dict[str, Image]: """Find relevant passes like normal and spec in same folder as image.""" abs_img_file = bpy.path.abspath(image_file) - conf.log("\tFind additional passes for: " + image_file, vv_only=True) + env.log(f"\tFind additional passes for: {image_file}", vv_only=True) if not os.path.isfile(abs_img_file): return {} @@ -791,7 +578,7 @@ def find_additional_passes(image_file): return res -def replace_missing_texture(image): +def replace_missing_texture(image: Image) -> bool: """If image missing from image datablock, replace from texture pack. Image block name could be the diffuse or any other pass of material, and @@ -813,7 +600,7 @@ def replace_missing_texture(image): elif os.path.isfile(bpy.path.abspath(image.filepath)): # ... or the filepath is present. return False - conf.log("Missing datablock detected: " + image.name) + env.log(f"Missing datablock detected: {image.name}") name = image.name if len(name) > 4 and name[-4] == ".": @@ -832,10 +619,10 @@ def replace_missing_texture(image): return True # updated image block -def is_image_grayscale(image): +def is_image_grayscale(image: Image) -> bool: """Returns true if image data is all grayscale, false otherwise""" - def rgb_to_saturation(r, g, b): + def rgb_to_saturation(r, g, b) -> float: """Converter 0-1 rgb values back to 0-1 saturation value""" mx = max(r, g, b) if mx == 0: @@ -846,11 +633,11 @@ def rgb_to_saturation(r, g, b): if not image: return None - conf.log("Checking image for grayscale " + image.name, vv_only=True) + env.log(f"Checking image for grayscale {image.name}", vv_only=True) if 'grayscale' in image: # cache return image['grayscale'] if not image.pixels: - conf.log("Not an image / no pixels", vv_only=True) + env.log("Not an image / no pixels", vv_only=True) return None # setup sampling to limit number of processed pixels @@ -898,80 +685,59 @@ def rgb_to_saturation(r, g, b): pixels_saturated += 1 if pixels_saturated >= max_thresh: is_grayscale = False - conf.log("Image not grayscale: " + image.name, vv_only=True) + env.log("Image not grayscale: {image.name}", vv_only=True) break if datablock_copied: # Cleanup if image was copied to scale down size. bpy.data.images.remove(imgcp) image['grayscale'] = is_grayscale # set cache - conf.log("Image is grayscale: " + image.name, vv_only=True) + env.log(f"Image not grayscale: {image.name}", vv_only=True) return is_grayscale -def set_saturation_material(mat): +def set_saturation_material(mat: Material) -> None: """Update material to be saturated or not""" if not mat: return canon, _ = get_mc_canonical_name(mat.name) if not checklist(canon, "desaturated"): - conf.log("debug: not eligible for saturation", vv_only=True) + env.log("Debug: not eligible for saturation", vv_only=True) return - conf.log("Running set_saturation on " + mat.name, vv_only=True) + env.log(f"Running set_saturation on {mat.name}", vv_only=True) diff_pass = get_node_for_pass(mat, "diffuse") if not diff_pass: return diff_img = diff_pass.image if not diff_img: - conf.log("debug: No diffuse", vv_only=True) + env.log("debug: No diffuse", vv_only=True) return saturate = is_image_grayscale(diff_img) - desat_color = conf.json_data['blocks']['desaturated'][canon] - engine = bpy.context.scene.render.engine - - if engine == 'BLENDER_RENDER' or engine == 'BLENDER_GAME': - # get the saturation textureslot, or create - sat_slot_ind = None - first_unused = None - for index in range(len(mat.texture_slots)): - slot = mat.texture_slots[index] - if not slot or not slot.texture: - if not first_unused: - first_unused = index - continue - elif "SATURATE" not in slot.texture: - continue - sat_slot_ind = index - break - if not sat_slot_ind: - return - mat.use_textures[sat_slot_ind] = bool(saturate) - - elif engine == 'CYCLES' or engine == 'BLENDER_EEVEE': - sat_node = None - for node in mat.node_tree.nodes: - if "SATURATE" not in node: - continue - sat_node = node - break + desat_color = env.json_data['blocks']['desaturated'][canon] + sat_node = None + for node in mat.node_tree.nodes: + if "SATURATE" not in node: + continue + sat_node = node + break - if not sat_node: - return # requires regenerating material to add back - if len(desat_color) == 3: - desat_color += [1] # add in alpha + if not sat_node: + return # requires regenerating material to add back + if len(desat_color) == 3: + desat_color += [1] # add in alpha - sat_node_in = get_node_socket(node, is_input=True) # Get the node sockets in a version agnostic way - sat_node.inputs[sat_node_in[2]].default_value = desat_color - sat_node.mute = not bool(saturate) - sat_node.hide = not bool(saturate) + sat_node_in = get_node_socket(node, is_input=True) # Get the node sockets in a version agnostic way + sat_node.inputs[sat_node_in[2]].default_value = desat_color + sat_node.mute = not bool(saturate) + sat_node.hide = not bool(saturate) -def create_node(tree_nodes, node_type, **attrs): +def create_node(tree_nodes: Nodes, node_type: str, **attrs: Dict[str, Any]) -> Node: """Create node with default attributes Args: @@ -1002,7 +768,7 @@ def create_node(tree_nodes, node_type, **attrs): return node -def get_node_socket(node, is_input=True): +def get_node_socket(node: Node, is_input: bool = True) -> list: """Gets the input or output sockets indicies for node""" n_type = node.bl_idname if n_type == 'ShaderNodeMix' or n_type == 'ShaderNodeMixRGB': @@ -1024,7 +790,7 @@ def get_node_socket(node, is_input=True): # ----------------------------------------------------------------------------- -def copy_texture_animation_pass_settings(mat): +def copy_texture_animation_pass_settings(mat: Material) -> AnimatedTex: """Get any animation settings for passes.""" # Pre-copy any animated node settings before clearing nodes animated_data = {} @@ -1056,7 +822,7 @@ def copy_texture_animation_pass_settings(mat): return animated_data -def apply_texture_animation_pass_settings(mat, animated_data): +def apply_texture_animation_pass_settings(mat: Material, animated_data: AnimatedTex) -> Optional[Dict]: """Apply animated texture settings for all given passes of dict.""" if not mat.use_nodes: @@ -1086,19 +852,18 @@ def apply_texture_animation_pass_settings(mat, animated_data): anim_node.image_user.use_cyclic = True -def texgen_specular(mat, passes, nodeInputs, use_reflections): - - matGen = util.nameGeneralize(mat.name) +def texgen_specular(mat: Material, passes: Dict[str, Image], nodeInputs: List, use_reflections: bool) -> None: + matGen: str = util.nameGeneralize(mat.name) canon, form = get_mc_canonical_name(matGen) # Define links and nodes - nodes = mat.node_tree.nodes - links = mat.node_tree.links + nodes: Nodes = mat.node_tree.nodes + links: NodeLinks = mat.node_tree.links # Define the diffuse, normal, and specular nodes - image_diff = passes["diffuse"] - image_norm = passes["normal"] - image_spec = passes["specular"] + image_diff: Image = passes["diffuse"] + image_norm: Image = passes["normal"] + image_spec: Image = passes["specular"] # Creates the necessary nodes nodeTexDiff = create_node( @@ -1192,8 +957,8 @@ def texgen_specular(mat, passes, nodeInputs, use_reflections): elif not is_image_grayscale(image_diff): pass else: - conf.log("Texture desaturated: " + canon, vv_only=True) - desat_color = conf.json_data['blocks']['desaturated'][canon] + env.log(f"Texture desaturated: {canon}", vv_only=True) + desat_color = env.json_data['blocks']['desaturated'][canon] if len(desat_color) < len(nodeSaturateMix.inputs[saturateMixIn[2]].default_value): desat_color.append(1.0) nodeSaturateMix.inputs[saturateMixIn[2]].default_value = desat_color @@ -1211,19 +976,19 @@ def texgen_specular(mat, passes, nodeInputs, use_reflections): nodeTexDiff.image = image_diff -def texgen_seus(mat, passes, nodeInputs, use_reflections, use_emission): +def texgen_seus(mat: Material, passes: Dict[str, Image], nodeInputs: List, use_reflections: bool, use_emission: bool) -> None: matGen = util.nameGeneralize(mat.name) canon, form = get_mc_canonical_name(matGen) # Define links and nodes - nodes = mat.node_tree.nodes - links = mat.node_tree.links + nodes: Nodes = mat.node_tree.nodes + links: NodeLinks = mat.node_tree.links # Define the diffuse, normal, and specular nodes - image_diff = passes["diffuse"] - image_norm = passes["normal"] - image_spec = passes["specular"] + image_diff: Image = passes["diffuse"] + image_norm: Image = passes["normal"] + image_spec: Image = passes["specular"] # Creates the necessary nodes nodeTexDiff = create_node( @@ -1334,8 +1099,8 @@ def texgen_seus(mat, passes, nodeInputs, use_reflections, use_emission): elif not is_image_grayscale(image_diff): pass else: - conf.log("Texture desaturated: " + canon, vv_only=True) - desat_color = conf.json_data['blocks']['desaturated'][canon] + env.log(f"Texture desaturated: {canon}", vv_only=True) + desat_color = env.json_data['blocks']['desaturated'][canon] desat_cmpr = len(nodeSaturateMix.inputs[saturateMixIn[2]].default_value) if len(desat_color) < desat_cmpr: desat_color.append(1.0) @@ -1353,18 +1118,66 @@ def texgen_seus(mat, passes, nodeInputs, use_reflections, use_emission): # nodeTexDisp["MCPREP_disp"] = True nodeTexDiff.image = image_diff +def generate_base_material( + context: Context, name: str, + path: Union[Path, str], useExtraMaps: bool +) -> Tuple[Optional[Material], Optional[str]]: + """Generate a base material from name and active resource pack""" + try: + image = bpy.data.images.load(path, check_existing=True) + except: # if Image is not found + image = None + mat = bpy.data.materials.new(name=name) + + engine = context.scene.render.engine + if engine in ['CYCLES','BLENDER_EEVEE']: + # need to create at least one texture node first, then the rest works + mat.use_nodes = True + nodes = mat.node_tree.nodes + node_diff = create_node( + nodes, 'ShaderNodeTexImage', + name="Diffuse Texture", + label="Diffuse Texture", + location=(-380, 140), + interpolation='Closest', + image=image + ) + node_diff["MCPREP_diffuse"] = True + + # The offset and link diffuse is for default no texture setup + links = mat.node_tree.links + for n in nodes: + if n.bl_idname == 'ShaderNodeBsdfPrincipled': + links.new(node_diff.outputs[0], n.inputs[0]) + links.new(node_diff.outputs[1], n.inputs["Alpha"]) + break + + env.log("Added blank texture node") + # Initialize extra passes as well + if image: + node_spec = create_node(nodes, 'ShaderNodeTexImage') + node_spec["MCPREP_specular"] = True + node_nrm = create_node(nodes, 'ShaderNodeTexImage') + node_nrm["MCPREP_normal"] = True + # now use standard method to update textures + set_cycles_texture(image, mat, useExtraMaps) + + else: + return None, "Only Cycles and Eevee supported" + + return mat, None -def matgen_cycles_simple( - mat, passes, use_reflections, use_emission, only_solid, use_principled, use_emission_nodes): + +def matgen_cycles_simple(mat: Material, options: PrepOptions) -> Optional[bool]: """Generate principled cycles material.""" matGen = util.nameGeneralize(mat.name) canon, form = get_mc_canonical_name(matGen) - image_diff = passes["diffuse"] + image_diff = options.passes["diffuse"] if not image_diff: - print("Could not find diffuse image, halting generation: " + mat.name) + print(f"Could not find diffuse image, halting generation: {mat.name}") return elif image_diff.size[0] == 0 or image_diff.size[1] == 0: if image_diff.source != 'SEQUENCE': @@ -1402,13 +1215,13 @@ def matgen_cycles_simple( node_out = create_node(nodes, "ShaderNodeOutputMaterial", location=(900, 0)) # Sets default reflective values - if use_reflections and checklist(canon, "reflective"): + if options.use_reflections and checklist(canon, "reflective"): principled.inputs["Roughness"].default_value = 0 else: principled.inputs["Roughness"].default_value = 0.7 # Sets default metallic values - if use_reflections and checklist(canon, "metallic"): + if options.use_reflections and checklist(canon, "metallic"): principled.inputs["Metallic"].default_value = 1 if principled.inputs["Roughness"].default_value < 0.2: principled.inputs["Roughness"].default_value = 0.2 @@ -1429,7 +1242,7 @@ def matgen_cycles_simple( links.new(nodeSaturateMix.outputs[saturateMixOut[0]], principled.inputs[0]) links.new(principled.outputs["BSDF"], node_out.inputs[0]) - if only_solid is True or checklist(canon, "solid"): + if options.only_solid is True or checklist(canon, "solid"): # faster, and appropriate for non-transparent (and refelctive?) materials principled.distribution = 'GGX' if hasattr(mat, "blend_method"): @@ -1442,8 +1255,8 @@ def matgen_cycles_simple( mat.blend_method = 'HASHED' if hasattr(mat, "shadow_method"): mat.shadow_method = 'HASHED' - - if use_emission_nodes and use_emission: + + if options.use_emission_nodes and options.use_emission: inputs = [inp.name for inp in principled.inputs] if 'Emission Strength' in inputs: # Later 2.9 versions only. principled.inputs['Emission Strength'].default_value = 1 @@ -1460,10 +1273,9 @@ def matgen_cycles_simple( elif not is_image_grayscale(image_diff): pass else: - conf.log("Texture desaturated: " + canon, vv_only=True) - desat_color = conf.json_data['blocks']['desaturated'][canon] - desat_cmpr = len(nodeSaturateMix.inputs[saturateMixIn[2]].default_value) - if len(desat_color) < desat_cmpr: + env.log(f"Texture desaturated: {canon}", vv_only=True) + desat_color = env.json_data['blocks']['desaturated'][canon] + if len(desat_color) < len(nodeSaturateMix.inputs[saturateMixIn[2]].default_value): desat_color.append(1.0) nodeSaturateMix.inputs[saturateMixIn[2]].default_value = desat_color nodeSaturateMix.mute = False @@ -1476,17 +1288,16 @@ def matgen_cycles_simple( return 0 -def matgen_cycles_principled( - mat, passes, use_reflections, use_emission, only_solid, pack_format, use_emission_nodes): +def matgen_cycles_principled(mat: Material, options: PrepOptions) -> Optional[bool]: """Generate principled cycles material""" matGen = util.nameGeneralize(mat.name) canon, form = get_mc_canonical_name(matGen) - image_diff = passes["diffuse"] + image_diff = options.passes["diffuse"] if not image_diff: - print("Could not find diffuse image, halting generation: " + mat.name) + print(f"Could not find diffuse image, halting generation: {mat.name}") return elif image_diff.size[0] == 0 or image_diff.size[1] == 0: if image_diff.source != 'SEQUENCE': @@ -1517,13 +1328,13 @@ def matgen_cycles_principled( nodeMixTrans.inputs[0].default_value = 1 # Sets default reflective values - if use_reflections and checklist(canon, "reflective"): + if options.use_reflections and checklist(canon, "reflective"): principled.inputs["Roughness"].default_value = 0 else: principled.inputs["Roughness"].default_value = 0.7 # Sets default metallic values - if use_reflections and checklist(canon, "metallic"): + if options.use_reflections and checklist(canon, "metallic"): principled.inputs["Metallic"].default_value = 1 if principled.inputs["Roughness"].default_value < 0.2: principled.inputs["Roughness"].default_value = 0.2 @@ -1540,7 +1351,7 @@ def matgen_cycles_principled( nodes, "ShaderNodeEmission", location=(120, 260)) nodeMixEmit = create_node( nodes, "ShaderNodeMixShader", location=(420, 0)) - if use_emission_nodes: + if options.use_emission_nodes: # Create emission nodes nodeMixCam = create_node( nodes, "ShaderNodeMixShader", location=(320, 260)) @@ -1562,7 +1373,7 @@ def matgen_cycles_principled( links.new(nodeMixCam.outputs["Shader"], nodeMixEmit.inputs[2]) links.new(nodeMixEmit.outputs["Shader"], nodeMixTrans.inputs[2]) - if use_emission: + if options.use_emission: nodeMixEmit.inputs[0].default_value = 1 else: nodeMixEmit.inputs[0].default_value = 0 @@ -1582,22 +1393,22 @@ def matgen_cycles_principled( [principled.inputs["Specular"]], [principled.inputs["Normal"]]] - if not use_emission_nodes: + if not options.use_emission_nodes: nodes.remove(nodeEmit) nodes.remove(nodeEmitCam) nodes.remove(nodeMixEmit) # generate texture format and connect - if pack_format == "specular": - texgen_specular(mat, passes, nodeInputs, use_reflections) - elif pack_format == "seus": - texgen_seus(mat, passes, nodeInputs, use_reflections, use_emission_nodes) + if options.pack_format == PackFormat.SPECULAR: + texgen_specular(mat, options.passes, nodeInputs, options.use_reflections) + elif options.pack_format == PackFormat.SEUS: + texgen_seus(mat, options.passes, nodeInputs, options.use_reflections, options.use_emission_nodes) - if only_solid is True or checklist(canon, "solid"): + if options.only_solid is True or checklist(canon, "solid"): nodes.remove(nodeTrans) nodes.remove(nodeMixTrans) nodeOut.location = (620, 0) - if use_emission_nodes: + if options.use_emission_nodes: links.new(nodeMixEmit.outputs[0], nodeOut.inputs[0]) # faster, and appropriate for non-transparent (and refelctive?) materials @@ -1626,24 +1437,23 @@ def matgen_cycles_principled( # both work fine with depth of field. # but, BLEND does NOT work well with Depth of Field or layering - + # reapply animation data if any to generated nodes apply_texture_animation_pass_settings(mat, animated_data) return 0 -def matgen_cycles_original( - mat, passes, use_reflections, use_emission, only_solid, pack_format, use_emission_nodes): - """Generate principled cycles material""" +def matgen_cycles_original(mat: Material, options: PrepOptions): + """Generate non-principled cycles material""" matGen = util.nameGeneralize(mat.name) canon, form = get_mc_canonical_name(matGen) - image_diff = passes["diffuse"] + image_diff = options.passes["diffuse"] if not image_diff: - print("Could not find diffuse image, halting generation: " + mat.name) + print(f"Could not find diffuse image, halting generation: {mat.name}") return elif image_diff.size[0] == 0 or image_diff.size[1] == 0: if image_diff.source != 'SEQUENCE': @@ -1734,7 +1544,7 @@ def matgen_cycles_original( nodeMixRGBDiff.inputs[mixDiffIn[2]].default_value = [1, 1, 1, 1] # Sets default reflective values - if use_reflections and checklist(canon, "reflective"): + if options.use_reflections and checklist(canon, "reflective"): nodeGlossMetallic.inputs["Roughness"].default_value = 0 nodeMathPower.inputs[0].default_value = 0 nodeGlossDiff.inputs["Roughness"].default_value = 0 @@ -1744,7 +1554,7 @@ def matgen_cycles_original( nodeGlossDiff.inputs["Roughness"].default_value = 0.7 # Sets default metallic values - if use_reflections and checklist(canon, "metallic"): + if options.use_reflections and checklist(canon, "metallic"): nodeMixMetallic.inputs["Fac"].default_value = 1 if nodeGlossMetallic.inputs["Roughness"].default_value < 0.2: @@ -1817,12 +1627,12 @@ def matgen_cycles_original( nodeBump.inputs["Normal"]]] # generate texture format and connect - if pack_format == "specular": - texgen_specular(mat, passes, nodeInputs, use_reflections) - elif pack_format == "seus": - texgen_seus(mat, passes, nodeInputs, use_reflections, use_emission_nodes) + if options.pack_format == PackFormat.SPECULAR: + texgen_specular(mat, options.passes, nodeInputs, options.use_reflections) + elif options.pack_format == PackFormat.SEUS: + texgen_seus(mat, options.passes, nodeInputs, options.use_reflections, options.use_emission_nodes) - if only_solid is True or checklist(canon, "solid"): + if options.only_solid is True or checklist(canon, "solid"): nodes.remove(nodeTrans) nodes.remove(nodeMixTrans) nodeOut.location = (1540, 0) @@ -1853,7 +1663,7 @@ def matgen_cycles_original( # but, BLEND does NOT work well with Depth of Field or layering - if use_emission: + if options.use_emission: nodeMixEmit.inputs[0].default_value = 1 else: nodeMixEmit.inputs[0].default_value = 0 @@ -1864,7 +1674,7 @@ def matgen_cycles_original( return 0 -def matgen_special_water(mat, passes): +def matgen_special_water(mat: Material, passes: Dict[str, Image]) -> Optional[bool]: """Generate special water material""" matGen = util.nameGeneralize(mat.name) @@ -1875,7 +1685,7 @@ def matgen_special_water(mat, passes): image_norm = passes["normal"] if not image_diff: - print("Could not find diffuse image, halting generation: " + mat.name) + print(f"Could not find diffuse image, halting generation: {mat.name}") return elif image_diff.size[0] == 0 or image_diff.size[1] == 0: if image_diff.source != 'SEQUENCE': @@ -2001,8 +1811,8 @@ def matgen_special_water(mat, passes): elif not is_image_grayscale(image_diff): pass else: - conf.log("Texture desaturated: " + canon, vv_only=True) - desat_color = conf.json_data['blocks']['desaturated'][canon] + env.log(f"Texture desaturated: {canon}", vv_only=True) + desat_color = env.json_data['blocks']['desaturated'][canon] if len(desat_color) < len(nodeSaturateMix.inputs[saturateMixIn[2]].default_value): desat_color.append(1.0) nodeSaturateMix.inputs[saturateMixIn[2]].default_value = desat_color @@ -2018,7 +1828,7 @@ def matgen_special_water(mat, passes): return 0 -def matgen_special_glass(mat, passes): +def matgen_special_glass(mat: Material, passes: Dict[str, Image]) -> Optional[bool]: """Generate special glass material""" matGen = util.nameGeneralize(mat.name) @@ -2029,7 +1839,7 @@ def matgen_special_glass(mat, passes): image_norm = passes["normal"] if not image_diff: - print("Could not find diffuse image, halting generation: " + mat.name) + print(f"Could not find diffuse image, halting generation: {mat.name}") return elif image_diff.size[0] == 0 or image_diff.size[1] == 0: if image_diff.source != 'SEQUENCE': diff --git a/MCprep_addon/materials/material_manager.py b/MCprep_addon/materials/material_manager.py index d6bd58b3..e4e5ef87 100644 --- a/MCprep_addon/materials/material_manager.py +++ b/MCprep_addon/materials/material_manager.py @@ -21,12 +21,13 @@ import bpy -from .. import conf from . import generate from . import sequences from .. import tracking from .. import util +from ..conf import env + # ----------------------------------------------------------------------------- # UI and utility functions @@ -40,14 +41,14 @@ def reload_materials(context): extensions = [".png", ".jpg", ".jpeg"] mcprep_props.material_list.clear() - if conf.use_icons and conf.preview_collections["materials"]: + if env.use_icons and env.preview_collections["materials"]: try: - bpy.utils.previews.remove(conf.preview_collections["materials"]) + bpy.utils.previews.remove(env.preview_collections["materials"]) except: - conf.log("Failed to remove icon set, materials") + env.log("Failed to remove icon set, materials") if not os.path.isdir(resource_folder): - conf.log("Error, resource folder does not exist") + env.log("Error, resource folder does not exist") return # Check multiple paths, picking the first match (order is important), @@ -83,15 +84,15 @@ def reload_materials(context): canon, _ = generate.get_mc_canonical_name(basename) asset = mcprep_props.material_list.add() asset.name = canon - asset.description = "Generate {} ({})".format(canon, basename) + asset.description = f"Generate {canon} ({basename})" asset.path = image_file asset.index = i # if available, load the custom icon too - if not conf.use_icons or conf.preview_collections["materials"] == "": + if not env.use_icons or env.preview_collections["materials"] == "": continue - conf.preview_collections["materials"].load( - "material-{}".format(i), image_file, 'IMAGE') + env.preview_collections["materials"].load( + f"material-{i}", image_file, 'IMAGE') if mcprep_props.material_list_index >= len(mcprep_props.material_list): mcprep_props.material_list_index = len(mcprep_props.material_list) - 1 @@ -100,9 +101,9 @@ def reload_materials(context): class ListMaterials(bpy.types.PropertyGroup): """For UI drawing of item assets and holding data""" # inherited: name - description = bpy.props.StringProperty() - path = bpy.props.StringProperty(subtype='FILE_PATH') - index = bpy.props.IntProperty(min=0, default=0) # for icon drawing + description: bpy.props.StringProperty() + path: bpy.props.StringProperty(subtype='FILE_PATH') + index: bpy.props.IntProperty(min=0, default=0) # for icon drawing # ----------------------------------------------------------------------------- @@ -143,11 +144,11 @@ class MCPREP_OT_combine_materials(bpy.types.Operator): bl_options = {'REGISTER', 'UNDO'} # arg to auto-force remove old? versus just keep as 0-users - selection_only = bpy.props.BoolProperty( + selection_only: bpy.props.BoolProperty( name="Selection only", description="Build materials to consoldiate based on selected objects only", default=True) - skipUsage = bpy.props.BoolProperty(default=False, options={'HIDDEN'}) + skipUsage: bpy.props.BoolProperty(default=False, options={'HIDDEN'}) track_function = "combine_materials" @tracking.report_error @@ -196,7 +197,7 @@ def getMaterials(self, context): elif mat.name not in name_cat[base]: name_cat[base].append(mat.name) else: - conf.log("Skipping, already added material", True) + env.log("Skipping, already added material", True) # Pre 2.78 solution, deep loop. if bpy.app.version < (2, 78): @@ -213,9 +214,7 @@ def getMaterials(self, context): postcount = len([True for x in bpy.data.materials if x.users > 0]) self.report( {"INFO"}, - "Consolidated {x} materials, down to {y} overall".format( - x=precount - postcount, - y=postcount)) + f"Consolidated {precount - postcount} materials, down to {postcount} overall") return {'FINISHED'} # perform the consolidation with one basename set at a time @@ -226,28 +225,25 @@ def getMaterials(self, context): name_cat[base].sort() # in-place sorting baseMat = bpy.data.materials[name_cat[base][0]] - conf.log([name_cat[base], " ## ", baseMat], vv_only=True) + env.log(f"{name_cat[base]} ## {baseMat}", vv_only=True) for matname in name_cat[base][1:]: # skip if fake user set if bpy.data.materials[matname].use_fake_user is True: continue # otherwise, remap - res = util.remap_users(bpy.data.materials[matname], baseMat) - if res != 0: - self.report({'ERROR'}, str(res)) - return {'CANCELLED'} + bpy.data.materials[matname].user_remap(baseMat) old = bpy.data.materials[matname] - conf.log("removing old? " + matname, vv_only=True) + env.log(f"removing old? {matname}", vv_only=True) if removeold is True and old.users == 0: - conf.log("removing old:" + matname, vv_only=True) + env.log(f"removing old:{matname}", vv_only=True) try: data.remove(old) except ReferenceError as err: - print('Error trying to remove material ' + matname) + print(f'Error trying to remove material {matname}') print(str(err)) except ValueError as err: - print('Error trying to remove material ' + matname) + print(f'Error trying to remove material {matname}') print(str(err)) # Final step.. rename to not have .001 if it does, @@ -261,12 +257,10 @@ def getMaterials(self, context): baseMat.name = gen_base else: baseMat.name = gen_base - conf.log(["Final: ", baseMat], vv_only=True) + env.log(f"Final: {baseMat}", vv_only=True) postcount = len(["x" for x in getMaterials(self, context) if x.users > 0]) - self.report({"INFO"}, "Consolidated {x} materials down to {y}".format( - x=precount, - y=postcount)) + self.report({"INFO"}, f"Consolidated {precount} materials down to {postcount}") return {'FINISHED'} @@ -277,12 +271,12 @@ class MCPREP_OT_combine_images(bpy.types.Operator): bl_description = "Consolidate the same images together e.g. img.001 and img.002" # arg to auto-force remove old? versus just keep as 0-users - selection_only = bpy.props.BoolProperty( + selection_only: bpy.props.BoolProperty( name="Selection only", description=( "Build images to consoldiate based on selected objects' materials only"), default=False) - skipUsage = bpy.props.BoolProperty( + skipUsage: bpy.props.BoolProperty( default=False, options={'HIDDEN'}) @@ -323,7 +317,7 @@ def execute(self, context): elif im.name not in name_cat[base]: name_cat[base].append(im.name) else: - conf.log("Skipping, already added image", vv_only=True) + env.log("Skipping, already added image", vv_only=True) # pre 2.78 solution, deep loop if bpy.app.version < (2, 78): @@ -337,8 +331,7 @@ def execute(self, context): postcount = len(["x" for x in bpy.data.materials if x.users > 0]) self.report( {"INFO"}, - "Consolidated {x} materials, down to {y} overall".format( - x=precount - postcount, y=postcount)) + f"Consolidated {precount - postcount} materials, down to {postcount} overall") return {'FINISHED'} # perform the consolidation with one basename set at a time @@ -353,7 +346,7 @@ def execute(self, context): if bpy.data.images[imgname].use_fake_user is True: continue # otherwise, remap - util.remap_users(data[imgname], baseImg) + data[imgname].user_remap(baseImg) old = bpy.data.images[imgname] if removeold is True and old.users == 0: bpy.data.images.remove(bpy.data.images[imgname]) @@ -372,9 +365,7 @@ def execute(self, context): postcount = len(["x" for x in bpy.data.images if x.users > 0]) self.report( - {"INFO"}, "Consolidated {x} images down to {y}".format( - x=precount, - y=postcount)) + {"INFO"}, f"Consolidated {precount} images down to {postcount}") return {'FINISHED'} @@ -386,11 +377,11 @@ class MCPREP_OT_replace_missing_textures(bpy.types.Operator): bl_options = {'REGISTER', 'UNDO'} # deleteAlpha = False - animateTextures = bpy.props.BoolProperty( + animateTextures: bpy.props.BoolProperty( name="Animate textures (may be slow first time)", description="Convert tiled images into image sequence for material.", default=True) - skipUsage = bpy.props.BoolProperty( + skipUsage: bpy.props.BoolProperty( default=False, options={'HIDDEN'}) @@ -417,7 +408,7 @@ def execute(self, context): updated = False passes = generate.get_textures(mat) if not passes: - conf.log("No images found within material") + env.log("No images found within material") for pass_name in passes: if pass_name == 'diffuse' and passes[pass_name] is None: res = self.load_from_texturepack(mat) @@ -427,17 +418,16 @@ def execute(self, context): updated = True if updated: count += 1 - conf.log("Updated " + mat.name) + env.log(f"Updated {mat.name}") if self.animateTextures: sequences.animate_single_material( mat, context.scene.render.engine) if count == 0: self.report( {'INFO'}, - "No missing image blocks detected in {} materials".format( - len(mat_list))) + f"No missing image blocks detected in {len(mat_list)} materials") - self.report({'INFO'}, "Updated {} materials".format(count)) + self.report({'INFO'}, f"Updated {count} materials") self.track_param = context.scene.render.engine addon_prefs = util.get_user_preferences(context) self.track_exporter = addon_prefs.MCprep_exporter_type @@ -445,15 +435,15 @@ def execute(self, context): def load_from_texturepack(self, mat): """If image datablock not found in passes, try to directly load and assign""" - conf.log("Loading from texpack for " + mat.name, vv_only=True) + env.log(f"Loading from texpack for {mat.name}", vv_only=True) canon, _ = generate.get_mc_canonical_name(mat.name) image_path = generate.find_from_texturepack(canon) if not image_path or not os.path.isfile(image_path): - conf.log("Find missing images: No source file found for " + mat.name) + env.log(f"Find missing images: No source file found for {mat.name}") return False # even if images of same name already exist, load new block - conf.log("Find missing images: Creating new image datablock for " + mat.name) + env.log(f"Find missing images: Creating new image datablock for {mat.name}") # do not use 'check_existing=False' to keep compatibility pre 2.79 image = bpy.data.images.load(image_path, check_existing=True) @@ -483,7 +473,6 @@ def load_from_texturepack(self, mat): def register(): for cls in classes: - util.make_annotations(cls) bpy.utils.register_class(cls) diff --git a/MCprep_addon/materials/prep.py b/MCprep_addon/materials/prep.py old mode 100755 new mode 100644 index 9a8f27ce..66279ed6 --- a/MCprep_addon/materials/prep.py +++ b/MCprep_addon/materials/prep.py @@ -21,13 +21,14 @@ import bpy from bpy_extras.io_utils import ImportHelper +from bpy.types import Context -from .. import conf from . import generate from . import sequences +from . import uv_tools from .. import tracking from .. import util -from . import uv_tools +from ..conf import env # ----------------------------------------------------------------------------- # Material class functions @@ -44,55 +45,54 @@ class McprepMaterialProps(): def pack_formats(self, context): """Blender version-dependant format for cycles/eevee material formats.""" itms = [] - if util.bv28(): - itms.append(( - "simple", "Simple (no PBR)", - "Use a simple shader setup with no PBR or emission falloff.")) + itms.append(( + "simple", "Simple (no PBR)", + "Use a simple shader setup with no PBR or emission falloff.")) itms.append(("specular", "Specular", "Sets the pack format to Specular.")) itms.append(("seus", "SEUS", "Sets the pack format to SEUS.")) return itms - - animateTextures = bpy.props.BoolProperty( + + animateTextures: bpy.props.BoolProperty( name="Animate textures (may be slow first time)", description=( "Swap still images for the animated sequenced found in " "the active or default texture pack."), default=False) - autoFindMissingTextures = bpy.props.BoolProperty( + autoFindMissingTextures: bpy.props.BoolProperty( name="Find missing images", description=( "If the texture for an existing material is missing, try " "to load from the default texture pack instead"), default=True) - combineMaterials = bpy.props.BoolProperty( + combineMaterials: bpy.props.BoolProperty( name="Combine materials", description="Consolidate duplciate materials & textures", default=False) - improveUiSettings = bpy.props.BoolProperty( + improveUiSettings: bpy.props.BoolProperty( name="Improve UI", description="Automatically improve relevant UI settings", default=True) - optimizeScene = bpy.props.BoolProperty( + optimizeScene: bpy.props.BoolProperty( name="Optimize scene (cycles)", description="Optimize the scene for faster cycles rendering", default=False) - usePrincipledShader = bpy.props.BoolProperty( + usePrincipledShader: bpy.props.BoolProperty( name="Use Principled Shader (if available)", description=( "If available and using cycles, build materials using the " "principled shader"), default=True) - useReflections = bpy.props.BoolProperty( + useReflections: bpy.props.BoolProperty( name="Use reflections", description="Allow appropriate materials to be rendered reflective", default=True) - useExtraMaps = bpy.props.BoolProperty( + useExtraMaps: bpy.props.BoolProperty( name="Use extra maps", description=( "load other image passes like normal and " "spec maps if available"), default=True) - normalIntensity = bpy.props.FloatProperty( + normalIntensity: bpy.props.FloatProperty( name="Normal map intensity", description=( "Set normal map intensity, if normal maps are found in " @@ -100,33 +100,33 @@ def pack_formats(self, context): default=1.0, max=1, min=0) - makeSolid = bpy.props.BoolProperty( + makeSolid: bpy.props.BoolProperty( name="Make all materials solid", description="Make all materials solid only, for shadows and rendering", default=False) - syncMaterials = bpy.props.BoolProperty( + syncMaterials: bpy.props.BoolProperty( name="Sync materials", description=( "Synchronize materials with those in the active " "pack's materials.blend file"), default=True) - # newDefault = bpy.props.BoolProperty( + # newDefault: bpy.props.BoolProperty( # name="Use custom default material", # description="Use a custom default material if you have one set up", # default=False) - packFormat = bpy.props.EnumProperty( + packFormat: bpy.props.EnumProperty( name="Pack Format", description="Change the pack format when using a PBR resource pack.", items=pack_formats ) - useEmission = bpy.props.BoolProperty( + useEmission: bpy.props.BoolProperty( name="Use Emission", description="Make emmisive materials emit light", default=True ) -def draw_mats_common(self, context): +def draw_mats_common(self, context: Context) -> None: row = self.layout.row() col = row.column() engine = context.scene.render.engine @@ -170,7 +170,7 @@ class MCPREP_OT_prep_materials(bpy.types.Operator, McprepMaterialProps): bl_label = "MCprep Materials" bl_options = {'REGISTER', 'UNDO'} - skipUsage = bpy.props.BoolProperty( + skipUsage: bpy.props.BoolProperty( default=False, options={'HIDDEN'}) @@ -208,7 +208,7 @@ def execute(self, context): for mat in mat_list: if not mat: - conf.log( + env.log( "During prep, found null material:" + str(mat), vv_only=True) continue @@ -244,31 +244,33 @@ def execute(self, context): if res > 0: mat["texture_swapped"] = True # used to apply saturation - if engine == 'BLENDER_RENDER' or engine == 'BLENDER_GAME': - res = generate.matprep_internal( - mat, passes, self.useReflections, self.makeSolid) - if res == 0: - count += 1 - elif engine == 'CYCLES' or engine == 'BLENDER_EEVEE': + if engine == 'CYCLES' or engine == 'BLENDER_EEVEE': + options = generate.PrepOptions( + passes, + self.useReflections, + self.usePrincipledShader, + self.makeSolid, + generate.PackFormat[self.packFormat.upper()], + self.useEmission, + False # This is for an option set in matprep_cycles + ) res = generate.matprep_cycles( mat=mat, - passes=passes, - use_reflections=self.useReflections, - use_principled=self.usePrincipledShader, - only_solid=self.makeSolid, - pack_format=self.packFormat, - use_emission_nodes=self.useEmission) + options=options + ) if res == 0: count += 1 else: self.report( {'ERROR'}, - "Only Blender Internal, Cycles, and Eevee are supported") + "Only Cycles and Eevee are supported") return {'CANCELLED'} if self.animateTextures: sequences.animate_single_material( - mat, context.scene.render.engine) + mat, + context.scene.render.engine, + export_location=sequences.ExportLocation.ORIGINAL) # Sync materials. if self.syncMaterials is True: @@ -284,7 +286,7 @@ def execute(self, context): try: bpy.ops.mcprep.improve_ui() except RuntimeError as err: - print("Failed to improve UI with error: " + str(err)) + print(f"Failed to improve UI with error: {err}") if self.optimizeScene and engine == 'CYCLES': bpy.ops.mcprep.optimize_scene() @@ -294,14 +296,14 @@ def execute(self, context): elif count_lib_skipped > 0: self.report( {"INFO"}, - "Modified {} materials, skipped {} linked ones.".format( - count, count_lib_skipped)) + f"Modified {count} materials, skipped {count_lib_skipped} linked ones.") elif count > 0: - self.report({"INFO"}, "Modified " + str(count) + " materials") + self.report({"INFO"}, f"Modified {count} materials") else: self.report( {"ERROR"}, - "Nothing modified, be sure you selected objects with existing materials!") + "Nothing modified, be sure you selected objects with existing materials!" + ) addon_prefs = util.get_user_preferences(context) self.track_param = context.scene.render.engine @@ -317,15 +319,15 @@ class MCPREP_OT_materials_help(bpy.types.Operator): def update_supress_help(self, context): """Update trigger for supressing help popups.""" - if conf.v and self.suppress_help: + if env.verbose and self.suppress_help: print("Supressing future help popups") - elif conf.v and not self.suppress_help: + elif env.verbose and not self.suppress_help: print("Re-enabling popup warnings") - help_num = bpy.props.IntProperty( + help_num: bpy.props.IntProperty( default=0, options={'HIDDEN'}) - suppress_help = bpy.props.BoolProperty( + suppress_help: bpy.props.BoolProperty( name="Don't show this again", default=False, options={'HIDDEN'}, @@ -386,25 +388,26 @@ class MCPREP_OT_swap_texture_pack( "select a folder path for an unzipped resource pack or texture folder") bl_options = {'REGISTER', 'UNDO'} - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="", - options={'HIDDEN'}) + options={"HIDDEN"}) use_filter_folder = True fileselectparams = "use_filter_blender" - filepath = bpy.props.StringProperty(subtype='DIR_PATH') - filter_image = bpy.props.BoolProperty( + filepath: bpy.props.StringProperty(subtype="DIR_PATH") + filter_image: bpy.props.BoolProperty( default=True, - options={'HIDDEN', 'SKIP_SAVE'}) - filter_folder = bpy.props.BoolProperty( + options={"HIDDEN", "SKIP_SAVE"}) + filter_folder: bpy.props.BoolProperty( default=True, - options={'HIDDEN', 'SKIP_SAVE'}) - prepMaterials = bpy.props.BoolProperty( + options={"HIDDEN", "SKIP_SAVE"}) + prepMaterials: bpy.props.BoolProperty( name="Prep materials", description="Runs prep materials after texture swap to regenerate materials", - default=False) - skipUsage = bpy.props.BoolProperty( default=False, - options={'HIDDEN'}) + ) + skipUsage: bpy.props.BoolProperty( + default=False, + options={"HIDDEN"}) @classmethod def poll(cls, context): @@ -438,6 +441,7 @@ def draw(self, context): track_function = "texture_pack" track_param = None track_exporter = None + @tracking.report_error def execute(self, context): addon_prefs = util.get_user_preferences(context) @@ -446,7 +450,7 @@ def execute(self, context): folder = self.filepath if os.path.isfile(bpy.path.abspath(folder)): folder = os.path.dirname(folder) - conf.log("Folder: " + folder) + env.log(f"Folder: {folder}") if not os.path.isdir(bpy.path.abspath(folder)): self.report({'ERROR'}, "Selected folder does not exist") @@ -471,14 +475,16 @@ def execute(self, context): # set the scene's folder for the texturepack being swapped context.scene.mcprep_texturepack_path = folder - conf.log("Materials detected: " + str(len(mat_list))) + env.log(f"Materials detected: {len(mat_list)}") res = 0 for mat in mat_list: self.preprocess_material(mat) res += generate.set_texture_pack(mat, folder, self.useExtraMaps) if self.animateTextures: sequences.animate_single_material( - mat, context.scene.render.engine) + mat, + context.scene.render.engine, + export_location=sequences.ExportLocation.ORIGINAL) # may be a double call if was animated tex generate.set_saturation_material(mat) @@ -501,10 +507,10 @@ def execute(self, context): self.report({'ERROR'}, ( "Detected scaled UV's (all in one texture), be sure to use " "Mineway's 'Export Individual Textures To..'' feature")) - conf.log("Detected scaledd UV's, incompatible with swap textures") - conf.log([ob.name for ob in affected_objs], vv_only=True) + env.log("Detected scaledd UV's, incompatible with swap textures") + env.log([ob.name for ob in affected_objs], vv_only=True) else: - self.report({'INFO'}, "{} materials affected".format(res)) + self.report({'INFO'}, f"{res} materials affected") self.track_param = context.scene.render.engine return {'FINISHED'} @@ -515,7 +521,7 @@ def preprocess_material(self, material): # but in Mineways export, this is the flattened grass/drit block side if material.name == "grass_block_side_overlay": material.name = "grass_block_side" - conf.log("Renamed material: grass_block_side_overlay to grass_block_side") + env.log("Renamed material: grass_block_side_overlay to grass_block_side") class MCPREP_OT_load_material(bpy.types.Operator, McprepMaterialProps): @@ -526,8 +532,8 @@ class MCPREP_OT_load_material(bpy.types.Operator, McprepMaterialProps): "Generate and apply the selected material based on active resource pack") bl_options = {'REGISTER', 'UNDO'} - filepath = bpy.props.StringProperty(default="") - skipUsage = bpy.props.BoolProperty(default=False, options={'HIDDEN'}) + filepath: bpy.props.StringProperty(default="") + skipUsage: bpy.props.BoolProperty(default=False, options={'HIDDEN'}) @classmethod def poll(cls, context): @@ -550,8 +556,9 @@ def execute(self, context): "File not found! Reset the resource pack under advanced " "settings (return arrow icon) and press reload materials")) return {'CANCELLED'} - mat, err = self.generate_base_material( - context, mat_name, self.filepath) + # Create the base material node tree setup + mat, err = generate.generate_base_material( + context, mat_name, self.filepath, self.useExtraMaps) if mat is None and err: self.report({"ERROR"}, err) return {'CANCELLED'} @@ -583,47 +590,18 @@ def execute(self, context): self.track_param = context.scene.render.engine return {'FINISHED'} - def generate_base_material(self, context, name, path): - """Generate a base material from name and active resource pack""" - image = bpy.data.images.load(path, check_existing=True) - mat = bpy.data.materials.new(name=name) - - engine = context.scene.render.engine - if engine == 'BLENDER_RENDER' or engine == 'BLENDER_GAME': - generate.set_internal_texture(image, mat, self.useExtraMaps) - elif engine == 'CYCLES' or engine == 'BLENDER_EEVEE': - # need to create at least one texture node first, then the rest works - mat.use_nodes = True - nodes = mat.node_tree.nodes - node_diff = generate.create_node(nodes, 'ShaderNodeTexImage', image=image) - node_diff["MCPREP_diffuse"] = True - - # Initialize extra passes as well - node_spec = generate.create_node(nodes, 'ShaderNodeTexImage') - node_spec["MCPREP_specular"] = True - node_nrm = generate.create_node(nodes, 'ShaderNodeTexImage') - node_nrm["MCPREP_normal"] = True - - conf.log("Added blank texture node") - - # now use standard method to update textures - generate.set_cycles_texture(image, mat, self.useExtraMaps) - else: - return None, "Only Blender Internal, Cycles, or Eevee supported" - - return mat, None def update_material(self, context, mat): """Update the initially created material""" if not mat: - conf.log("During prep, found null material:" + str(mat), vv_only=True) + env.log(f"During prep, found null material: {mat}", vv_only=True) return elif mat.library: return engine = context.scene.render.engine passes = generate.get_textures(mat) - conf.log("Load Mat Passes:" + str(passes), vv_only=True) + env.log(f"Load Mat Passes:{passes}", vv_only=True) if not self.useExtraMaps: for pass_name in passes: if pass_name != "diffuse": @@ -635,26 +613,30 @@ def update_material(self, context, mat): if res > 0: mat["texture_swapped"] = True # used to apply saturation - if engine == 'BLENDER_RENDER' or engine == 'BLENDER_GAME': - res = generate.matprep_internal( - mat, passes, self.useReflections, self.makeSolid) - elif engine == 'CYCLES' or engine == 'BLENDER_EEVEE': - res = generate.matprep_cycles( - mat=mat, + if engine == 'CYCLES' or engine == 'BLENDER_EEVEE': + options = generate.PrepOptions( passes=passes, use_reflections=self.useReflections, use_principled=self.usePrincipledShader, only_solid=self.makeSolid, - pack_format=self.packFormat, - use_emission_nodes=self.useEmission) + pack_format=generate.PackFormat[self.packFormat.upper()], + use_emission_nodes=self.useEmission, + use_emission=False # This is for an option set in matprep_cycles + ) + res = generate.matprep_cycles( + mat=mat, + options=options + ) else: - return False, "Only Blender Internal, Cycles, or Eevee supported" + return False, "Only Cycles and Eevee supported" success = res == 0 if self.animateTextures: sequences.animate_single_material( - mat, context.scene.render.engine) + mat, + context.scene.render.engine, + export_location=sequences.ExportLocation.ORIGINAL) return success, None @@ -673,9 +655,7 @@ def update_material(self, context, mat): def register(): - util.make_annotations(McprepMaterialProps) for cls in classes: - util.make_annotations(cls) bpy.utils.register_class(cls) diff --git a/MCprep_addon/materials/sequences.py b/MCprep_addon/materials/sequences.py index 8b9db870..ba112f57 100644 --- a/MCprep_addon/materials/sequences.py +++ b/MCprep_addon/materials/sequences.py @@ -17,18 +17,28 @@ # ##### END GPL LICENSE BLOCK ##### +from pathlib import Path +from typing import Optional, Tuple, Dict +import enum import errno import json import os import re import bpy +from bpy.types import Context, Material, Image, Texture -from .. import conf from . import generate +from . import uv_tools from .. import tracking from .. import util -from . import uv_tools +from ..conf import env, Engine, Form + + +class ExportLocation(enum.Enum): + ORIGINAL = "original" + LOCAL = "local" + TEXTUREPACK = "texturepack" # ----------------------------------------------------------------------------- @@ -37,7 +47,7 @@ def animate_single_material( - mat, engine, export_location='original', clear_cache=False): + mat: Material, engine: Engine, export_location: ExportLocation, clear_cache: bool=False) -> Tuple[bool, bool, str]: """Animates texture for single material, including all passes. Args: @@ -63,22 +73,21 @@ def animate_single_material( # get the base image from the texturepack (cycles/BI general) image_path_canon = generate.find_from_texturepack(canon) if not image_path_canon: - conf.log("Canon path not found for {}:{}, form {}, path: {}".format( - mat_gen, canon, form, image_path_canon), vv_only=True) + env.log(f"Canon path not found for {mat_gen}:{canon}, form {form}, path: {image_path_canon}", vv_only=True) return affectable, False, None - if not os.path.isfile(image_path_canon + ".mcmeta"): - conf.log(".mcmeta not found for " + mat_gen, vv_only=True) + if not os.path.isfile(f"{image_path_canon}.mcmeta"): + env.log(f".mcmeta not found for {mat_gen}", vv_only=True) affectable = False return affectable, False, None affectable = True mcmeta = {} - with open(image_path_canon + ".mcmeta", "r") as mcf: + with open(f"{image_path_canon}.mcmeta", "r") as mcf: try: mcmeta = json.load(mcf) except json.JSONDecodeError: print("Failed to parse the mcmeta data") - conf.log("MCmeta for {}: {}".format(diffuse_block, mcmeta)) + env.log(f"MCmeta for {diffuse_block}: {mcmeta}") # apply the sequence if any found, will be empty dict if not if diffuse_block and hasattr(diffuse_block, "filepath"): @@ -87,12 +96,12 @@ def animate_single_material( source_path = None if not source_path: source_path = image_path_canon - conf.log("Fallback to using image canon path instead of source path") + env.log("Fallback to using image canon path instead of source path") tile_path_dict, err = generate_material_sequence( source_path, image_path_canon, form, export_location, clear_cache) if err: - conf.log("Error occured during sequence generation:") - conf.log(err) + env.log("Error occured during sequence generation:") + env.log(err) return affectable, False, err if tile_path_dict == {}: return affectable, False, None @@ -100,7 +109,7 @@ def animate_single_material( affected_materials = 0 for pass_name in tile_path_dict: if not tile_path_dict[pass_name]: # ie '' - conf.log("Skipping passname: " + pass_name) + env.log(f"Skipping passname: {pass_name}") continue if engine == 'CYCLES' or engine == 'BLENDER_EEVEE': node = generate.get_node_for_pass(mat, pass_name) @@ -112,6 +121,7 @@ def animate_single_material( # since image sequences make it tricky to read pixel data (shows empty) # assign cache based on the undersatnding if known desaturated or not node.image['grayscale'] = generate.checklist(canon, "desaturated") + # TODO 2.7 elif engine == 'BLENDER_RENDER' or engine == 'BLENDER_GAME': texture = generate.get_texlayer_for_pass(mat, pass_name) if not texture: @@ -123,7 +133,7 @@ def animate_single_material( return affectable, affected_materials > 0, None -def is_image_tiled(image_block): +def is_image_tiled(image_block: Image) -> bool: """Checks whether an image block tiled.""" if not image_block or image_block.size[0] == 0: return False @@ -134,8 +144,7 @@ def is_image_tiled(image_block): return True -def generate_material_sequence( - source_path, image_path, form, export_location, clear_cache): +def generate_material_sequence(source_path: Path, image_path: Path, form: Optional[Form], export_location: ExportLocation, clear_cache: bool) -> Tuple[Dict[str, Path], Optional[str]]: """Performs frame by frame export of sequences to location based on input. Returns Dictionary of the image paths to the first tile of each @@ -186,20 +195,20 @@ def generate_material_sequence( # export to save frames in currently selected texturepack (could be addon) seq_path_base = os.path.dirname(bpy.path.abspath(image_path)) - if conf.vv: - conf.log("Pre-sequence details") - conf.log(image_path) - conf.log(image_dict) - conf.log(img_pass_dict) - conf.log(seq_path_base) - conf.log("---") + if env.very_verbose: + env.log("Pre-sequence details") + env.log(image_path) + env.log(image_dict) + env.log(img_pass_dict) + env.log(seq_path_base) + env.log("---") if form == "jmc2obj": - conf.log("DEBUG - jmc2obj aniamted texture detected") + env.log("DEBUG - jmc2obj aniamted texture detected") elif form == "mineways": - conf.log("DEBUG - mineways aniamted texture detected") + env.log("DEBUG - mineways aniamted texture detected") else: - conf.log("DEBUG - other form of animated texture detected") + env.log("DEBUG - other form of animated texture detected") perm_denied = ( "Permission denied, could not make folder - " @@ -207,8 +216,8 @@ def generate_material_sequence( for img_pass in img_pass_dict: passfile = img_pass_dict[img_pass] - conf.log("Running on file:") - conf.log(bpy.path.abspath(passfile)) + env.log("Running on file:") + env.log(bpy.path.abspath(passfile)) # Create the sequence subdir (without race condition) pass_name = os.path.splitext(os.path.basename(passfile))[0] @@ -216,7 +225,7 @@ def generate_material_sequence( seq_path = os.path.join(os.path.dirname(passfile), pass_name) else: seq_path = os.path.join(seq_path_base, pass_name) - conf.log("Using sequence directory: " + seq_path) + env.log(f"Using sequence directory: {seq_path}") try: # check parent folder exists/create if needed os.mkdir(os.path.dirname(seq_path)) @@ -225,24 +234,24 @@ def generate_material_sequence( if exc.errno == errno.EACCES: return {}, perm_denied elif exc.errno != errno.EEXIST: # ok if error is that it exists - raise Exception("Failed to make director, missing path: " + seq_path) + raise Exception(f"Failed to make director, missing path: {seq_path}") try: # check folder exists/create if needed os.mkdir(seq_path) except OSError as exc: if exc.errno == errno.EACCES: return {}, perm_denied elif exc.errno != errno.EEXIST: # ok if error is that it exists - raise Exception("Path does not exist: " + seq_path) + raise Exception(f"Path does not exist: {seq_path}") # overwrite the files found if not cached if not os.path.isdir(seq_path): - raise Exception("Path does not exist: " + seq_path) + raise Exception(f"Path does not exist: {seq_path}") cached = [ tile for tile in os.listdir(seq_path) if os.path.isfile(os.path.join(seq_path, tile)) and tile.startswith(pass_name)] if cached: - conf.log("Cached detected") + env.log("Cached detected") if clear_cache and cached: for tile in cached: @@ -267,7 +276,7 @@ def generate_material_sequence( return image_dict, None -def export_image_to_sequence(image_path, params, output_folder=None, form=None): +def export_image_to_sequence(image_path: Path, params: Tuple[str, int, bool], output_folder: Path=None, form: Optional[Form]=None) -> Path: """Convert image tiles into image sequence files. image_path: image filepath source @@ -283,12 +292,12 @@ def export_image_to_sequence(image_path, params, output_folder=None, form=None): image = bpy.data.images.load(image_path) tiles = image.size[1] / image.size[0] if int(tiles) != tiles: - conf.log("Not perfectly tiled image - " + image_path) + env.log(f"Not perfectly tiled image - {image_path}") image.user_clear() if image.users == 0: bpy.data.images.remove(image) else: - conf.log("Couldn't remove image, shouldn't keep: " + image.name) + env.log(f"Couldn't remove image, shouldn't keep: {image.name}") return None # any non-titled materials will exit here else: tiles = int(tiles) @@ -310,8 +319,8 @@ def export_image_to_sequence(image_path, params, output_folder=None, form=None): pxlen = len(image.pixels) first_img = None for i in range(tiles): - conf.log("Exporting sequence tile " + str(i)) - tile_name = basename + "_" + str(i + 1).zfill(4) + env.log(f"Exporting sequence tile {i}") + tile_name = f"{basename}_{i + 1:04}" out_path = os.path.join(output_folder, tile_name + ext) if not first_img: first_img = out_path @@ -319,9 +328,9 @@ def export_image_to_sequence(image_path, params, output_folder=None, form=None): revi = tiles - i - 1 # To reverse index, based on MC tile order. # new image for copying pixels over to - if form != "minways": + if form != "mineways": img_tile = bpy.data.images.new( - basename + "-seq-temp", + f"{basename}-seq-temp", image.size[0], image.size[0], alpha=(image.channels == 4)) img_tile.filepath = out_path @@ -341,22 +350,22 @@ def export_image_to_sequence(image_path, params, output_folder=None, form=None): if img_tile.users == 0: bpy.data.images.remove(img_tile) else: - conf.log("Couldn't remove tile, shouldn't keep: " + img_tile.name) + env.log(f"Couldn't remove tile, shouldn't keep: {img_tile.name}") else: img_tile = None raise Exception("No Animate Textures Mineways support yet") - conf.log("Finished exporting frame sequence: " + basename) + env.log(f"Finished exporting frame sequence: {basename}") image.user_clear() if image.users == 0: bpy.data.images.remove(image) else: - conf.log("Couldn't remove image block, shouldn't keep: " + image.name) + env.log(f"Couldn't remove image block, shouldn't keep: {image.name}") return bpy.path.abspath(first_img) -def get_sequence_int_index(base_name): +def get_sequence_int_index(base_name: str) -> int: """Return the index of the image name, number of digits at filename end.""" ind = 0 nums = '0123456789' @@ -367,17 +376,16 @@ def get_sequence_int_index(base_name): return ind -def set_sequence_to_texnode(node, image_path): +def set_sequence_to_texnode(node: Texture, image_path: Path) -> None: """Take first image of sequence and apply full sequence to a node. Note: this also works as-is where "node" is actually a texture block """ - conf.log("Sequence exporting " + os.path.basename(image_path), vv_only=True) + env.log(f"Sequence exporting {os.path.basename(image_path)}", vv_only=True) image_path = bpy.path.abspath(image_path) base_dir = os.path.dirname(image_path) first_img = os.path.splitext(os.path.basename(image_path))[0] - conf.log("IMAGE path to apply: {}, node/tex: {}".format( - image_path, node.name)) + env.log(f"IMAGE path to apply: {image_path}, node/tex: {node.name}") ind = get_sequence_int_index(first_img) base_name = first_img[:-ind] @@ -386,7 +394,7 @@ def set_sequence_to_texnode(node, image_path): img_count = len(img_sets) image_data = bpy.data.images.load(image_path) - conf.log("Loaded in " + str(image_data)) + env.log(f"Loaded in {image_data}") image_data.source = 'SEQUENCE' node.image = image_data node.image_user.frame_duration = img_count @@ -406,12 +414,12 @@ class MCPREP_OT_prep_animated_textures(bpy.types.Operator): bl_idname = "mcprep.animate_textures" bl_label = "Animate textures" - clear_cache = bpy.props.BoolProperty( + clear_cache: bpy.props.BoolProperty( default=False, name="Clear cache of previous animated sequence exports", description="Always regenerate tile files, even if tiles already exist" ) - export_location = bpy.props.EnumProperty( + export_location: bpy.props.EnumProperty( name="Save location", items=[ ("original", "Next to current source image", @@ -422,7 +430,7 @@ class MCPREP_OT_prep_animated_textures(bpy.types.Operator): "Save animation tiles next to current saved blend file")], description="Set where to export (or duplicate to) tile sequence images." ) - skipUsage = bpy.props.BoolProperty( + skipUsage: bpy.props.BoolProperty( default=False, options={'HIDDEN'} ) @@ -482,7 +490,7 @@ def execute(self, context): print(self.break_err) self.report( {"ERROR"}, - "Halted: " + str(self.break_err)) + f"Halted: {self.break_err}") return {'CANCELLED'} elif self.affectable_materials == 0: self.report( @@ -499,15 +507,14 @@ def execute(self, context): {'ERROR'}, ("Detected scaled UV's (all in one texture), be sure to use " "Mineway's 'Export Individual Textures To..' feature")) - conf.log("Detected scaled UV's, incompatible with animate textures") + env.log("Detected scaled UV's, incompatible with animate textures") return {'FINISHED'} else: - self.report({'INFO'}, "Modified {} material(s)".format( - self.affected_materials)) + self.report({'INFO'}, f"Modified {self.affected_materials} material(s)") self.track_param = context.scene.render.engine return {'FINISHED'} - def process_single_material(self, context, mat): + def process_single_material(self, context: Context, mat: Material): """Run animate textures for single material, and fix UVs and saturation""" affectable, affected, err = animate_single_material( mat, context.scene.render.engine, @@ -533,7 +540,6 @@ def process_single_material(self, context, mat): def register(): for cls in classes: - util.make_annotations(cls) bpy.utils.register_class(cls) diff --git a/MCprep_addon/materials/skin.py b/MCprep_addon/materials/skin.py index 9a596669..d775d16a 100644 --- a/MCprep_addon/materials/skin.py +++ b/MCprep_addon/materials/skin.py @@ -17,25 +17,29 @@ # ##### END GPL LICENSE BLOCK ##### -import bpy import os -from bpy_extras.io_utils import ImportHelper +from pathlib import Path +from typing import Optional, List, Tuple import shutil import urllib.request + +import bpy +from bpy_extras.io_utils import ImportHelper from bpy.app.handlers import persistent +from bpy.types import Context, Image, Material -from .. import conf from . import generate from .. import tracking from .. import util +from ..conf import env # ----------------------------------------------------------------------------- # Support functions # ----------------------------------------------------------------------------- -def reloadSkinList(context): +def reloadSkinList(context: Context): """Reload the skins in the directory for UI list""" skinfolder = context.scene.mcprep_skin_path @@ -51,18 +55,18 @@ def reloadSkinList(context): for path in files: if path.split(".")[-1].lower() not in ["png", "jpg", "jpeg", "tiff"]: continue - skinlist.append((path, "{x} skin".format(x=path))) + skinlist.append((path, f"{path} skin")) skinlist = sorted(skinlist, key=lambda x: x[0].lower()) # clear lists context.scene.mcprep_skins_list.clear() - conf.skin_list = [] + env.skin_list = [] # recreate for i, (skin, description) in enumerate(skinlist, 1): item = context.scene.mcprep_skins_list.add() - conf.skin_list.append( + env.skin_list.append( (skin, os.path.join(skinfolder, skin)) ) item.label = description @@ -70,9 +74,9 @@ def reloadSkinList(context): item.name = skin -def update_skin_path(self, context): +def update_skin_path(self, context: Context): """For UI list path callback""" - conf.log("Updating rig path", vv_only=True) + env.log("Updating rig path", vv_only=True) reloadSkinList(context) @@ -82,22 +86,22 @@ def handler_skins_enablehack(scene): bpy.app.handlers.scene_update_pre.remove(handler_skins_enablehack) except: pass - conf.log("Triggering Handler_skins_load from first enable", vv_only=True) + env.log("Triggering Handler_skins_load from first enable", vv_only=True) handler_skins_load(scene) @persistent def handler_skins_load(scene): try: - conf.log("Reloading skins", vv_only=True) + env.log("Reloading skins", vv_only=True) reloadSkinList(bpy.context) except: - conf.log("Didn't run skin reloading callback", vv_only=True) + env.log("Didn't run skin reloading callback", vv_only=True) -def loadSkinFile(self, context, filepath, new_material=False): +def loadSkinFile(self, context: Context, filepath: Path, new_material: bool=False): if not os.path.isfile(filepath): - self.report({'ERROR'}, "Image file not found") + self.report({'ERROR'}, f"Image file not found: {filepath}") return 1 # special message for library linking? @@ -124,9 +128,6 @@ def loadSkinFile(self, context, filepath, new_material=False): else: pass - if not util.bv28(): - setUVimage(context.selected_objects, image) - # TODO: adjust the UVs if appropriate, and fix eyes if image.size[0] != 0 and image.size[1] / image.size[0] != 1: self.report({'INFO'}, "Skin swapper works best on 1.8 skins") @@ -134,14 +135,14 @@ def loadSkinFile(self, context, filepath, new_material=False): return 0 -def convert_skin_layout(image_file): +def convert_skin_layout(image_file: Path) -> bool: """Convert skin to 1.8+ layout if old format detected Could be improved using numpy, but avoiding the dependency. """ if not os.path.isfile(image_file): - conf.log("Error! Image file does not exist: " + image_file) + env.log(f"Error! Image file does not exist: {image_file}") return False img = bpy.data.images.load(image_file) @@ -149,13 +150,13 @@ def convert_skin_layout(image_file): return False elif img.size[0] != img.size[1] * 2: # some image that isn't the normal 64x32 of old skin formats - conf.log("Unknown skin image format, not converting layout") + env.log("Unknown skin image format, not converting layout") return False elif img.size[0] / 64 != int(img.size[0] / 64): - conf.log("Non-regular scaling of skin image, can't process") + env.log("Non-regular scaling of skin image, can't process") return False - conf.log("Old image format detected, converting to post 1.8 layout") + env.log("Old image format detected, converting to post 1.8 layout") scale = int(img.size[0] / 64) has_alpha = img.channels == 4 @@ -195,7 +196,7 @@ def convert_skin_layout(image_file): end = (row * block_width * 4) + block_width + (block_width * 2.5) lower_half += upper_half[int(start):int(end)] else: - conf.log("Bad math! Should never go above 4 blocks") + env.log("Bad math! Should never go above 4 blocks") failout = True break @@ -206,7 +207,7 @@ def convert_skin_layout(image_file): new_image.pixels = new_pixels new_image.filepath_raw = image_file new_image.save() - conf.log("Saved out post 1.8 converted skin file") + env.log("Saved out post 1.8 converted skin file") # cleanup files img.user_clear() @@ -220,7 +221,7 @@ def convert_skin_layout(image_file): return False -def getMatsFromSelected(selected, new_material=False): +def getMatsFromSelected(selected: List[bpy.types.Object], new_material: bool=False) -> Tuple[List[Material], List[bpy.types.Object]]: """Get materials; if new material provided, ensure material slot is added Used by skin swapping, to either update existing material or create new one @@ -243,7 +244,7 @@ def getMatsFromSelected(selected, new_material=False): for ob in obj_list: if ob.data.library: - conf.log("Library object, skipping") + env.log("Library object, skipping") linked_objs += 1 continue elif new_material is False: @@ -266,6 +267,7 @@ def getMatsFromSelected(selected, new_material=False): slot.material = mat_ret[mat_list.index(slot.material)] # if internal, also ensure textures are made unique per new mat + # TODO Remove 2.7 engine = bpy.context.scene.render.engine if new_material and (engine == 'BLENDER_RENDER' or engine == 'BLENDER_GAME'): for m in mat_ret: @@ -277,27 +279,13 @@ def getMatsFromSelected(selected, new_material=False): return mat_ret, linked_objs -def setUVimage(objs, image): - """Set image for each face for viewport displaying (2.7 only)""" - for obj in objs: - if obj.type != "MESH": - continue - if not hasattr(obj.data, "uv_textures"): - conf.log("Called setUVimage on object with no uv_textures, 2.8?") - return - if obj.data.uv_textures.active is None: - continue - for uv_face in obj.data.uv_textures.active.data: - uv_face.image = image - - -def download_user(self, context, username): +def download_user(self, context: Context, username: str) -> Optional[Path]: """Download user skin from online. Reusable function from within two common operators for downloading skin. Example link: http://minotar.net/skin/theduckcow """ - conf.log("Downloading skin: " + username) + env.log(f"Downloading skin: {username}") src_link = "http://minotar.net/skin/" saveloc = os.path.join( @@ -305,9 +293,9 @@ def download_user(self, context, username): username.lower() + ".png") try: - if conf.vv: - print("Download starting with url: " + src_link + username.lower()) - print("to save location: " + saveloc) + if env.very_verbose: + print(f"Download starting with url: {src_link} - {username.lower()}") + print(f"to save location: {saveloc}") urllib.request.urlretrieve(src_link + username.lower(), saveloc) except urllib.error.HTTPError as e: print(e) @@ -318,8 +306,8 @@ def download_user(self, context, username): self.report({"ERROR"}, "URL error, check internet connection") return None except Exception as e: - print("Error occured while downloading skin: " + str(e)) - self.report({"ERROR"}, "Error occured while downloading skin: " + str(e)) + print(f"Error occured while downloading skin: {e}") + self.report({"ERROR"}, f"Error occured while downloading skin: {e}") return None # convert to 1.8 skin as needed (double height) @@ -351,8 +339,8 @@ def draw_item( class ListColl(bpy.types.PropertyGroup): """For asset listing""" - label = bpy.props.StringProperty() - description = bpy.props.StringProperty() + label: bpy.props.StringProperty() + description: bpy.props.StringProperty() class MCPREP_OT_swap_skin_from_file(bpy.types.Operator, ImportHelper): @@ -361,21 +349,21 @@ class MCPREP_OT_swap_skin_from_file(bpy.types.Operator, ImportHelper): bl_label = "Swap skin" bl_options = {'REGISTER', 'UNDO'} - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="", options={'HIDDEN'}) fileselectparams = "use_filter_blender" - files = bpy.props.CollectionProperty( + files: bpy.props.CollectionProperty( type=bpy.types.PropertyGroup, options={'HIDDEN', 'SKIP_SAVE'}) - filter_image = bpy.props.BoolProperty( + filter_image: bpy.props.BoolProperty( default=True, options={'HIDDEN', 'SKIP_SAVE'}) - new_material = bpy.props.BoolProperty( + new_material: bpy.props.BoolProperty( name="New Material", description="Create a new material instead of overwriting existing one", default=True) - skipUsage = bpy.props.BoolProperty(default=False, options={'HIDDEN'}) + skipUsage: bpy.props.BoolProperty(default=False, options={'HIDDEN'}) track_function = "skin" track_param = "file import" @@ -395,15 +383,15 @@ class MCPREP_OT_apply_skin(bpy.types.Operator): bl_description = "Apply the active UV image to selected character materials" bl_options = {'REGISTER', 'UNDO'} - filepath = bpy.props.StringProperty( + filepath: bpy.props.StringProperty( name="Skin", description="selected", options={'HIDDEN'}) - new_material = bpy.props.BoolProperty( + new_material: bpy.props.BoolProperty( name="New Material", description="Create a new material instead of overwriting existing one", default=True) - skipUsage = bpy.props.BoolProperty( + skipUsage: bpy.props.BoolProperty( default=False, options={'HIDDEN'}) @@ -425,25 +413,25 @@ class MCPREP_OT_apply_username_skin(bpy.types.Operator): bl_description = "Download and apply skin from specific username" bl_options = {'REGISTER', 'UNDO'} - username = bpy.props.StringProperty( + username: bpy.props.StringProperty( name="Username", description="Exact name of user to get texture from", default="") - skip_redownload = bpy.props.BoolProperty( + skip_redownload: bpy.props.BoolProperty( name="Skip download if skin already local", description="Avoid re-downloading skin and apply local file instead", default=True) - new_material = bpy.props.BoolProperty( + new_material: bpy.props.BoolProperty( name="New Material", description="Create a new material instead of overwriting existing one", default=True) - convert_layout = bpy.props.BoolProperty( + convert_layout: bpy.props.BoolProperty( name="Convert pre 1.8 skins", description=( "If an older skin layout (pre Minecraft 1.8) is detected, convert " "to new format (with clothing layers)"), default=True) - skipUsage = bpy.props.BoolProperty(default=False, options={'HIDDEN'}) + skipUsage: bpy.props.BoolProperty(default=False, options={'HIDDEN'}) def invoke(self, context, event): return context.window_manager.invoke_props_dialog( @@ -464,9 +452,11 @@ def execute(self, context): self.report({"ERROR"}, "Invalid username") return {'CANCELLED'} - skins = [str(skin[0]).lower() for skin in conf.skin_list] - paths = [skin[1] for skin in conf.skin_list] - if self.username.lower() not in skins or not self.skip_redownload: + user_ref = self.username.lower() + ".png" + + skins = [str(skin[0]).lower() for skin in env.skin_list] + paths = [skin[1] for skin in env.skin_list] + if user_ref not in skins or not self.skip_redownload: # Do the download saveloc = download_user(self, context, self.username) if not saveloc: @@ -479,9 +469,9 @@ def execute(self, context): bpy.ops.mcprep.reload_skins() return {'FINISHED'} else: - conf.log("Reusing downloaded skin") - ind = skins.index(self.username.lower()) - res = loadSkinFile(self, context, paths[ind][1], self.new_material) + env.log("Reusing downloaded skin") + ind = skins.index(user_ref) + res = loadSkinFile(self, context, paths[ind], self.new_material) if res != 0: return {'CANCELLED'} return {'FINISHED'} @@ -510,17 +500,17 @@ class MCPREP_OT_add_skin(bpy.types.Operator, ImportHelper): bl_description = "Add a new skin to the active folder" # filename_ext = ".zip" # needs to be only tinder - filter_glob = bpy.props.StringProperty(default="*", options={'HIDDEN'}) + filter_glob: bpy.props.StringProperty(default="*", options={'HIDDEN'}) fileselectparams = "use_filter_blender" - files = bpy.props.CollectionProperty(type=bpy.types.PropertyGroup) + files: bpy.props.CollectionProperty(type=bpy.types.PropertyGroup) - convert_layout = bpy.props.BoolProperty( + convert_layout: bpy.props.BoolProperty( name="Convert pre 1.8 skins", description=( "If an older skin layout (pre Minecraft 1.8) is detected, convert " "to new format (with clothing layers)"), default=True) - skipUsage = bpy.props.BoolProperty(default=False, options={'HIDDEN'}) + skipUsage: bpy.props.BoolProperty(default=False, options={'HIDDEN'}) track_function = "add_skin" track_param = None @@ -571,24 +561,23 @@ def invoke(self, context, event): self, width=400 * util.ui_scale()) def draw(self, context): - skin_path = conf.skin_list[context.scene.mcprep_skins_list_index] + skin_path = env.skin_list[context.scene.mcprep_skins_list_index] col = self.layout.column() col.scale_y = 0.7 - col.label(text="Warning, will delete file {} from".format( - os.path.basename(skin_path[0]))) + col.label(text=f"Warning, will delete file {os.path.basename(skin_path[0])} from") col.label(text=os.path.dirname(skin_path[-1])) @tracking.report_error def execute(self, context): - if not conf.skin_list: + if not env.skin_list: self.report({"ERROR"}, "No skins loaded in memory, try reloading") return {'CANCELLED'} - if context.scene.mcprep_skins_list_index >= len(conf.skin_list): + if context.scene.mcprep_skins_list_index >= len(env.skin_list): self.report({"ERROR"}, "Indexing error") return {'CANCELLED'} - file = conf.skin_list[context.scene.mcprep_skins_list_index][-1] + file = env.skin_list[context.scene.mcprep_skins_list_index][-1] if os.path.isfile(file) is False: self.report({"ERROR"}, "Skin not found to delete") @@ -597,11 +586,11 @@ def execute(self, context): # refresh the folder bpy.ops.mcprep.reload_skins() - if context.scene.mcprep_skins_list_index >= len(conf.skin_list): - context.scene.mcprep_skins_list_index = len(conf.skin_list) - 1 + if context.scene.mcprep_skins_list_index >= len(env.skin_list): + context.scene.mcprep_skins_list_index = len(env.skin_list) - 1 # in future, select multiple - self.report({"INFO"}, "Removed " + bpy.path.basename(file)) + self.report({"INFO"}, f"Removed {bpy.path.basename(file)}") return {'FINISHED'} @@ -639,7 +628,7 @@ class MCPREP_OT_spawn_mob_with_skin(bpy.types.Operator): bl_label = "Spawn with skin" bl_description = "Spawn rig and apply selected skin" - relocation = bpy.props.EnumProperty( + relocation: bpy.props.EnumProperty( items=[ ('Cursor', 'Cursor', 'No relocation'), ('Clear', 'Origin', 'Move the rig to the origin'), @@ -647,22 +636,22 @@ class MCPREP_OT_spawn_mob_with_skin(bpy.types.Operator): 'Offset the root bone to curse while moving the rest pose to ' 'the origin'))], name="Relocation") - toLink = bpy.props.BoolProperty( + toLink: bpy.props.BoolProperty( name="Library Link", description="Library link instead of append the group", default=False) - clearPose = bpy.props.BoolProperty( + clearPose: bpy.props.BoolProperty( name="Clear Pose", description="Clear the pose to rest position", default=True) - skipUsage = bpy.props.BoolProperty(default=False, options={'HIDDEN'}) + skipUsage: bpy.props.BoolProperty(default=False, options={'HIDDEN'}) track_function = "spawn_with_skin" track_param = None @tracking.report_error def execute(self, context): scn_props = context.scene.mcprep_props - if not conf.skin_list: + if not env.skin_list: self.report({'ERROR'}, "No skins found") return {'CANCELLED'} @@ -678,7 +667,7 @@ def execute(self, context): # bpy.ops.mcprep.spawn_with_skin() spawn based on active mob ind = context.scene.mcprep_skins_list_index - _ = loadSkinFile(self, context, conf.skin_list[ind][1]) + _ = loadSkinFile(self, context, env.skin_list[ind][1]) return {'FINISHED'} @@ -690,24 +679,24 @@ class MCPREP_OT_download_username_list(bpy.types.Operator): bl_description = "Download a list of skins from comma-separated usernames" bl_options = {'REGISTER', 'UNDO'} - username_list = bpy.props.StringProperty( + username_list: bpy.props.StringProperty( name="Username list", description="Comma-separated list of usernames to download.", default="" ) - skip_redownload = bpy.props.BoolProperty( + skip_redownload: bpy.props.BoolProperty( name="Skip download if skin already local", description="Avoid re-downloading skin and apply local file instead", default=True ) - convert_layout = bpy.props.BoolProperty( + convert_layout: bpy.props.BoolProperty( name="Convert pre 1.8 skins", description=( "If an older skin layout (pre Minecraft 1.8) is detected, convert " "to new format (with clothing layers)"), default=True ) - skipUsage = bpy.props.BoolProperty( + skipUsage: bpy.props.BoolProperty( default=False, options={'HIDDEN'} ) @@ -735,7 +724,7 @@ def execute(self, context): user_list = list(set(user_list)) # Make list unique. # Currently loaded - skins = [str(skin[0]).lower() for skin in conf.skin_list] + skins = [str(skin[0]).lower() for skin in env.skin_list] issue_skins = [] for username in user_list: if username.lower() not in skins or not self.skip_redownload: @@ -751,11 +740,10 @@ def execute(self, context): elif issue_skins and len(issue_skins) < len(user_list): self.report( {"WARNING"}, - "Could not download {} of {} skins, see console".format( - len(issue_skins), len(user_list))) + f"Could not download {len(issue_skins)} of {len(user_list)} skins, see console") return {'FINISHED'} else: - self.report({"INFO"}, "Downloaded {} skins".format(len(user_list))) + self.report({"INFO"}, f"Downloaded {len(user_list)} skins") return {'FINISHED'} @@ -782,7 +770,6 @@ def execute(self, context): def register(): for cls in classes: - util.make_annotations(cls) bpy.utils.register_class(cls) bpy.types.Scene.mcprep_skins_list = bpy.props.CollectionProperty( @@ -790,7 +777,7 @@ def register(): bpy.types.Scene.mcprep_skins_list_index = bpy.props.IntProperty(default=0) # to auto-load the skins - conf.log("Adding reload skin handler to scene", vv_only=True) + env.log("Adding reload skin handler to scene", vv_only=True) try: bpy.app.handlers.scene_update_pre.append(handler_skins_enablehack) except: diff --git a/MCprep_addon/materials/sync.py b/MCprep_addon/materials/sync.py index 86169b22..effd448b 100644 --- a/MCprep_addon/materials/sync.py +++ b/MCprep_addon/materials/sync.py @@ -18,56 +18,59 @@ import os +from typing import Union, Tuple +from pathlib import Path import bpy from bpy.app.handlers import persistent +from bpy.types import Context, Material from . import generate -from .. import conf +from ..conf import env from .. import tracking from .. import util - # ----------------------------------------------------------------------------- # Utilities # ----------------------------------------------------------------------------- + @persistent def clear_sync_cache(scene): - conf.log("Resetting sync mat cache", vv_only=True) - conf.material_sync_cache = None + env.log("Resetting sync mat cache", vv_only=True) + env.material_sync_cache = None -def get_sync_blend(context): +def get_sync_blend(context: Context) -> Path: """Return the sync blend file path that might exist, based on active pack""" resource_pack = bpy.path.abspath(context.scene.mcprep_texturepack_path) return os.path.join(resource_pack, "materials.blend") -def reload_material_sync_library(context): +def reload_material_sync_library(context: Context) -> None: """Reloads the library and cache""" sync_file = get_sync_blend(context) if not os.path.isfile(sync_file): - conf.material_sync_cache = [] + env.material_sync_cache = [] return with bpy.data.libraries.load(sync_file) as (data_from, _): - conf.material_sync_cache = list(data_from.materials) - conf.log("Updated sync cache", vv_only=True) + env.material_sync_cache = list(data_from.materials) + env.log("Updated sync cache", vv_only=True) -def material_in_sync_library(mat_name, context): +def material_in_sync_library(mat_name: str, context: Context) -> bool: """Returns true if the material is in the sync mat library blend file""" - if conf.material_sync_cache is None: + if env.material_sync_cache is None: reload_material_sync_library(context) - if util.nameGeneralize(mat_name) in conf.material_sync_cache: + if util.nameGeneralize(mat_name) in env.material_sync_cache: return True - elif mat_name in conf.material_sync_cache: + elif mat_name in env.material_sync_cache: return True return False -def sync_material(context, source_mat, sync_mat_name, link, replace): +def sync_material(context: Context, source_mat: Material, sync_mat_name: str, link: bool, replace: bool) -> Tuple[bool, Union[bool, str, None]]: """If found, load and apply the material found in a library. Args: @@ -81,9 +84,9 @@ def sync_material(context, source_mat, sync_mat_name, link, replace): 0 if nothing modified, 1 if modified None if no error or string if error """ - if sync_mat_name in conf.material_sync_cache: + if sync_mat_name in env.material_sync_cache: import_name = sync_mat_name - elif util.nameGeneralize(sync_mat_name) in conf.material_sync_cache: + elif util.nameGeneralize(sync_mat_name) in env.material_sync_cache: import_name = util.nameGeneralize(sync_mat_name) # if link is true, check library material not already linked @@ -93,14 +96,10 @@ def sync_material(context, source_mat, sync_mat_name, link, replace): imported = set(bpy.data.materials[:]) - set(init_mats) if not imported: - return 0, "Could not import {}".format(import_name) + return 0, f"Could not import {import_name}" new_material = list(imported)[0] - # 2.78+ only, else silent failure - res = util.remap_users(source_mat, new_material) - if res != 0: - # try a fallback where we at least go over the selected objects - return 0, res + source_mat.user_remap(new_material) if replace is True: bpy.data.materials.remove(source_mat) @@ -118,21 +117,21 @@ class MCPREP_OT_sync_materials(bpy.types.Operator): bl_label = "Sync Materials" bl_options = {'REGISTER', 'UNDO'} - selected = bpy.props.BoolProperty( + selected: bpy.props.BoolProperty( name="Only selected", description=( "Affect only the materials on selected objects, otherwise " "sync all materials in blend file"), default=True) - link = bpy.props.BoolProperty( + link: bpy.props.BoolProperty( name="Link", description="Link instead of appending material", default=False) - replace_materials = bpy.props.BoolProperty( + replace_materials: bpy.props.BoolProperty( name="Replace", description="Delete the local materials being synced, where matched", default=False) - skipUsage = bpy.props.BoolProperty( + skipUsage: bpy.props.BoolProperty( default=False, options={'HIDDEN'}) @@ -146,7 +145,7 @@ def execute(self, context): sync_file = get_sync_blend(context) if not os.path.isfile(sync_file): if not self.skipUsage: - self.report({'ERROR'}, "Sync file not found: " + sync_file) + self.report({'ERROR'}, "Sync file not found: {sync_file}") return {'CANCELLED'} if sync_file == bpy.data.filepath: @@ -197,7 +196,7 @@ def execute(self, context): last_err = err if last_err: - conf.log("Most recent error during sync:" + str(last_err)) + env.log(f"Most recent error during sync:{last_err}") # Re-establish initial state, as append material clears selections for obj in inital_selection: @@ -217,7 +216,38 @@ def execute(self, context): elif modified == 1: self.report({'INFO'}, "Synced 1 material") else: - self.report({'INFO'}, "Synced {} materials".format(modified)) + self.report({'INFO'}, f"Synced {modified} materials") + return {'FINISHED'} + + +class MCPREP_OT_edit_sync_materials_file(bpy.types.Operator): + """Open the the file used fo syncrhonization.""" + bl_idname = "mcprep.edit_sync_materials_file" + bl_label = "Edit sync file" + bl_options = {'REGISTER', 'UNDO'} + + track_function = "edit_sync_materials" + track_param = None + @tracking.report_error + def execute(self, context): + file = get_sync_blend(context) + if not bpy.data.is_saved: + self.report({'ERROR'}, "Save your blend file first") + return {'CANCELLED'} + + # Will open without saving or prompting! + # TODO: Perform action more similar to the asset browser, which opens + # a new instance of blender. + if os.path.isfile(file): + bpy.ops.wm.open_mainfile(filepath=file) + else: + # Open and save a new sync file instead. + bpy.ops.wm.read_homefile(use_empty=True) + + # Set the local resource pack to match this generated file. + bpy.context.scene.mcprep_texturepack_path = "//" + + bpy.ops.wm.save_as_mainfile(filepath=file) return {'FINISHED'} @@ -265,7 +295,6 @@ def execute(self, context): def register(): for cls in classes: - util.make_annotations(cls) bpy.utils.register_class(cls) bpy.app.handlers.load_post.append(clear_sync_cache) diff --git a/MCprep_addon/materials/uv_tools.py b/MCprep_addon/materials/uv_tools.py index 1a634cbc..8b602034 100644 --- a/MCprep_addon/materials/uv_tools.py +++ b/MCprep_addon/materials/uv_tools.py @@ -18,10 +18,12 @@ import bpy +from bpy.types import Context import time +from typing import Dict, List, Tuple -from .. import conf +from ..conf import env from . import generate from .. import tracking from .. import util @@ -31,7 +33,7 @@ # UV functions # ----------------------------------------------------------------------------- -def get_uv_bounds_per_material(obj): +def get_uv_bounds_per_material(obj: bpy.types.Object) -> Dict[str, list]: """Return the maximum uv bounds per object, split per material Returns: @@ -83,7 +85,7 @@ def get_uv_bounds_per_material(obj): return res -def detect_invalid_uvs_from_objs(obj_list): +def detect_invalid_uvs_from_objs(obj_list: List[bpy.types.Object]) -> Tuple[bool, List[bpy.types.Object]]: """Detect all-in one combined images from concentrated UV layouts. Returns: @@ -98,7 +100,7 @@ def detect_invalid_uvs_from_objs(obj_list): # rightfully not up against bounds of image. But generally, for combined # images the area covered is closer to 0.05 thresh = 0.25 - conf.log("Doing check for invalid UV faces", vv_only=True) + env.log("Doing check for invalid UV faces", vv_only=True) t0 = time.time() for obj in obj_list: @@ -122,7 +124,7 @@ def detect_invalid_uvs_from_objs(obj_list): invalid_objects.append(obj) t1 = time.time() t_diff = t1 - t0 # round to .1s - conf.log("UV check took {}s".format(t_diff), vv_only=True) + env.log(f"UV check took {t_diff}s", vv_only=True) return invalid, invalid_objects @@ -137,18 +139,18 @@ class MCPREP_OT_scale_uv(bpy.types.Operator): bl_description = "Scale all selected UV faces. See F6 or redo-last panel to adjust factor" bl_options = {'REGISTER', 'UNDO'} - scale = bpy.props.FloatProperty(default=0.75, name="Scale") - selected_only = bpy.props.BoolProperty(default=True, name="Seleced only") - skipUsage = bpy.props.BoolProperty(default=False, options={'HIDDEN'}) + scale: bpy.props.FloatProperty(default=0.75, name="Scale") + selected_only: bpy.props.BoolProperty(default=True, name="Seleced only") + skipUsage: bpy.props.BoolProperty(default=False, options={'HIDDEN'}) @classmethod - def poll(cls, context): + def poll(cls, context: Context): return context.mode == 'EDIT_MESH' or ( context.mode == 'OBJECT' and context.object) track_function = "scale_uv" @tracking.report_error - def execute(self, context): + def execute(self, context: Context): # INITIAL WIP """ @@ -158,10 +160,10 @@ def execute(self, context): uvs = ob.data.uv_layers[0].data matchingVertIndex = list(chain.from_iterable(polyIndices)) # example, matching list of uv coord and 3dVert coord: - uvs_XY = [i.uv for i in Object.data.uv_layers[0].data] - vertXYZ= [v.co for v in Object.data.vertices] + uvs_XY = [i.uv for i in bpy.types.Object.data.uv_layers[0].data] + vertXYZ= [v.co for v in bpy.types.Object.data.vertices] matchingVertIndex = list(chain.from_iterable( - [p.vertices for p in Object.data.polygons])) + [p.vertices for p in bpy.types.Object.data.polygons])) # and now, the coord to pair with uv coord: matchingVertsCoord = [vertsXYZ[i] for i in matchingVertIndex] """ @@ -187,7 +189,7 @@ def execute(self, context): if ret is not None: self.report({'ERROR'}, ret) - conf.log("Error, " + ret) + env.log(f"Error, {ret}") return {'CANCELLED'} return {'FINISHED'} @@ -237,25 +239,25 @@ class MCPREP_OT_select_alpha_faces(bpy.types.Operator): bl_description = "Select or delete transparent UV faces of a mesh" bl_options = {'REGISTER', 'UNDO'} - delete = bpy.props.BoolProperty( + delete: bpy.props.BoolProperty( name="Delete faces", description="Delete detected transparent mesh faces", default=False) - threshold = bpy.props.FloatProperty( + threshold: bpy.props.FloatProperty( name="Threshold", description="How transparent pixels need to be to select", default=0.2, min=0.0, max=1.0) - skipUsage = bpy.props.BoolProperty(default=False, options={'HIDDEN'}) + skipUsage: bpy.props.BoolProperty(default=False, options={'HIDDEN'}) @classmethod - def poll(cls, context): + def poll(cls, context: Context): return context.mode == 'EDIT_MESH' track_function = "alpha_faces" @tracking.report_error - def execute(self, context): + def execute(self, context: Context): ob = context.object if ob is None: @@ -285,7 +287,7 @@ def execute(self, context): return {"CANCELLED"} if not ret and self.delete: - conf.log("Delet faces") + env.log("Delet faces") bpy.ops.mesh.delete(type='FACE') return {"FINISHED"} @@ -293,7 +295,7 @@ def execute(self, context): def select_alpha(self, ob, threshold): """Core function to select alpha faces based on active material/image.""" if not ob.material_slots: - conf.log("No materials, skipping.") + env.log("No materials, skipping.") return "No materials" # pre-cache the materials and their respective images for comparing @@ -309,7 +311,7 @@ def select_alpha(self, ob, threshold): continue elif image.channels != 4: textures.append(None) # no alpha channel anyways - conf.log("No alpha channel for: " + image.name) + env.log(f"No alpha channel for: {image.name}") continue textures.append(image) data = [None for tex in textures] @@ -321,7 +323,7 @@ def select_alpha(self, ob, threshold): fnd = f.material_index image = textures[fnd] if not image: - conf.log("Could not get image from face's material") + env.log("Could not get image from face's material") return "Could not get image from face's material" # lazy load alpha part of image to memory, hold for whole operator @@ -348,7 +350,7 @@ def select_alpha(self, ob, threshold): xmax = round(max(xlist) * image.size[0]) - 0.5 ymin = round(min(ylist) * image.size[1]) - 0.5 ymax = round(max(ylist) * image.size[1]) - 0.5 - conf.log(["\tSet size:", xmin, xmax, ymin, ymax], vv_only=True) + env.log(["\tSet size:", xmin, xmax, ymin, ymax], vv_only=True) # assuming faces are roughly rectangular, sum pixels a face covers asum = 0 @@ -362,17 +364,16 @@ def select_alpha(self, ob, threshold): asum += data[fnd][image.size[1] * row + col] acount += 1 except IndexError as err: - print("Index error while parsing col {}, row {}: {}".format( - col, row, err)) + print(f"Index error while parsing col {col}, row {row}: {err}") if acount == 0: acount = 1 ratio = float(asum) / float(acount) if ratio < float(threshold): - print("\t{} - Below threshold, select".format(ratio)) + print(f"\t{ratio} - Below threshold, select") f.select = True else: - print("\t{} - above thresh, NO select".format(ratio)) + print(f"\t{ratio} - above thresh, NO select") f.select = False return @@ -390,7 +391,6 @@ def select_alpha(self, ob, threshold): def register(): for cls in classes: - util.make_annotations(cls) bpy.utils.register_class(cls) diff --git a/MCprep_addon/mcprep_ui.py b/MCprep_addon/mcprep_ui.py index 4b24b4a1..f906bef5 100644 --- a/MCprep_addon/mcprep_ui.py +++ b/MCprep_addon/mcprep_ui.py @@ -19,11 +19,12 @@ import os import time +# library imports import bpy +from bpy.types import Context, UILayout # addon imports from . import addon_updater_ops -from . import conf from . import optimize_scene from . import tracking from . import util @@ -37,12 +38,13 @@ from .spawner import meshswap from .spawner import mobs from .spawner import spawn_util +from .conf import env # from .import_bridge import bridge -# blender 2.7 vs 2.8 icon selections -LOAD_FACTORY = 'LOOP_BACK' if util.bv28() else 'LOAD_FACTORY' -HAND_ICON = 'FILE_REFRESH' if util.bv28() else 'HAND' -OPT_IN = 'URL' if util.bv28() else 'HAND' +# blender 2.8 icon selections +LOAD_FACTORY = 'LOOP_BACK' +HAND_ICON = 'FILE_REFRESH' +OPT_IN = 'URL' def addon_just_updated(): @@ -63,9 +65,9 @@ def addon_just_updated(): # hasn't been renamed yet to mcprep_data.json, which happens on init after # an install/update) check_interval = 5 # Time in seconds - if time.time() - check_interval > conf.last_check_for_updated: + if time.time() - check_interval > env.last_check_for_updated: check_for_updated_files() - conf.last_check_for_updated = time.time() + env.last_check_for_updated = time.time() return @@ -81,7 +83,7 @@ def check_for_updated_files(): This covers the scenario where someone used the native blender install addon *instead* of the auto updater route to update the addon. """ - if os.path.isfile(conf.json_path_update): + if os.path.isfile(env.json_path_update): addon_updater_ops.updater.json["just_updated"] = True @@ -125,13 +127,13 @@ def draw(self, context): for mobkey in keys: # show icon if available mob = scn_props.mob_list_all[mobkey] - icn = "mob-{}".format(mob.index) - if conf.use_icons and icn in conf.preview_collections["mobs"]: + icn = f"mob-{mob.index}" + if env.use_icons and icn in env.preview_collections["mobs"]: ops = layout.operator( "mcprep.mob_spawner", text=mob.name, - icon_value=conf.preview_collections["mobs"][icn].icon_id) - elif conf.use_icons: + icon_value=env.preview_collections["mobs"][icn].icon_id) + elif env.use_icons: ops = layout.operator( "mcprep.mob_spawner", text=mob.name, icon="BLANK1") else: @@ -139,7 +141,7 @@ def draw(self, context): ops.mcmob_type = mob.mcmob_type # Skip prep materials in case of unique shader. - if conf.json_data and mob.name in conf.json_data.get("mob_skip_prep", []): + if env.json_data and mob.name in env.json_data.get("mob_skip_prep", []): ops.prep_materials = False @@ -165,10 +167,10 @@ def draw(self, context): icon=icn ) opr.block = blockset[0] - opr.location = util.get_cuser_location(context) + opr.location = util.get_cursor_location(context) # Ensure meshswap with rigs is made real, so the rigs can be used. - if conf.json_data and blockset[1] in conf.json_data.get("make_real", []): + if env.json_data and blockset[1] in env.json_data.get("make_real", []): opr.make_real = True @@ -182,12 +184,12 @@ def draw(self, context): if not context.scene.mcprep_props.item_list: layout.label(text="No items found!") for item in context.scene.mcprep_props.item_list: - icn = "item-{}".format(item.index) - if conf.use_icons and icn in conf.preview_collections["items"]: + icn = f"item-{item.index}" + if env.use_icons and icn in env.preview_collections["items"]: ops = layout.operator( "mcprep.spawn_item", text=item.name, - icon_value=conf.preview_collections["items"][icn].icon_id) - elif conf.use_icons: + icon_value=env.preview_collections["items"][icn].icon_id) + elif env.use_icons: ops = layout.operator( "mcprep.spawn_item", text=item.name, icon="BLANK1") else: @@ -202,7 +204,7 @@ class MCPREP_MT_effect_spawn(bpy.types.Menu): def draw(self, context): col = self.layout.column() - loc = util.get_cuser_location(context) + loc = util.get_cursor_location(context) for effect in context.scene.mcprep_props.effects_list: if effect.effect_type in (effects.GEO_AREA, effects.PARTICLE_AREA): if effect.effect_type == effects.GEO_AREA: @@ -222,12 +224,12 @@ def draw(self, context): ops.location = loc ops.frame = context.scene.frame_current elif effect.effect_type == effects.IMG_SEQ: - icon = "effects-{}".format(effect.index) - if conf.use_icons and icon in conf.preview_collections["effects"]: + icon = f"effects-{effect.index}" + if env.use_icons and icon in env.preview_collections["effects"]: ops = col.operator( "mcprep.spawn_instant_effect", text=effect.name, - icon_value=conf.preview_collections["effects"][icon].icon_id) + icon_value=env.preview_collections["effects"][icon].icon_id) else: ops = col.operator( "mcprep.spawn_instant_effect", @@ -268,9 +270,6 @@ class MCPREP_MT_model_spawn(bpy.types.Menu): def draw(self, context): layout = self.layout - if not util.bv28(): - layout.label(text="Requires blender 2.8 or newer") - return if not context.scene.mcprep_props.model_list: layout.label(text="No models found!") for model in context.scene.mcprep_props.model_list: @@ -278,7 +277,7 @@ def draw(self, context): mcmodel.MCPREP_OT_spawn_minecraft_model.bl_idname, text=model.name) opr.filepath = model.filepath - opr.location = util.get_cuser_location(context) + opr.location = util.get_cursor_location(context) class MCPREP_MT_3dview_add(bpy.types.Menu): @@ -293,13 +292,13 @@ def draw(self, context): layout = self.layout props = context.scene.mcprep_props - if conf.preview_collections["main"] != "": - spawner_icon = conf.preview_collections["main"].get("spawner_icon") - meshswap_icon = conf.preview_collections["main"].get("meshswap_icon") - sword_icon = conf.preview_collections["main"].get("sword_icon") - effects_icon = conf.preview_collections["main"].get("effects_icon") - entity_icon = conf.preview_collections["main"].get("entity_icon") - model_icon = conf.preview_collections["main"].get("model_icon") + if env.preview_collections["main"] != "": + spawner_icon = env.preview_collections["main"].get("spawner_icon") + meshswap_icon = env.preview_collections["main"].get("meshswap_icon") + sword_icon = env.preview_collections["main"].get("sword_icon") + effects_icon = env.preview_collections["main"].get("effects_icon") + entity_icon = env.preview_collections["main"].get("entity_icon") + model_icon = env.preview_collections["main"].get("model_icon") else: spawner_icon = None meshswap_icon = None @@ -309,7 +308,7 @@ def draw(self, context): model_icon = None all_loaded = props.mob_list and props.meshswap_list and props.item_list - if not conf.loaded_all_spawners and not all_loaded: + if not env.loaded_all_spawners and not all_loaded: row = layout.row() row.operator( "mcprep.reload_spawners", text="Load spawners", icon=HAND_ICON) @@ -356,7 +355,7 @@ def draw(self, context): layout.menu(MCPREP_MT_meshswap_place.bl_idname) -def mineways_update(self, context): +def mineways_update(self, context: Context) -> None: """For updating the mineways path on OSX.""" if ".app/" in self.open_mineways_path: # will run twice inherently @@ -364,7 +363,7 @@ def mineways_update(self, context): self.open_mineways_path = temp + ".app" -def feature_set_update(self, context): +def feature_set_update(self, context: Context) -> None: tracking.Tracker.feature_set = self.feature_set tracking.trackUsage("feature_set", param=self.feature_set) @@ -374,50 +373,50 @@ class McprepPreference(bpy.types.AddonPreferences): scriptdir = bpy.path.abspath(os.path.dirname(__file__)) def change_verbose(self, context): - conf.v = self.verbose + env.verbose = self.verbose - meshswap_path = bpy.props.StringProperty( + meshswap_path: bpy.props.StringProperty( name="Meshswap path", description=( "Default path to the meshswap asset file, for " "meshswapable objects and groups"), subtype='FILE_PATH', - default=scriptdir + "/MCprep_resources/mcprep_meshSwap.blend") - entity_path = bpy.props.StringProperty( + default=f"{scriptdir}/MCprep_resources/mcprep_meshSwap.blend") + entity_path: bpy.props.StringProperty( name="Entity path", description="Default path to the entity asset file, for entities", subtype='FILE_PATH', default=os.path.join(scriptdir, "MCprep_resources", "mcprep_entities.blend")) - mob_path = bpy.props.StringProperty( + mob_path: bpy.props.StringProperty( name="Mob path", description="Default folder for rig loads/spawns in new blender instances", subtype='DIR_PATH', - default=scriptdir + "/MCprep_resources/rigs/") - custom_texturepack_path = bpy.props.StringProperty( + default=f"{scriptdir}/MCprep_resources/rigs/") + custom_texturepack_path: bpy.props.StringProperty( name="Texture pack path", description=( "Path to a folder containing resources and textures to use " "with material prepping"), subtype='DIR_PATH', - default=scriptdir + "/MCprep_resources/resourcepacks/mcprep_default/") - skin_path = bpy.props.StringProperty( + default=f"{scriptdir}/MCprep_resources/resourcepacks/mcprep_default/") + skin_path: bpy.props.StringProperty( name="Skin path", description="Folder for skin textures, used in skin swapping", subtype='DIR_PATH', - default=scriptdir + "/MCprep_resources/skins/") - effects_path = bpy.props.StringProperty( + default=f"{scriptdir}/MCprep_resources/skins/") + effects_path: bpy.props.StringProperty( name="Effects path", description="Folder for effects blend files and assets", subtype='DIR_PATH', - default=scriptdir + "/MCprep_resources/effects/") - world_obj_path = bpy.props.StringProperty( + default=f"{scriptdir}/MCprep_resources/effects/") + world_obj_path: bpy.props.StringProperty( name="World Folder", description=( "Default folder for opening world objs from programs " "like jmc2obj or Mineways"), subtype='DIR_PATH', default="//") - MCprep_groupAppendLayer = bpy.props.IntProperty( + MCprep_groupAppendLayer: bpy.props.IntProperty( name="Group Append Layer", description=( "When groups are appended instead of linked, " @@ -426,43 +425,43 @@ def change_verbose(self, context): min=0, max=20, default=20) - MCprep_exporter_type = bpy.props.EnumProperty( + MCprep_exporter_type: bpy.props.EnumProperty( items=[ ('(choose)', '(choose)', 'Select your exporter'), ('jmc2obj', 'jmc2obj', 'Select if exporter used was jmc2obj'), ('Mineways', 'Mineways', 'Select if exporter used was Mineways')], name="Exporter") - preferences_tab = bpy.props.EnumProperty( + preferences_tab: bpy.props.EnumProperty( items=[ ('settings', 'Settings', 'Change MCprep settings'), ('tutorials', 'Tutorials', 'View MCprep tutorials & other help'), ('tracker_updater', 'Tracking/Updater', 'Change tracking and updating settings')], name="Exporter") - verbose = bpy.props.BoolProperty( + verbose: bpy.props.BoolProperty( name="Verbose logging", description="Print out more information in the console", default=False, update=change_verbose) - open_jmc2obj_path = bpy.props.StringProperty( + open_jmc2obj_path: bpy.props.StringProperty( name="jmc2obj path", description="Path to the jmc2obj executable", subtype='FILE_PATH', default="jmc2obj.jar") - open_mineways_path = bpy.props.StringProperty( + open_mineways_path: bpy.props.StringProperty( name="Mineways path", description="Path to the Mineways executable", subtype='FILE_PATH', update=mineways_update, default="Mineways") - save_folder = bpy.props.StringProperty( + save_folder: bpy.props.StringProperty( name="MC saves folder", description=( "Folder containing Minecraft world saves directories, " "for the direct import bridge"), subtype='FILE_PATH', default='') - feature_set = bpy.props.EnumProperty( + feature_set: bpy.props.EnumProperty( items=[ ('supported', 'Supported', 'Use only supported features'), ('experimental', 'Experimental', 'Enable experimental features')], @@ -471,31 +470,31 @@ def change_verbose(self, context): # addon updater preferences - auto_check_update = bpy.props.BoolProperty( + auto_check_update: bpy.props.BoolProperty( name="Auto-check for Update", description="If enabled, auto-check for updates using an interval", default=True, ) - updater_interval_months = bpy.props.IntProperty( + 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( + updater_interval_days: bpy.props.IntProperty( name='Days', description="Number of days between checking for updates", default=1, min=0, ) - updater_interval_hours = bpy.props.IntProperty( + 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( + updater_interval_minutes: bpy.props.IntProperty( name='Minutes', description="Number of minutes between checking for updates", default=0, @@ -509,8 +508,6 @@ def draw(self, context): row.prop(self, "preferences_tab", expand=True) factor_width = 0.3 - if util.bv28(): - factor_width = 0.3 if self.preferences_tab == "settings": @@ -721,15 +718,13 @@ def draw(self, context): # updater draw function addon_updater_ops.update_settings_ui(self, context) - if not util.bv28(): - layout.label(text="Don't forget to save user preferences!") class MCPREP_PT_world_imports(bpy.types.Panel): """World importing related settings and tools""" bl_label = "World Imports" bl_space_type = 'VIEW_3D' - bl_region_type = 'TOOLS' if not util.bv28() else 'UI' + bl_region_type = 'UI' # bl_context = "objectmode" bl_category = "MCprep" @@ -769,14 +764,9 @@ def draw(self, context): row.operator("mcprep.open_jmc2obj") wpath = addon_prefs.world_obj_path - if util.bv28(): - # custom operator for splitting via mats after importing - col.operator( - "mcprep.import_world_split", - text="OBJ world import").filepath = wpath - else: - col.operator( - "import_scene.obj", text="OBJ world import").filepath = wpath + col.operator( + "mcprep.import_world_split", + text="OBJ world import").filepath = wpath split = layout.split() col = split.column(align=True) @@ -803,22 +793,13 @@ def draw(self, context): col = split.column(align=True) # Indicate whether UI can be improved or not. - view27 = ['TEXTURED', 'MATEIRAL', 'RENDERED'] view28 = ['SOLID', 'MATERIAL', 'RENDERED'] + + improved_28 = util.viewport_textured(context) is True + improved_28 &= context.scene.display.shading.type in view28 + improved_28 &= context.scene.display.shading.background_type == "WORLD" - improved_27 = not util.bv28() - if improved_27: - improved_27 &= util.viewport_textured(context) is True - improved_27 &= context.space_data.viewport_shade in view27 - improved_27 &= util.get_preferences(context).system.use_mipmaps is False - - improved_28 = util.bv28() if improved_28: - improved_28 &= util.viewport_textured(context) is True - improved_28 &= context.scene.display.shading.type in view28 - improved_28 &= context.scene.display.shading.background_type == "WORLD" - - if improved_27 or improved_28: row = col.row(align=True) row.enabled = False row.operator( @@ -829,15 +810,14 @@ def draw(self, context): "mcprep.improve_ui", text="Improve UI", icon='SETTINGS') # Optimizer Panel (only for blender 2.80+) - if util.min_bv((2, 80)): + row = col.row(align=True) + icon = "TRIA_DOWN" if scn_props.show_settings_optimizer else "TRIA_RIGHT" + row.prop( + scn_props, "show_settings_optimizer", + text="Cycles Optimizer", icon=icon) + if scn_props.show_settings_optimizer: row = col.row(align=True) - icon = "TRIA_DOWN" if scn_props.show_settings_optimizer else "TRIA_RIGHT" - row.prop( - scn_props, "show_settings_optimizer", - text="Cycles Optimizer", icon=icon) - if scn_props.show_settings_optimizer: - row = col.row(align=True) - optimize_scene.panel_draw(context, row) + optimize_scene.panel_draw(context, row) # Advanced settings. row = col.row(align=True) @@ -897,7 +877,7 @@ class MCPREP_PT_bridge(bpy.types.Panel): """MCprep panel for directly importing and reloading minecraft saves""" bl_label = "World Bridge" bl_space_type = "VIEW_3D" - bl_region_type = 'TOOLS' if not util.bv28() else 'UI' + bl_region_type = 'UI' bl_context = "objectmode" bl_category = "MCprep" @@ -914,7 +894,7 @@ class MCPREP_PT_world_tools(bpy.types.Panel): """World settings and tools""" bl_label = "World Tools" bl_space_type = 'VIEW_3D' - bl_region_type = 'TOOLS' if not util.bv28() else 'UI' + bl_region_type = 'UI' bl_category = "MCprep" def draw(self, context): @@ -969,7 +949,7 @@ class MCPREP_PT_skins(bpy.types.Panel): """MCprep panel for skin swapping""" bl_label = "Skin Swapper" bl_space_type = 'VIEW_3D' - bl_region_type = 'TOOLS' if not util.bv28() else 'UI' + bl_region_type = 'UI' bl_category = "MCprep" def draw(self, context): @@ -993,18 +973,18 @@ def draw(self, context): row = layout.row() col = row.column() - is_sortable = len(conf.skin_list) > 1 + is_sortable = len(env.skin_list) > 1 rows = 1 if (is_sortable): rows = 4 # any other conditions for needing reloading? - if not conf.skin_list: + if not env.skin_list: col = layout.column() col.label(text="No skins found/loaded") p = col.operator( "mcprep.reload_skins", text="Press to reload", icon="ERROR") - elif conf.skin_list and len(conf.skin_list) <= sind: + elif env.skin_list and len(env.skin_list) <= sind: col = layout.column() col.label(text="Reload skins") p = col.operator( @@ -1020,10 +1000,10 @@ def draw(self, context): row = col.row(align=True) row.scale_y = 1.5 - if conf.skin_list: - skinname = bpy.path.basename(conf.skin_list[sind][0]) - p = row.operator("mcprep.applyskin", text="Apply " + skinname) - p.filepath = conf.skin_list[sind][1] + if env.skin_list: + skinname = bpy.path.basename(env.skin_list[sind][0]) + p = row.operator("mcprep.applyskin", text=f"Apply {skinname}") + p.filepath = env.skin_list[sind][1] else: row.enabled = False p = row.operator("mcprep.skin_swapper", text="No skins found") @@ -1066,14 +1046,14 @@ def draw(self, context): row.enabled = False row.operator( "mcprep.spawn_with_skin", text="Reload mobs below") - elif not conf.skin_list: + elif not env.skin_list: row.enabled = False row.operator( "mcprep.spawn_with_skin", text="Reload skins above") else: name = scn_props.mob_list[mob_ind].name # datapass = scn_props.mob_list[mob_ind].mcmob_type - tx = "Spawn {x} with {y}".format(x=name, y=skinname) + tx = f"Spawn {name} with {skinname}" row.operator("mcprep.spawn_with_skin", text=tx) @@ -1108,7 +1088,7 @@ def draw(self, context): row = col.row(align=True) row.scale_y = 1.5 mat = scn_props.material_list[scn_props.material_list_index] - ops = row.operator("mcprep.load_material", text="Load: " + mat.name) + ops = row.operator("mcprep.load_material", text=f"Load: {mat.name}") ops.filepath = mat.path else: box = col.box() @@ -1154,7 +1134,7 @@ def draw(self, context): # Spawner related UI # ----------------------------------------------------------------------------- -def draw_mode_warning(ui_element): +def draw_mode_warning(ui_element: UILayout) -> None: col = ui_element.column(align=True) col.label(text="Enter object mode", icon="ERROR") col.label(text="to use spawner", icon="BLANK1") @@ -1162,7 +1142,7 @@ def draw_mode_warning(ui_element): col.label(text="") -def mob_spawner(self, context): +def mob_spawner(self, context: Context) -> None: scn_props = context.scene.mcprep_props layout = self.layout @@ -1210,12 +1190,12 @@ def mob_spawner(self, context): row = col.row(align=True) row.scale_y = 1.5 row.enabled = len(scn_props.mob_list) > 0 - p = row.operator("mcprep.mob_spawner", text="Spawn " + name) + p = row.operator("mcprep.mob_spawner", text=f"Spawn {name}") if mcmob_type: p.mcmob_type = mcmob_type # Skip prep materials in case of unique shader. - if conf.json_data and name in conf.json_data.get("mob_skip_prep", []): + if env.json_data and name in env.json_data.get("mob_skip_prep", []): p.prep_materials = False p = col.operator("mcprep.mob_install_menu") @@ -1255,7 +1235,7 @@ def mob_spawner(self, context): b_col.operator("mcprep.mob_install_icon") else: icon_index = scn_props.mob_list[scn_props.mob_list_index].index - if "mob-{}".format(icon_index) in conf.preview_collections["mobs"]: + if f"mob-{icon_index}" in env.preview_collections["mobs"]: b_col.operator( "mcprep.mob_install_icon", text="Change mob icon") else: @@ -1265,7 +1245,7 @@ def mob_spawner(self, context): b_col.label(text=mcmob_type) -def meshswap_spawner(self, context): +def meshswap_spawner(self, context: Context) -> None: scn_props = context.scene.mcprep_props layout = self.layout @@ -1316,12 +1296,12 @@ def meshswap_spawner(self, context): name = scn_props.meshswap_list[scn_props.meshswap_list_index].name block = scn_props.meshswap_list[scn_props.meshswap_list_index].block method = scn_props.meshswap_list[scn_props.meshswap_list_index].method - p = row.operator("mcprep.meshswap_spawner", text="Place: " + name) + p = row.operator("mcprep.meshswap_spawner", text=f"Place: {name}") p.block = block p.method = method - p.location = util.get_cuser_location(context) + p.location = util.get_cursor_location(context) # Ensure meshswap with rigs is made real, so the rigs can be used. - if conf.json_data and block in conf.json_data.get("make_real", []): + if env.json_data and block in env.json_data.get("make_real", []): p.make_real = True else: @@ -1358,10 +1338,10 @@ def meshswap_spawner(self, context): b_col.operator("mcprep.reload_meshswap") -def item_spawner(self, context): +def item_spawner(self, context: Context) -> None: """Code for drawing the item spawner""" scn_props = context.scene.mcprep_props - + layout = self.layout layout.label(text="Generate items from textures") split = layout.split() @@ -1377,7 +1357,7 @@ def item_spawner(self, context): row = col.row(align=True) row.scale_y = 1.5 name = scn_props.item_list[scn_props.item_list_index].name - row.operator("mcprep.spawn_item", text="Place: " + name) + row.operator("mcprep.spawn_item", text=f"Place: {name}") row = col.row(align=True) row.operator("mcprep.spawn_item_file") else: @@ -1423,7 +1403,7 @@ def item_spawner(self, context): b_col.operator("mcprep.reload_items") -def entity_spawner(self, context): +def entity_spawner(self, context: Context) -> None: scn_props = context.scene.mcprep_props layout = self.layout @@ -1473,7 +1453,7 @@ def entity_spawner(self, context): if scn_props.entity_list: name = scn_props.entity_list[scn_props.entity_list_index].name entity = scn_props.entity_list[scn_props.entity_list_index].entity - p = row.operator("mcprep.entity_spawner", text="Spawn: " + name) + p = row.operator("mcprep.entity_spawner", text=f"Spawn: {name}") p.entity = entity else: row.operator("mcprep.entity_spawner", text="Spawn Entity") @@ -1506,16 +1486,13 @@ def entity_spawner(self, context): b_col.operator("mcprep.reload_entities") -def model_spawner(self, context): +def model_spawner(self, context: Context) -> None: """Code for drawing the model block spawner""" scn_props = context.scene.mcprep_props addon_prefs = util.get_user_preferences(context) layout = self.layout layout.label(text="Generate models from .json files") - if not util.bv28(): - layout.label(text="Requires blender 2.8 or newer") - return split = layout.split() col = split.column(align=True) @@ -1530,8 +1507,8 @@ def model_spawner(self, context): row = col.row(align=True) row.scale_y = 1.5 model = scn_props.model_list[scn_props.model_list_index] - ops = row.operator("mcprep.spawn_model", text="Place: " + model.name) - ops.location = util.get_cuser_location(context) + ops = row.operator("mcprep.spawn_model", text=f"Place: {model.name}") + ops.location = util.get_cursor_location(context) ops.filepath = model.filepath if addon_prefs.MCprep_exporter_type == "Mineways": ops.snapping = "offset" @@ -1554,7 +1531,7 @@ def model_spawner(self, context): row.operator(mcmodel.MCPREP_OT_spawn_minecraft_model.bl_idname) ops = col.operator("mcprep.import_model_file") - ops.location = util.get_cuser_location(context) + ops.location = util.get_cursor_location(context) if addon_prefs.MCprep_exporter_type == "Mineways": ops.snapping = "center" elif addon_prefs.MCprep_exporter_type == "jmc2obj": @@ -1585,7 +1562,7 @@ def model_spawner(self, context): b_col.operator("mcprep.reload_models") -def effects_spawner(self, context): +def effects_spawner(self, context: Context) -> None: """Code for drawing the effects spawner""" scn_props = context.scene.mcprep_props @@ -1606,13 +1583,13 @@ def effects_spawner(self, context): effect = scn_props.effects_list[scn_props.effects_list_index] if effect.effect_type in (effects.GEO_AREA, effects.PARTICLE_AREA): ops = row.operator( - "mcprep.spawn_global_effect", text="Add: " + effect.name) + "mcprep.spawn_global_effect", text=f"Add: {effect.name}") ops.effect_id = str(effect.index) elif effect.effect_type in (effects.COLLECTION, effects.IMG_SEQ): ops = row.operator( - "mcprep.spawn_instant_effect", text="Add: " + effect.name) + "mcprep.spawn_instant_effect", text=f"Add: {effect.name}") ops.effect_id = str(effect.index) - ops.location = util.get_cuser_location(context) + ops.location = util.get_cursor_location(context) ops.frame = context.scene.frame_current else: box = col.box() @@ -1631,7 +1608,7 @@ def effects_spawner(self, context): row.operator("mcprep.spawn_item", text="Add effect") row = col.row(align=True) ops = row.operator("mcprep.spawn_particle_planes") - ops.location = util.get_cuser_location(context) + ops.location = util.get_cursor_location(context) ops.frame = context.scene.frame_current # If particle planes has not been changed yet this session, @@ -1692,7 +1669,7 @@ class MCPREP_PT_spawn(bpy.types.Panel): """MCprep panel for mob spawning""" bl_label = "Spawner" bl_space_type = "VIEW_3D" - bl_region_type = 'TOOLS' if not util.bv28() else 'UI' + bl_region_type = 'UI' bl_category = "MCprep" def draw(self, context): @@ -1712,7 +1689,7 @@ class MCPREP_PT_mob_spawner(bpy.types.Panel): bl_label = "Mob spawner" bl_parent_id = "MCPREP_PT_spawn" bl_space_type = "VIEW_3D" - bl_region_type = 'TOOLS' if not util.bv28() else 'UI' + bl_region_type = 'UI' bl_category = "MCprep" bl_options = {'DEFAULT_CLOSED'} @@ -1727,9 +1704,9 @@ def draw(self, context): mob_spawner(self, context) def draw_header(self, context): - if not conf.use_icons or conf.preview_collections["main"] == "": + if not env.use_icons or env.preview_collections["main"] == "": return - icon = conf.preview_collections["main"].get("spawner_icon") + icon = env.preview_collections["main"].get("spawner_icon") if not icon: return self.layout.label(text="", icon_value=icon.icon_id) @@ -1740,7 +1717,7 @@ class MCPREP_PT_model_spawner(bpy.types.Panel): bl_label = "Block (model) spawner" bl_parent_id = "MCPREP_PT_spawn" bl_space_type = "VIEW_3D" - bl_region_type = 'TOOLS' if not util.bv28() else 'UI' + bl_region_type = 'UI' bl_category = "MCprep" bl_options = {'DEFAULT_CLOSED'} @@ -1755,9 +1732,9 @@ def draw(self, context): model_spawner(self, context) def draw_header(self, context): - if not conf.use_icons or conf.preview_collections["main"] == "": + if not env.use_icons or env.preview_collections["main"] == "": return - icon = conf.preview_collections["main"].get("model_icon") + icon = env.preview_collections["main"].get("model_icon") if not icon: return self.layout.label(text="", icon_value=icon.icon_id) @@ -1768,7 +1745,7 @@ class MCPREP_PT_item_spawner(bpy.types.Panel): bl_label = "Item spawner" bl_parent_id = "MCPREP_PT_spawn" bl_space_type = "VIEW_3D" - bl_region_type = 'TOOLS' if not util.bv28() else 'UI' + bl_region_type = 'UI' bl_category = "MCprep" bl_options = {'DEFAULT_CLOSED'} @@ -1784,9 +1761,9 @@ def draw(self, context): item_spawner(self, context) def draw_header(self, context): - if not conf.use_icons or conf.preview_collections["main"] == "": + if not env.use_icons or env.preview_collections["main"] == "": return - icon = conf.preview_collections["main"].get("sword_icon") + icon = env.preview_collections["main"].get("sword_icon") if not icon: return self.layout.label(text="", icon_value=icon.icon_id) @@ -1797,7 +1774,7 @@ class MCPREP_PT_effects_spawner(bpy.types.Panel): bl_label = "Effects + weather" bl_parent_id = "MCPREP_PT_spawn" bl_space_type = "VIEW_3D" - bl_region_type = 'TOOLS' if not util.bv28() else 'UI' + bl_region_type = 'UI' bl_category = "MCprep" bl_options = {'DEFAULT_CLOSED'} @@ -1812,9 +1789,9 @@ def draw(self, context): effects_spawner(self, context) def draw_header(self, context): - if not conf.use_icons or conf.preview_collections["main"] == "": + if not env.use_icons or env.preview_collections["main"] == "": return - icon = conf.preview_collections["main"].get("effects_icon") + icon = env.preview_collections["main"].get("effects_icon") if not icon: return self.layout.label(text="", icon_value=icon.icon_id) @@ -1825,7 +1802,7 @@ class MCPREP_PT_entity_spawner(bpy.types.Panel): bl_label = "Entity spawner" bl_parent_id = "MCPREP_PT_spawn" bl_space_type = "VIEW_3D" - bl_region_type = 'TOOLS' if not util.bv28() else 'UI' + bl_region_type = 'UI' bl_category = "MCprep" bl_options = {'DEFAULT_CLOSED'} @@ -1840,9 +1817,9 @@ def draw(self, context): entity_spawner(self, context) def draw_header(self, context): - if not conf.use_icons or conf.preview_collections["main"] == "": + if not env.use_icons or env.preview_collections["main"] == "": return - icon = conf.preview_collections["main"].get("entity_icon") + icon = env.preview_collections["main"].get("entity_icon") if not icon: return self.layout.label(text="", icon_value=icon.icon_id) @@ -1853,7 +1830,7 @@ class MCPREP_PT_meshswap_spawner(bpy.types.Panel): bl_label = "Meshswap spawner" bl_parent_id = "MCPREP_PT_spawn" bl_space_type = "VIEW_3D" - bl_region_type = 'TOOLS' if not util.bv28() else 'UI' + bl_region_type = 'UI' bl_category = "MCprep" bl_options = {'DEFAULT_CLOSED'} @@ -1868,9 +1845,9 @@ def draw(self, context): meshswap_spawner(self, context) def draw_header(self, context): - if not conf.use_icons or conf.preview_collections["main"] == "": + if not env.use_icons or env.preview_collections["main"] == "": return - icon = conf.preview_collections["main"].get("meshswap_icon") + icon = env.preview_collections["main"].get("meshswap_icon") if not icon: return self.layout.label(text="", icon_value=icon.icon_id) @@ -1882,10 +1859,10 @@ def draw_header(self, context): # ----------------------------------------------------------------------------- -def draw_mcprepadd(self, context): +def draw_mcprepadd(self, context: Context) -> None: """Append to Shift+A, icon for top-level MCprep section.""" layout = self.layout - pcoll = conf.preview_collections["main"] + pcoll = env.preview_collections["main"] if pcoll != "": my_icon = pcoll["crafting_icon"] layout.menu(MCPREP_MT_3dview_add.bl_idname, icon_value=my_icon.icon_id) @@ -1893,7 +1870,7 @@ def draw_mcprepadd(self, context): layout.menu(MCPREP_MT_3dview_add.bl_idname) -def mcprep_uv_tools(self, context): +def mcprep_uv_tools(self, context: Context) -> None: """Appended to UV tools in UV image editor tab, in object edit mode.""" layout = self.layout layout.separator() @@ -1903,7 +1880,7 @@ def mcprep_uv_tools(self, context): col.operator("mcprep.select_alpha_faces") -def mcprep_image_tools(self, context): +def mcprep_image_tools(self, context: Context) -> None: """Tools that will display in object mode in the UV image editor.""" row = self.layout.row() img = context.space_data.image @@ -1918,8 +1895,8 @@ def mcprep_image_tools(self, context): txt = "Spawn as item" if not img: row.enabled = False - if conf.preview_collections["main"] != "": - sword_icon = conf.preview_collections["main"]["sword_icon"] + if env.preview_collections["main"] != "": + sword_icon = env.preview_collections["main"]["sword_icon"] else: sword_icon = None @@ -1940,38 +1917,38 @@ class McprepProps(bpy.types.PropertyGroup): """Properties saved to an individual scene""" # not available here - addon_prefs = util.get_user_preferences() + # addon_prefs = util.get_user_preferences() # depreciated, keeping to prevent re-registration errors - show_settings_material = bpy.props.BoolProperty( + show_settings_material: bpy.props.BoolProperty( name="show material settings", description="Show extra MCprep panel settings", default=False) - show_settings_skin = bpy.props.BoolProperty( + show_settings_skin: bpy.props.BoolProperty( name="show skin settings", description="Show extra MCprep panel settings", default=False) - show_settings_optimizer = bpy.props.BoolProperty( + show_settings_optimizer: bpy.props.BoolProperty( name="show optimizer settings", description="Show extra MCprep panel settings", default=False) - show_settings_spawner = bpy.props.BoolProperty( + show_settings_spawner: bpy.props.BoolProperty( name="show spawner settings", description="Show extra MCprep panel settings", default=False) - show_settings_effect = bpy.props.BoolProperty( + show_settings_effect: bpy.props.BoolProperty( name="show effect settings", description="Show extra MCprep panel settings", default=False) # Rig settings - spawn_rig_category = bpy.props.EnumProperty( + spawn_rig_category: bpy.props.EnumProperty( name="Mob category", description="Category of mobs & character rigs to spawn", update=mobs.spawn_rigs_category_load, items=mobs.spawn_rigs_categories ) - spawn_mode = bpy.props.EnumProperty( + spawn_mode: bpy.props.EnumProperty( name="Spawn Mode", description="Set mode for rig/object spawner", items=[ @@ -1983,28 +1960,28 @@ class McprepProps(bpy.types.PropertyGroup): ) # spawn lists - mob_list = bpy.props.CollectionProperty(type=spawn_util.ListMobAssets) - mob_list_index = bpy.props.IntProperty(default=0) - mob_list_all = bpy.props.CollectionProperty( + mob_list: bpy.props.CollectionProperty(type=spawn_util.ListMobAssets) + mob_list_index: bpy.props.IntProperty(default=0) + mob_list_all: bpy.props.CollectionProperty( type=spawn_util.ListMobAssetsAll) - meshswap_list = bpy.props.CollectionProperty( + meshswap_list: bpy.props.CollectionProperty( type=spawn_util.ListMeshswapAssets) - meshswap_list_index = bpy.props.IntProperty(default=0) - item_list = bpy.props.CollectionProperty(type=spawn_util.ListItemAssets) - item_list_index = bpy.props.IntProperty(default=0) - material_list = bpy.props.CollectionProperty( + meshswap_list_index: bpy.props.IntProperty(default=0) + item_list: bpy.props.CollectionProperty(type=spawn_util.ListItemAssets) + item_list_index: bpy.props.IntProperty(default=0) + material_list: bpy.props.CollectionProperty( type=material_manager.ListMaterials) - material_list_index = bpy.props.IntProperty(default=0) - entity_list = bpy.props.CollectionProperty(type=spawn_util.ListEntityAssets) - entity_list_index = bpy.props.IntProperty(default=0) - model_list = bpy.props.CollectionProperty(type=spawn_util.ListModelAssets) - model_list_index = bpy.props.IntProperty(default=0) + material_list_index: bpy.props.IntProperty(default=0) + entity_list: bpy.props.CollectionProperty(type=spawn_util.ListEntityAssets) + entity_list_index: bpy.props.IntProperty(default=0) + model_list: bpy.props.CollectionProperty(type=spawn_util.ListModelAssets) + model_list_index: bpy.props.IntProperty(default=0) # Effects are uniqune in that they are loaded into a list structure, # but the UI list itself is not directly displayed. Rather, dropdowns # will iterate over this to populate based on type. - effects_list = bpy.props.CollectionProperty(type=spawn_util.ListEffectsAssets) - effects_list_index = bpy.props.IntProperty(default=0) + effects_list: bpy.props.CollectionProperty(type=spawn_util.ListEffectsAssets) + effects_list_index: bpy.props.IntProperty(default=0) # ----------------------------------------------------------------------------- @@ -2040,7 +2017,6 @@ class McprepProps(bpy.types.PropertyGroup): def register(): for cls in classes: - util.make_annotations(cls) bpy.utils.register_class(cls) bpy.types.Scene.mcprep_props = bpy.props.PointerProperty(type=McprepProps) @@ -2092,16 +2068,13 @@ def register(): update=update_mcprep_texturepack_path, default=addon_prefs.custom_texturepack_path) - conf.v = addon_prefs.verbose - if hasattr(bpy.types, "INFO_MT_add"): # 2.7 - bpy.types.INFO_MT_add.append(draw_mcprepadd) - elif hasattr(bpy.types, "VIEW3D_MT_add"): # 2.8 + env.verbose = addon_prefs.verbose + if hasattr(bpy.types, "VIEW3D_MT_add"): # 2.8 bpy.types.VIEW3D_MT_add.append(draw_mcprepadd) - if hasattr(bpy.types, "IMAGE_PT_tools_transform_uvs"): # 2.7 only - bpy.types.IMAGE_PT_tools_transform_uvs.append(mcprep_uv_tools) if hasattr(bpy.types, "IMAGE_MT_uvs"): # 2.8 *and* 2.7 # this is a dropdown menu for UVs, not a panel + env.log("IMAGE_MT_uvs registration!") bpy.types.IMAGE_MT_uvs.append(mcprep_uv_tools) # bpy.types.IMAGE_MT_image.append(mcprep_image_tools) # crashes, re-do ops @@ -2110,13 +2083,9 @@ def unregister(): for cls in reversed(classes): bpy.utils.unregister_class(cls) - if hasattr(bpy.types, "INFO_MT_add"): # 2.7 - bpy.types.INFO_MT_add.remove(draw_mcprepadd) - elif hasattr(bpy.types, "VIEW3D_MT_add"): # 2.8 + if hasattr(bpy.types, "VIEW3D_MT_add"): # 2.8 bpy.types.VIEW3D_MT_add.remove(draw_mcprepadd) - if hasattr(bpy.types, "IMAGE_PT_tools_transform_uvs"): # 2.7 - bpy.types.IMAGE_PT_tools_transform_uvs.remove(mcprep_uv_tools) if hasattr(bpy.types, "IMAGE_MT_uvs"): # 2.8 *and* 2.7 bpy.types.IMAGE_MT_uvs.remove(mcprep_uv_tools) # bpy.types.IMAGE_MT_image.remove(mcprep_image_tools) diff --git a/MCprep_addon/optimize_scene.py b/MCprep_addon/optimize_scene.py index e09f11d7..66b04cc3 100644 --- a/MCprep_addon/optimize_scene.py +++ b/MCprep_addon/optimize_scene.py @@ -18,7 +18,9 @@ import bpy +from bpy.types import Context, UILayout, Node import addon_utils + from . import util from .materials import generate @@ -46,40 +48,40 @@ def scene_brightness(self, context): ] return itms - caustics_bool = bpy.props.BoolProperty( + caustics_bool: bpy.props.BoolProperty( name="Caustics (slower)", default=False, description="If checked allows cautics to be enabled" ) - motion_blur_bool = bpy.props.BoolProperty( + motion_blur_bool: bpy.props.BoolProperty( name="Motion Blur (slower)", default=False, description="If checked allows motion blur to be enabled" ) - scene_brightness = bpy.props.EnumProperty( + scene_brightness: bpy.props.EnumProperty( name="", description="Brightness of the scene: Affects how the optimizer adjusts sampling", items=scene_brightness ) - quality_vs_speed = bpy.props.BoolProperty( + quality_vs_speed: bpy.props.BoolProperty( name="Optimize scene for quality: Makes the optimizer adjust settings in a less \"destructive\" way", default=True ) - simplify = bpy.props.BoolProperty( + simplify: bpy.props.BoolProperty( name="Simplify the viewport: Reduces subdivisions to 0. Only disable if any assets will break when using this", default=True ) - scrambling_unsafe = bpy.props.BoolProperty( + scrambling_unsafe: bpy.props.BoolProperty( name="Automatic Scrambling Distance: Can cause artifacts when rendering", default=False ) - preview_scrambling = bpy.props.BoolProperty( + preview_scrambling: bpy.props.BoolProperty( name="Preview Scrambling in the viewport", default=True ) -def panel_draw(context, element): +def panel_draw(context: Context, element: UILayout): box = element.box() col = box.column() engine = context.scene.render.engine @@ -115,7 +117,7 @@ class MCPrep_OT_optimize_scene(bpy.types.Operator): bl_label = "Optimize Scene" bl_options = {'REGISTER', 'UNDO'} - def __init__(self): + def __init__(self) -> None: # Sampling Settings. self.samples = bpy.context.scene.cycles.samples # We will be doing some minor adjustments to the sample count self.minimum_samples = None @@ -148,7 +150,7 @@ def __init__(self): self.preview_scrambling = None self.scrambling_multiplier = MIN_SCRAMBLING_MULTIPLIER - def is_vol(self, context, node): + def is_vol(self, context: Context, node: Node) -> None: density_socket = node.inputs["Density"] # Grab the density node_name = util.nameGeneralize(node.name).rstrip() # Get the name (who knew this could be used on nodes?) # Sometimes there may be something linked to the density but it's fine to treat it as a homogeneous volume @@ -172,7 +174,7 @@ def is_vol(self, context, node): print(self.homogenous_volumes, " ", self.not_homogenous_volumes) - def is_pricipled(self, context, mat_type, node): + def is_pricipled(self, context: Context, mat_type: str, node: Node) -> None: if mat_type == "reflective": roughness_socket = node.inputs["Roughness"] if not roughness_socket.is_linked and roughness_socket.default_value >= 0.2: @@ -404,7 +406,6 @@ def execute(self, context): def register(): for cls in classes: - util.make_annotations(cls) bpy.utils.register_class(cls) bpy.types.Scene.optimizer_props = bpy.props.PointerProperty( diff --git a/MCprep_addon/spawner/effects.py b/MCprep_addon/spawner/effects.py index 6acf45d3..3cae1823 100644 --- a/MCprep_addon/spawner/effects.py +++ b/MCprep_addon/spawner/effects.py @@ -19,18 +19,32 @@ import json import os import random +from typing import List, TypeVar, Tuple, Sequence +from pathlib import Path import bmesh from bpy_extras.io_utils import ImportHelper import bpy +from bpy.types import Context, Collection, Image, Mesh from mathutils import Vector -from .. import conf from .. import util from .. import tracking from . import spawn_util +from ..conf import env, VectorType + +# For Geometry nodes modifier in 3.0 +if util.bv30(): + from bpy.types import NodesModifier +else: + NodesModifier = TypeVar("NodesModifier") + +# Check spawn_util.py for the +# definition of ListEffectsAssets +ListEffectsAssets = TypeVar("ListEffectsAssets") + # ----------------------------------------------------------------------------- # Global enum values # ----------------------------------------------------------------------------- @@ -52,7 +66,7 @@ # ----------------------------------------------------------------------------- -def add_geonode_area_effect(context, effect): +def add_geonode_area_effect(context: Context, effect: ListEffectsAssets) -> bpy.types.Object: """Create a persistent effect which is meant to emulate a wide-area effect. Effect is of type: ListEffectsAssets. @@ -86,7 +100,7 @@ def add_geonode_area_effect(context, effect): else: this_nodegroup = existing_geonodes[0] - mesh = bpy.data.meshes.new(effect.name + " empty mesh") + mesh = bpy.data.meshes.new(f"{effect.name} empty mesh") new_obj = bpy.data.objects.new(effect.name, mesh) # TODO: consider trying to pick up the current collection setting, @@ -108,7 +122,7 @@ def add_geonode_area_effect(context, effect): return new_obj -def add_area_particle_effect(context, effect, location): +def add_area_particle_effect(context: Context, effect: ListEffectsAssets, location: VectorType) -> bpy.types.Object: """Create a persistent effect over wide area using traditional particles. Effect is of type: ListEffectsAssets. @@ -156,10 +170,7 @@ def add_area_particle_effect(context, effect, location): bpy.ops.object.particle_system_add() psystem = obj.particle_systems[-1] psystem.settings = imported_particles - if util.bv28(): - obj.show_instancer_for_render = False - else: - psystem.settings.use_render_emitter = False + obj.show_instancer_for_render = False # Update particle density to match the current timeline. frames = psystem.settings.frame_end - psystem.settings.frame_start @@ -176,7 +187,7 @@ def add_area_particle_effect(context, effect, location): return obj -def add_collection_effect(context, effect, location, frame): +def add_collection_effect(context: Context, effect: ListEffectsAssets, location: VectorType, frame: int) -> bpy.types.Object: """Spawn a pre-animated collection effect at a specific point and time. Import a new copy of a collection from the effects_collections.blend file. @@ -191,12 +202,12 @@ def add_collection_effect(context, effect, location, frame): any particles the collection may be using. """ - keyname = "{}_frame_{}".format(effect.name, frame) + keyname = f"{effect.name}_frame_{frame}" if keyname in util.collections(): coll = util.collections()[keyname] else: coll = import_animated_coll(context, effect, keyname) - coll.name = effect.name + "_" + str(frame) + coll.name = f"{effect.name}_{frame}" # Update the animation per intended frame. offset_animation_to_frame(coll, frame) @@ -205,8 +216,7 @@ def add_collection_effect(context, effect, location, frame): obj = util.addGroupInstance(coll.name, location) obj.location = location obj.name = keyname - if util.bv28(): - util.move_to_collection(obj, context.collection) + util.move_to_collection(obj, context.collection) # Deselect everything and set newly added empty as active. for ob in context.scene.objects: @@ -216,7 +226,7 @@ def add_collection_effect(context, effect, location, frame): util.select_set(obj, True) -def add_image_sequence_effect(context, effect, location, frame, speed): +def add_image_sequence_effect(context: Context, effect: ListEffectsAssets, location: VectorType, frame: int, speed: float) -> bpy.types.Object: """Spawn a short-term sequence of individual images at a point in time. Effect is of type: ListEffectsAssets. @@ -232,25 +242,21 @@ def add_image_sequence_effect(context, effect, location, frame, speed): ] if not images: - raise Exception("Failed to load images in question: " + root) + raise Exception(f"Failed to load images in question: {root}") # Implement human sorting, such that img_2.png is before img_11.png human_sorted = util.natural_sort(images) # Create the collection to add objects into. - keyname = "{}_frame_{}@{:.2f}".format(effect.name, frame, speed) - - if util.bv28(): - # Move the source collection (world center) into excluded coll - effects_vl = util.get_or_create_viewlayer(context, EFFECT_EXCLUDE) - effects_vl.exclude = True + keyname = f"{effect.name}_frame_{frame}@{speed:.2f}" - seq_coll = bpy.data.collections.get(keyname) - if not seq_coll: - seq_coll = bpy.data.collections.new(name=keyname) - effects_vl.collection.children.link(seq_coll) - else: - seq_coll = bpy.data.groups.new(keyname) + # Move the source collection (world center) into excluded coll + effects_vl = util.get_or_create_viewlayer(context, EFFECT_EXCLUDE) + effects_vl.exclude = True + seq_coll = bpy.data.collections.get(keyname) + if not seq_coll: + seq_coll = bpy.data.collections.new(name=keyname) + effects_vl.collection.children.link(seq_coll) if len(seq_coll.objects) != len(human_sorted): # Generate the items before instancing collection/group. @@ -282,16 +288,12 @@ def add_image_sequence_effect(context, effect, location, frame, speed): obj.location = Vector([0, 0, 0]) # Set the animation for visibility for the range of frames. - def keyframe_current_visibility(context, obj, state): + def keyframe_current_visibility(context: Context, obj: bpy.types.Object, state: bool): frame = context.scene.frame_current obj.hide_render = state - if util.bv28(): - obj.hide_viewport = state - obj.keyframe_insert(data_path="hide_viewport", frame=frame) - else: - obj.hide = state - obj.keyframe_insert(data_path="hide", frame=frame) + obj.hide_viewport = state + obj.keyframe_insert(data_path="hide_viewport", frame=frame) obj.keyframe_insert(data_path="hide_render", frame=frame) context.scene.frame_current = target_frame - 1 @@ -303,10 +305,7 @@ def keyframe_current_visibility(context, obj, state): context.scene.frame_current = end_frame keyframe_current_visibility(context, obj, True) - if util.bv28(): - util.move_to_collection(obj, seq_coll) - else: - seq_coll.objects.link(obj) + util.move_to_collection(obj, seq_coll) context.scene.frame_current = frame @@ -327,15 +326,13 @@ def keyframe_current_visibility(context, obj, state): img_block = bpy.data.images.load(img, check_existing=True) instance.data = img_block - if util.bv28(): - # Move the new object into the active layer. - util.move_to_collection(instance, context.collection) - # TODO: if not bv28, unlink the src group objects from the scene. + # Move the new object into the active layer. + util.move_to_collection(instance, context.collection) return instance -def add_particle_planes_effect(context, image_path, location, frame): +def add_particle_planes_effect(context: Context, image_path: Path, location: VectorType, frame: int) -> None: """Spawn a short-term particle system at a specific point and time. This is the only effect type that does not get pre-loaded into a list. The @@ -347,7 +344,7 @@ def add_particle_planes_effect(context, image_path, location, frame): # Add object, use lower level functions to make it run faster. f_name = os.path.splitext(os.path.basename(image_path))[0] - base_name = "{}_frame_{}".format(f_name, frame) + base_name = f"{f_name}_frame_{frame}" mesh = get_or_create_plane_mesh("particle_plane") obj = bpy.data.objects.new(base_name, mesh) @@ -392,7 +389,7 @@ def add_particle_planes_effect(context, image_path, location, frame): # emitters will have the same material (how it was initially working) obj.material_slots[0].link = 'OBJECT' obj.material_slots[0].material = mat - print("Linked {} with {}".format(obj.name, mat.name)) + print(f"Linked {obj.name} with {mat.name}") apply_particle_settings(obj, frame, base_name, pcoll) @@ -401,7 +398,7 @@ def add_particle_planes_effect(context, image_path, location, frame): # Core effects supportive functions # ----------------------------------------------------------------------------- -def geo_update_params(context, effect, geo_mod): +def geo_update_params(context: Context, effect: ListEffectsAssets, geo_mod: NodesModifier) -> None: """Update the paramters of the applied geonode effect. Loads fields to apply based on json file where necessary. @@ -409,12 +406,12 @@ def geo_update_params(context, effect, geo_mod): base_file = os.path.splitext(os.path.basename(effect.filepath))[0] base_dir = os.path.dirname(effect.filepath) - jpath = os.path.join(base_dir, base_file + ".json") + jpath = os.path.join(base_dir, f"{base_file}.json") geo_fields = {} if os.path.isfile(jpath): geo_fields = geo_fields_from_json(effect, jpath) else: - conf.log("No json params path for geonode effects", vv_only=True) + env.log("No json params path for geonode effects", vv_only=True) return # Determine if these special keywords exist. @@ -422,48 +419,58 @@ def geo_update_params(context, effect, geo_mod): for input_name in geo_fields.keys(): if geo_fields[input_name] == "FOLLOW_OBJ": has_followkey = True - conf.log("geonode has_followkey field? " + str(has_followkey), vv_only=True) + env.log(f"geonode has_followkey field? {has_followkey}", vv_only=True) # Create follow empty if required by the group. camera = context.scene.camera center_empty = None if has_followkey: center_empty = bpy.data.objects.new( - name="{} origin".format(effect.name), + name=f"{effect.name} origin", object_data=None) util.obj_link_scene(center_empty) if camera: center_empty.parent = camera center_empty.location = (0, 0, -10) else: - center_empty.location = util.get_cuser_location() + center_empty.location = util.get_cursor_location() + + input_list = [] + input_node = None + for nd in geo_mod.node_group.nodes: + if nd.type == "GROUP_INPUT": + input_node = nd + break + if input_node is None: + raise RuntimeError(f"Geo node has no input group: {effect.name}") + input_list = list(input_node.outputs) # Cache mapping of names like "Weather Type" to "Input_1" internals. geo_inp_id = {} - for inp in geo_mod.node_group.inputs: + for inp in input_list: if inp.name in list(geo_fields): geo_inp_id[inp.name] = inp.identifier # Now update the final geo node inputs based gathered settings. - for inp in geo_mod.node_group.inputs: + for inp in input_list: if inp.name in list(geo_fields): value = geo_fields[inp.name] if value == "CAMERA_OBJ": - conf.log("Set cam for geonode input", vv_only=True) + env.log("Set cam for geonode input", vv_only=True) geo_mod[geo_inp_id[inp.name]] = camera elif value == "FOLLOW_OBJ": if not center_empty: - print(">> Center empty missing, not in preset!") + env.log("Geo Node effects: Center empty missing, not in preset!") else: - conf.log("Set follow for geonode input", vv_only=True) + env.log("Set follow for geonode input", vv_only=True) geo_mod[geo_inp_id[inp.name]] = center_empty else: - conf.log("Set {} for geonode input".format(inp.name), vv_only=True) + env.log("Set {} for geonode input".format(inp.name), vv_only=True) geo_mod[geo_inp_id[inp.name]] = value # TODO: check if any socket name in json specified not found in node. -def geo_fields_from_json(effect, jpath): +def geo_fields_from_json(effect: ListEffectsAssets, jpath: Path) -> dict: """Extract json values from a file for a given effect. Parse for a json structure with a hierarhcy of: @@ -476,7 +483,7 @@ def geo_fields_from_json(effect, jpath): CAMERA_OBJ: Tells MCprep to assign the active camera object to slot. FOLLOW_OBJ: Tells MCprep to assign a generated empty to this slot. """ - conf.log("Loading geo fields form json: " + jpath) + env.log(f"Loading geo fields form json: {jpath}") with open(jpath) as fopen: jdata = json.load(fopen) @@ -494,7 +501,8 @@ def geo_fields_from_json(effect, jpath): return geo_fields -def get_or_create_plane_mesh(mesh_name, uvs=[]): +def get_or_create_plane_mesh( + mesh_name: str, uvs: Sequence[Tuple[int, int]] = []) -> Mesh: """Generate a 1x1 plane with UVs stretched out to ends, cache if exists. Arg `uvs` represents the 4 coordinate values clockwise from top left of the @@ -523,7 +531,7 @@ def get_or_create_plane_mesh(mesh_name, uvs=[]): if not uvs: uvs = [[0, 0], [1, 0], [1, 1], [0, 1]] if len(uvs) != 4: - raise Exception("Wrong number of coords for UVs: " + str(len(uvs))) + raise Exception(f"Wrong number of coords for UVs: {len(uvs)}") face = bm.faces.new(bm.verts) face.normal_update() @@ -537,8 +545,9 @@ def get_or_create_plane_mesh(mesh_name, uvs=[]): return mesh -def get_or_create_particle_meshes_coll(context, particle_name, img): - """Generate a selection of subsets of a given image for use in partucles. +def get_or_create_particle_meshes_coll(context: Context, particle_name: str, img: Image) -> Collection: + """ TODO 2.7 + Generate a selection of subsets of a given image for use in particles. The goal is that instead of spawning entire, complete UVs of the texture, we spawn little subsets of the particles. @@ -547,19 +556,16 @@ def get_or_create_particle_meshes_coll(context, particle_name, img): """ # Check if it exists already, and if it has at least one object, # assume we'll just use those. - particle_key = particle_name + "_particles" + particle_key = f"{particle_name}_particles" particle_coll = util.collections().get(particle_key) if particle_coll: # Check if any objects. - if util.bv28() and len(particle_coll.objects) > 0: + if len(particle_coll.objects) > 0: return particle_coll # Create the collection/group. - if util.bv28(): - particle_view = util.get_or_create_viewlayer(context, particle_key) - particle_view.exclude = True - else: - particle_group = bpy.data.groups.new(name=particle_key) + particle_view = util.get_or_create_viewlayer(context, particle_key) + particle_view.exclude = True # Get or create the material. if particle_name in bpy.data.materials: @@ -590,23 +596,18 @@ def get_or_create_particle_meshes_coll(context, particle_name, img): del uv_variants[keys[del_index]] for key in uv_variants: - name = particle_name + "_particle_" + key + name = f"{particle_name}_particle_{key}" mesh = get_or_create_plane_mesh(name, uvs=uv_variants[key]) obj = bpy.data.objects.new(name, mesh) obj.data.materials.append(mat) - if util.bv28(): - util.move_to_collection(obj, particle_view.collection) - else: - particle_group.objects.link(obj) + util.move_to_collection(obj, particle_view.collection) - if util.bv28(): - return particle_view.collection - else: - return particle_group + return particle_view.collection -def apply_particle_settings(obj, frame, base_name, pcoll): +def apply_particle_settings( + obj: bpy.types.Object, frame: int, base_name: str, pcoll: Collection) -> None: """Update the particle settings for particle planes.""" obj.scale = (0.5, 0.5, 0.5) # Tighen up the area it spawns over. @@ -628,17 +629,13 @@ def apply_particle_settings(obj, frame, base_name, pcoll): psystem.settings.particle_size = 0.2 psystem.settings.factor_random = 1 - if util.bv28(): - obj.show_instancer_for_render = False - psystem.settings.render_type = 'COLLECTION' - psystem.settings.instance_collection = pcoll - else: - psystem.settings.use_render_emitter = False - psystem.settings.render_type = 'GROUP' - psystem.settings.dupli_group = pcoll + obj.show_instancer_for_render = False + psystem.settings.render_type = 'COLLECTION' + psystem.settings.instance_collection = pcoll -def import_animated_coll(context, effect, keyname): +def import_animated_coll( + context: Context, effect: ListEffectsAssets, keyname: str) -> Collection: """Import and return a new animated collection given a specific key.""" init_colls = list(util.collections()) any_imported = False @@ -647,20 +644,16 @@ def import_animated_coll(context, effect, keyname): for itm in collections: if itm != effect.name: continue - if util.bv28(): - data_to.collections.append(itm) - any_imported = True - else: - data_to.groups.append(itm) - any_imported = True + data_to.collections.append(itm) + any_imported = True final_colls = list(util.collections()) new_colls = list(set(final_colls) - set(init_colls)) if not new_colls: if any_imported: - conf.log("New collection loaded, but not picked up") + env.log("New collection loaded, but not picked up") else: - conf.log("No colleections imported or recognized") + env.log("No colleections imported or recognized") raise Exception("No collections imported") elif len(new_colls) > 1: # Pick the closest fitting one. At worst, will pick a random one. @@ -673,16 +666,15 @@ def import_animated_coll(context, effect, keyname): else: coll = new_colls[0] - if util.bv28(): - # Move the source collection (world center) into excluded coll - effects_vl = util.get_or_create_viewlayer(context, EFFECT_EXCLUDE) - effects_vl.exclude = True - effects_vl.collection.children.link(coll) + # Move the source collection (world center) into excluded coll + effects_vl = util.get_or_create_viewlayer(context, EFFECT_EXCLUDE) + effects_vl.exclude = True + effects_vl.collection.children.link(coll) return coll -def offset_animation_to_frame(collection, frame): +def offset_animation_to_frame(collection: Collection, frame: int) -> None: """Offset all animations and particles based on the given frame.""" if frame == 1: return @@ -692,19 +684,12 @@ def offset_animation_to_frame(collection, frame): actions = [] mats = [] - if util.bv28(): - objs = list(collection.all_objects) - else: - objs = list(collection.objects) + objs = list(collection.all_objects) # Expand the list by checking for any empties instancing other collections. for obj in objs: - if util.bv28(): - if obj.instance_collection: - objs.extend(list(obj.instance_collection.all_objects)) - else: - if obj.dupli_group: - objs.extend(list(obj.dupli_group.objects)) + if obj.instance_collection: + objs.extend(list(obj.instance_collection.all_objects)) # Make unique. objs = list(set(objs)) @@ -752,23 +737,23 @@ def offset_animation_to_frame(collection, frame): # ----------------------------------------------------------------------------- -def update_effects_path(self, context): +def update_effects_path(self, context: Context) -> None: """List for UI effects callback .""" - conf.log("Updating effects path", vv_only=True) + env.log("Updating effects path", vv_only=True) update_effects_list(context) -def update_effects_list(context): +def update_effects_list(context: Context) -> None: """Update the effects list.""" mcprep_props = context.scene.mcprep_props mcprep_props.effects_list.clear() - if conf.use_icons and conf.preview_collections["effects"]: + if env.use_icons and env.preview_collections["effects"]: try: - bpy.utils.previews.remove(conf.preview_collections["effects"]) + bpy.utils.previews.remove(env.preview_collections["effects"]) except Exception as e: print(e) - conf.log("MCPREP: Failed to remove icon set, effects") + env.log("MCPREP: Failed to remove icon set, effects") load_geonode_effect_list(context) load_area_particle_effects(context) @@ -776,7 +761,7 @@ def update_effects_list(context): load_image_sequence_effects(context) -def load_geonode_effect_list(context): +def load_geonode_effect_list(context: Context) -> None: """Load effects defined by geonodes for wide area effects.""" if not util.bv30(): print("Not loading geonode effects") @@ -803,23 +788,23 @@ def load_geonode_effect_list(context): and jsf.lower().endswith(".json") ] - conf.log("json pairs of blend files", vv_only=True) - conf.log(json_files, vv_only=True) + env.log("json pairs of blend files", vv_only=True) + env.log(json_files, vv_only=True) for bfile in blends: row_items = [] using_json = False - js_equiv = os.path.splitext(bfile)[0] + ".json" + js_equiv = f"{os.path.splitext(bfile)[0]}.json" if js_equiv in json_files: - conf.log("Loading json preset for geonode for " + bfile) + env.log(f"Loading json preset for geonode for {bfile}") # Read nodegroups to include from json presets file. - jpath = os.path.splitext(bfile)[0] + ".json" + jpath = f"{os.path.splitext(bfile)[0]}.json" with open(jpath) as jopen: jdata = json.load(jopen) row_items = jdata.keys() using_json = True else: - conf.log("Loading nodegroups from blend for geonode effects: " + bfile) + env.log(f"Loading nodegroups from blend for geonode effects: {bfile}") # Read nodegroup names from blend file directly. with bpy.data.libraries.load(bfile) as (data_from, _): row_items = list(data_from.node_groups) @@ -832,7 +817,7 @@ def load_geonode_effect_list(context): # First key in index is nodegroup, save to subpath, but then # the present name (list of keys within) are the actual names. for preset in jdata[itm]: - conf.log("\tgeonode preset: " + preset, vv_only=True) + env.log(f"\tgeonode preset: {preset}", vv_only=True) effect = mcprep_props.effects_list.add() effect.name = preset # This is the assign json preset name. effect.subpath = itm # This is the node group name. @@ -850,7 +835,7 @@ def load_geonode_effect_list(context): effect.index = len(mcprep_props.effects_list) - 1 # For icon index. -def load_area_particle_effects(context): +def load_area_particle_effects(context : Context) -> None: """Load effects defined by wide area particle effects (non geo nodes). This is a fallback for older versions of blender which don't have geonodes, @@ -885,11 +870,8 @@ def load_area_particle_effects(context): effect.index = len(mcprep_props.effects_list) - 1 # For icon index. -def load_collection_effects(context): +def load_collection_effects(context: Context) -> None: """Load effects defined by collections saved to an effects blend file.""" - if not util.bv28(): - print("Collection spawning not supported in Blender 2.7x") - return mcprep_props = context.scene.mcprep_props path = context.scene.mcprep_effects_path path = os.path.join(path, "collection") @@ -918,7 +900,7 @@ def load_collection_effects(context): effect.index = len(mcprep_props.effects_list) - 1 # For icon index. -def load_image_sequence_effects(context): +def load_image_sequence_effects(context: Context) -> None: """Load effects from the particles folder that should be animated.""" mcprep_props = context.scene.mcprep_props @@ -931,7 +913,7 @@ def load_image_sequence_effects(context): lvl_3 = os.path.join(resource_folder, "assets", "minecraft", "textures", "particle") if not os.path.isdir(resource_folder): - conf.log( + env.log( "The particle resource directory is missing! Assign another resource pack") return elif os.path.isdir(lvl_0): @@ -968,7 +950,7 @@ def load_image_sequence_effects(context): effect.index = len(mcprep_props.effects_list) - 1 # For icon index. # Try to load a middle frame as the icon. - if not conf.use_icons or conf.preview_collections["effects"] == "": + if not env.use_icons or env.preview_collections["effects"] == "": continue effect_files = [ os.path.join(resource_folder, fname) @@ -980,7 +962,7 @@ def load_image_sequence_effects(context): if effect_files: # 0 if 1 item, otherwise greater side of median. e_index = int(len(effect_files) / 2) - conf.preview_collections["effects"].load( + env.preview_collections["effects"].load( "effects-{}".format(effect.index), effect_files[e_index], 'IMAGE') @@ -1039,12 +1021,13 @@ def effects_enum(self, context): "Add {} {} from {}".format( effect.name, display_type, - os.path.basename(effect.filepath)) + os.path.basename(effect.filepath) + ) )) return elist - effect_id = bpy.props.EnumProperty(items=effects_enum, name="Effect") - skipUsage = bpy.props.BoolProperty(default=False, options={'HIDDEN'}) + effect_id: bpy.props.EnumProperty(items=effects_enum, name="Effect") + skipUsage: bpy.props.BoolProperty(default=False, options={'HIDDEN'}) @classmethod def poll(cls, context): @@ -1074,7 +1057,7 @@ def execute(self, context): location = context.scene.camera.location + vartical_offset use_camera = True else: - location = util.get_cuser_location(context) + location = util.get_cursor_location(context) obj = add_area_particle_effect(context, effect, location) if use_camera: @@ -1093,7 +1076,7 @@ class MCPREP_OT_instant_effect(bpy.types.Operator): bl_label = "Instant effect" bl_options = {'REGISTER', 'UNDO'} - def effects_enum(self, context): + def effects_enum(self, context: Context) -> List[tuple]: """Identifies eligible instant effects to include in the dropdown.""" mcprep_props = context.scene.mcprep_props elist = [] @@ -1114,22 +1097,22 @@ def effects_enum(self, context): )) return elist - effect_id = bpy.props.EnumProperty(items=effects_enum, name="Effect") - location = bpy.props.FloatVectorProperty(default=(0, 0, 0), name="Location") - frame = bpy.props.IntProperty( + effect_id: bpy.props.EnumProperty(items=effects_enum, name="Effect") + location: bpy.props.FloatVectorProperty(default=(0, 0, 0), name="Location") + frame: bpy.props.IntProperty( default=0, name="Frame", description="Start frame for animation") - speed = bpy.props.FloatProperty( + speed: bpy.props.FloatProperty( default=1.0, min=0.1, name="Speed", description="Make the effect run faster (skip frames) or slower (hold frames)") - show_image = bpy.props.BoolProperty( + show_image: bpy.props.BoolProperty( default=False, name="Show image preview", description="Show a middle animation frame as a viewport preview") - skipUsage = bpy.props.BoolProperty(default=False, options={'HIDDEN'}) + skipUsage: bpy.props.BoolProperty(default=False, options={'HIDDEN'}) @classmethod def poll(cls, context): @@ -1171,18 +1154,18 @@ class MCPREP_OT_spawn_particle_planes(bpy.types.Operator, ImportHelper): bl_label = "Spawn Particle Planes" bl_options = {'REGISTER', 'UNDO'} - location = bpy.props.FloatVectorProperty( + location: bpy.props.FloatVectorProperty( default=(0, 0, 0), name="Location") - frame = bpy.props.IntProperty(default=0, name="Frame") + frame: bpy.props.IntProperty(default=0, name="Frame") # Importer helper exts = ";".join(["*" + ext for ext in EXTENSIONS]) - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default=exts, options={'HIDDEN'}) fileselectparams = "use_filter_blender" - skipUsage = bpy.props.BoolProperty( + skipUsage: bpy.props.BoolProperty( default=False, options={'HIDDEN'}) @@ -1240,7 +1223,6 @@ def execute(self, context): def register(): for cls in classes: - util.make_annotations(cls) bpy.utils.register_class(cls) diff --git a/MCprep_addon/spawner/entities.py b/MCprep_addon/spawner/entities.py index 04d5ebf2..96b70711 100644 --- a/MCprep_addon/spawner/entities.py +++ b/MCprep_addon/spawner/entities.py @@ -17,10 +17,12 @@ # ##### END GPL LICENSE BLOCK ##### import os +from typing import Dict, List import bpy -from .. import conf +from bpy.types import Context +from ..conf import env, Entity from .. import util from .. import tracking @@ -28,14 +30,14 @@ # ----------------------------------------------------------------------------- -# Mesh swap functions +# Entity spawn functions # ----------------------------------------------------------------------------- entity_cache = {} entity_cache_path = None -def get_entity_cache(context, clear=False): +def get_entity_cache(context: Context, clear: bool=False) -> Dict[str, List[str]]: """Load collections from entity spawning lib if not cached, return key vars.""" global entity_cache global entity_cache_path # Used to auto-clear path if bpy prop changed. @@ -53,10 +55,10 @@ def get_entity_cache(context, clear=False): # Note: Only using groups, not objects, for entities. entity_cache = {"groups": [], "objects": []} if not os.path.isfile(entity_path): - conf.log("Entity path not found") + env.log("Entity path not found") return entity_cache if not entity_path.lower().endswith('.blend'): - conf.log("Entity path must be a .blend file") + env.log("Entity path must be a .blend file") return entity_cache with bpy.data.libraries.load(entity_path) as (data_from, _): @@ -65,20 +67,20 @@ def get_entity_cache(context, clear=False): return entity_cache -def getEntityList(context): +def getEntityList(context: Context) -> List[Entity]: """Only used for UI drawing of enum menus, full list.""" # may redraw too many times, perhaps have flag if not context.scene.mcprep_props.entity_list: updateEntityList(context) return [ - (itm.entity, itm.name.title(), "Place {}".format(itm.name)) + (itm.entity, itm.name.title(), f"Place {itm.name}") for itm in context.scene.mcprep_props.entity_list] -def update_entity_path(self, context): +def update_entity_path(self, context: Context) -> None: """for UI list path callback""" - conf.log("Updating entity path", vv_only=True) + env.log("Updating entity path", vv_only=True) if not context.scene.entity_path.lower().endswith('.blend'): print("Entity file is not a .blend, and should be") if not os.path.isfile(bpy.path.abspath(context.scene.entity_path)): @@ -86,7 +88,7 @@ def update_entity_path(self, context): updateEntityList(context) -def updateEntityList(context): +def updateEntityList(context: Context) -> None: """Update the entity list""" entity_file = bpy.path.abspath(context.scene.entity_path) if not os.path.isfile(entity_file): @@ -104,8 +106,8 @@ def updateEntityList(context): continue if util.nameGeneralize(name).lower() in temp_entity_list: continue - description = "Place {x} entity".format(x=name) - entity_list.append((prefix + name, name.title(), description)) + description = f"Place {name} entity" + entity_list.append((f"{prefix}{name}", name.title(), description)) temp_entity_list.append(util.nameGeneralize(name).lower()) # sort the list alphabetically by name @@ -124,16 +126,8 @@ def updateEntityList(context): item.description = itm[2] -class face_struct(): - """Structure class for preprocessed faces of a mesh""" - def __init__(self, normal_coord, global_coord, local_coord): - self.n = normal_coord - self.g = global_coord - self.l = local_coord - - # ----------------------------------------------------------------------------- -# Mesh swap functions +# Entity spawn operators # ----------------------------------------------------------------------------- @@ -159,17 +153,11 @@ class MCPREP_OT_entity_spawner(bpy.types.Operator): bl_options = {'REGISTER', 'UNDO'} # properties, will appear in redo-last menu - def swap_enum(self, context): + def swap_enum(self, context: Context) -> List[tuple]: return getEntityList(context) - entity = bpy.props.EnumProperty(items=swap_enum, name="Entity") - append_layer = bpy.props.IntProperty( - name="Append layer", - default=20, - min=0, - max=20, - description="Set the layer for appending groups, 0 means same as active layers") - relocation = bpy.props.EnumProperty( + entity: bpy.props.EnumProperty(items=swap_enum, name="Entity") + relocation: bpy.props.EnumProperty( items=[ ('Cursor', 'Cursor', 'Move the rig to the cursor'), ('Clear', 'Origin', 'Move the rig to the origin'), @@ -177,18 +165,18 @@ def swap_enum(self, context): 'Offset the root bone to cursor while leaving the rest pose ' 'at the origin'))], name="Relocation") - clearPose = bpy.props.BoolProperty( + clearPose: bpy.props.BoolProperty( name="Clear Pose", description="Clear the pose to rest position", default=True) - prep_materials = bpy.props.BoolProperty( + prep_materials: bpy.props.BoolProperty( name="Prep materials (will reset nodes)", description=( "Prep materials of the added rig, will replace cycles node groups " "with default"), default=True) - skipUsage = bpy.props.BoolProperty( + skipUsage: bpy.props.BoolProperty( default=False, options={'HIDDEN'}) @@ -256,7 +244,6 @@ def execute(self, context): def register(): for cls in classes: - util.make_annotations(cls) bpy.utils.register_class(cls) global entity_cache diff --git a/MCprep_addon/spawner/item.py b/MCprep_addon/spawner/item.py index 05ba9429..f66bca1e 100644 --- a/MCprep_addon/spawner/item.py +++ b/MCprep_addon/spawner/item.py @@ -16,16 +16,19 @@ # # ##### END GPL LICENSE BLOCK ##### +from pathlib import Path +from typing import Optional, Tuple +import mathutils import os import bpy +from bpy.types import Context from bpy_extras.io_utils import ImportHelper -import mathutils -from .. import conf from .. import util from .. import tracking -from ..materials import generate +from ..conf import env +from ..materials import generate try: import bpy.utils.previews except ImportError: @@ -36,7 +39,7 @@ # ----------------------------------------------------------------------------- -def reload_items(context): +def reload_items(context: Context) -> None: """Reload the items UI list for spawning""" mcprep_props = context.scene.mcprep_props @@ -44,12 +47,12 @@ def reload_items(context): extensions = [".png", ".jpg", ".jpeg"] mcprep_props.item_list.clear() - if conf.use_icons and conf.preview_collections["items"]: + if env.use_icons and env.preview_collections["items"]: try: - bpy.utils.previews.remove(conf.preview_collections["items"]) + bpy.utils.previews.remove(env.preview_collections["items"]) except Exception as e: print(e) - conf.log("MCPREP: Failed to remove icon set, items") + env.log("MCPREP: Failed to remove icon set, items") # Check levels lvl_1 = os.path.join(resource_folder, "textures") @@ -57,7 +60,7 @@ def reload_items(context): lvl_3 = os.path.join(resource_folder, "assets", "minecraft", "textures") if not os.path.isdir(resource_folder): - conf.log("Error, resource folder does not exist") + env.log("Error, resource folder does not exist") return elif os.path.isdir(lvl_1): resource_folder = lvl_1 @@ -84,22 +87,24 @@ def reload_items(context): basename = os.path.splitext(os.path.basename(item_file))[0] asset = mcprep_props.item_list.add() asset.name = basename.replace("_", " ") - asset.description = "Spawn one " + basename + asset.description = f"Spawn one {basename}" asset.path = item_file asset.index = i # if available, load the custom icon too - if not conf.use_icons or conf.preview_collections["items"] == "": + if not env.use_icons or env.preview_collections["items"] == "": continue - conf.preview_collections["items"].load( - "item-{}".format(i), item_file, 'IMAGE') + env.preview_collections["items"].load( + f"item-{i}", item_file, 'IMAGE') if mcprep_props.item_list_index >= len(mcprep_props.item_list): mcprep_props.item_list_index = len(mcprep_props.item_list) - 1 def spawn_item_from_filepath( - context, path, max_pixels, thickness, threshold, transparency): + context: Context, path: Path, + max_pixels: int, thickness: float, threshold: float, transparency: bool + ) -> Tuple[Optional[bpy.types.Object], Optional[str]]: """Reusable function for generating an item from an image filepath Arguments @@ -154,27 +159,11 @@ def spawn_item_from_filepath( size=2, calc_uvs=True, location=(0, 0, 0)) - elif util.bv28(): - bpy.ops.mesh.primitive_grid_add( - x_subdivisions=height + 1, # Outter edges count as a subdiv. - y_subdivisions=width + 1, # Outter edges count as a subdiv. - size=2, - calc_uvs=True, - location=(0, 0, 0)) - elif bpy.app.version < (2, 77): # Could be 2.76 even. - bpy.ops.mesh.primitive_grid_add( - x_subdivisions=height + 1, # Outter edges count as a subdiv. - y_subdivisions=width + 1, # Outter edges count as a subdiv. - radius=1, - location=(0, 0, 0)) - bpy.ops.object.mode_set(mode='EDIT') - bpy.ops.uv.unwrap() - bpy.ops.object.mode_set(mode='OBJECT') else: bpy.ops.mesh.primitive_grid_add( x_subdivisions=height + 1, # Outter edges count as a subdiv. y_subdivisions=width + 1, # Outter edges count as a subdiv. - radius=1, + size=2, calc_uvs=True, location=(0, 0, 0)) itm_obj = context.object @@ -225,7 +214,7 @@ def spawn_item_from_filepath( bpy.ops.mesh.delete(type='FACE') bpy.ops.object.mode_set(mode='OBJECT') - itm_obj.location = util.get_cuser_location(context) + itm_obj.location = util.get_cursor_location(context) # Material and Textures. # TODO: use the generate functions here instead @@ -297,44 +286,44 @@ class ItemSpawnBase(): """Class to inheret reused MCprep item spawning settings and functions.""" # TODO: add options like spawning attached to rig hand or other, - size = bpy.props.FloatProperty( + size: bpy.props.FloatProperty( name="Size", default=1.0, min=0.001, description="Size in blender units of the item") - thickness = bpy.props.FloatProperty( + thickness: bpy.props.FloatProperty( name="Thickness", default=1.0, min=0.0, description=( "The thickness of the item (this can later be changed in " "modifiers)")) - transparency = bpy.props.BoolProperty( + transparency: bpy.props.BoolProperty( name="Remove transparent faces", description="Transparent pixels will be transparent once rendered", default=True) - threshold = bpy.props.FloatProperty( + threshold: bpy.props.FloatProperty( name="Transparent threshold", description="1.0 = zero tolerance, no transparent pixels will be generated", default=0.5, min=0.0, max=1.0) - max_pixels = bpy.props.IntProperty( + max_pixels: bpy.props.IntProperty( name="Max pixels", default=50000, min=1, description=( "If needed, scale down image to generate less than this maximum " "pixel count")) - scale_uvs = bpy.props.FloatProperty( + scale_uvs: bpy.props.FloatProperty( name="Scale UVs", default=0.75, description="Scale individual UV faces of the generated item") - filepath = bpy.props.StringProperty( + filepath: bpy.props.StringProperty( default="", subtype="FILE_PATH", options={'HIDDEN', 'SKIP_SAVE'}) - skipUsage = bpy.props.BoolProperty( + skipUsage: bpy.props.BoolProperty( default=False, options={'HIDDEN'}) @@ -343,7 +332,7 @@ def poll(cls, context): pose_active = context.mode == 'POSE' and context.active_bone return context.mode == 'OBJECT' or pose_active - def spawn_item_execution(self, context): + def spawn_item_execution(self, context: Context): """Common execution for both spawn item from filepath and list.""" if context.mode == 'POSE' and context.active_bone: spawn_in_pose = True @@ -421,14 +410,14 @@ class MCPREP_OT_spawn_item_from_file(bpy.types.Operator, ImportHelper, ItemSpawn bl_idname = "mcprep.spawn_item_file" bl_label = "Item from file" - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="", options={'HIDDEN'}) fileselectparams = "use_filter_blender" - files = bpy.props.CollectionProperty( + files: bpy.props.CollectionProperty( type=bpy.types.PropertyGroup, options={'HIDDEN', 'SKIP_SAVE'}) - filter_image = bpy.props.BoolProperty( + filter_image: bpy.props.BoolProperty( default=True, options={'HIDDEN', 'SKIP_SAVE'}) @@ -466,9 +455,7 @@ def execute(self, context): def register(): - util.make_annotations(ItemSpawnBase) # Don't register, only annotate. for cls in classes: - util.make_annotations(cls) bpy.utils.register_class(cls) diff --git a/MCprep_addon/spawner/mcmodel.py b/MCprep_addon/spawner/mcmodel.py index 6c895d9a..c3663471 100644 --- a/MCprep_addon/spawner/mcmodel.py +++ b/MCprep_addon/spawner/mcmodel.py @@ -20,27 +20,41 @@ import json from mathutils import Vector from math import sin, cos, radians +from pathlib import Path +from typing import Dict, Optional, Tuple, Union, Sequence import bpy import bmesh +from bpy.types import Context, Material from bpy_extras.io_utils import ImportHelper -from .. import conf +from ..conf import env, VectorType from .. import util from .. import tracking -from ..materials import generate +from ..materials import generate # TODO: Use this module for mat gen in future +TexFace = Dict[str, Dict[str, str]] + +Element = Sequence[Union[Dict[str, VectorType], TexFace]] +Texture = Dict[str, str] # ----------------------------------------------------------------------------- # Core MC model functions and implementation # ----------------------------------------------------------------------------- + class ModelException(Exception): """Custom exception type for model loading.""" def rotate_around( - d, pos, origin, axis='z', offset=[8, 0, 8], scale=[0.063, 0.063, 0.063]): + d: float, + pos: VectorType, + origin: VectorType, + axis: str = 'z', + offset: VectorType = [8, 0, 8], + scale: VectorType = [0.0625, 0.0625, 0.0625] +) -> VectorType: r = -radians(d) axis_i = ord(axis) - 120 # 'x'=0, 'y'=1, 'z'=2 a = pos[(1 + axis_i) % 3] @@ -64,11 +78,12 @@ def rotate_around( def add_element( - elm_from=[0, 0, 0], - elm_to=[16, 16, 16], - rot_origin=[8, 8, 8], - rot_axis='y', - rot_angle=0): + elm_from: VectorType = [0, 0, 0], + elm_to: VectorType = [16, 16, 16], + rot_origin: VectorType = [8, 8, 8], + rot_axis: str = 'y', + rot_angle: float = 0 +) -> list: """Calculates and defines the verts, edge, and faces that to create.""" verts = [ rotate_around( @@ -91,34 +106,60 @@ def add_element( edges = [] faces = [ - [0, 1, 2, 3], - [5, 4, 7, 6], - [1, 0, 4, 5], - [7, 6, 2, 3], - [4, 0, 3, 7], - [1, 5, 6, 2]] + [0, 1, 2, 3], # north + [5, 4, 7, 6], # south + [1, 0, 4, 5], # up + [7, 6, 2, 3], # down + [4, 0, 3, 7], # west + [1, 5, 6, 2]] # east - return [verts, edges, faces] + return verts, edges, faces -def add_material(name="material", path=""): +def add_material( + name: str = "material", path: str = "", use_name: bool = False +) -> Optional[Material]: """Creates a simple material with an image texture from path.""" - cur_mats = list(bpy.data.materials) - res = bpy.ops.mcprep.load_material(filepath=path, skipUsage=True) - if res != {'FINISHED'}: - conf.log("Failed to generate material as specified") - post_mats = list(bpy.data.materials) - - new_mats = list(set(post_mats) - set(cur_mats)) - if not new_mats: - conf.log("Failed to fetch any generated material") + engine = bpy.context.scene.render.engine + + # Create the base material node tree setup + mat, err = generate.generate_base_material(bpy.context, name, path, False) + if mat is None and err: + env.log("Failed to fetch any generated material") return None - mat = new_mats[0] + passes = generate.get_textures(mat) + # In most case Minecraft JSON material + # do not use PBR passes, so set it to None + for pass_name in passes: + if pass_name != "diffuse": + passes[pass_name] = None + # Prep material + # Halt if no diffuse image found + if engine == 'CYCLES' or engine == 'BLENDER_EEVEE': + options = generate.PrepOptions( + passes=passes, + use_reflections=False, + use_principled=True, + only_solid=False, + pack_format=generate.PackFormat.SIMPLE, + use_emission_nodes=False, + use_emission=False # This is for an option set in matprep_cycles + ) + _ = generate.matprep_cycles( + mat=mat, + options=options + ) + + if use_name: + mat.name = name + return mat -def locate_image(context, textures, img, model_filepath): +def locate_image( + context: Context, textures: Dict[str, str], img: str, model_filepath: Path +) -> Path: """Finds and returns the filepath of the image texture.""" resource_folder = bpy.path.abspath(context.scene.mcprep_texturepack_path) @@ -132,7 +173,7 @@ def locate_image(context, textures, img, model_filepath): if local_path[0] == '.': # path is local to the model file directory = os.path.dirname(model_filepath) else: - if(len(local_path.split(":")) == 1): + if len(local_path.split(":")) == 1: namespace = "minecraft" else: namespace = local_path.split(":")[0] @@ -143,7 +184,8 @@ def locate_image(context, textures, img, model_filepath): return os.path.realpath(os.path.join(directory, local_path) + ".png") -def read_model(context, model_filepath): +def read_model( + context: Context, model_filepath: Path) -> Tuple[Element, Texture]: """Reads json file to get textures and elements needed for model. This function is recursively called to also get the elements and textures @@ -175,8 +217,8 @@ def read_model(context, model_filepath): resource_folder = bpy.path.abspath(context.scene.mcprep_texturepack_path) fallback_folder = bpy.path.abspath(addon_prefs.custom_texturepack_path) - elements = None - textures = None + elements: Optional[Element] = None + textures: Optional[Texture] = None parent = obj_data.get("parent") if parent is not None: @@ -187,7 +229,7 @@ def read_model(context, model_filepath): # heads, shields, banners and tridents. pass else: - if(len(parent.split(":")) == 1): + if len(parent.split(":")) == 1: namespace = "minecraft" parent_filepath = parent else: @@ -196,7 +238,7 @@ def read_model(context, model_filepath): # resource_folder models_dir = os.path.join( - "assets", namespace, "models", parent_filepath + ".json") + "assets", namespace, "models", f"{parent_filepath}.json") target_path = os.path.join(targets_folder, models_dir) active_path = os.path.join(resource_folder, models_dir) base_path = os.path.join(fallback_folder, models_dir) @@ -208,13 +250,13 @@ def read_model(context, model_filepath): elif os.path.isfile(base_path): elements, textures = read_model(context, base_path) else: - conf.log("Failed to find mcmodel file " + parent_filepath) + env.log(f"Failed to find mcmodel file {parent_filepath}") - current_elements = obj_data.get("elements") + current_elements: Element = obj_data.get("elements") if current_elements is not None: elements = current_elements # overwrites any elements from parents - current_textures = obj_data.get("textures") + current_textures: Texture = obj_data.get("textures") if current_textures is not None: if textures is None: textures = current_textures @@ -222,21 +264,30 @@ def read_model(context, model_filepath): for img in current_textures: textures[img] = current_textures[img] - conf.log("\nfile:" + str(model_filepath), vv_only=True) - # conf.log("parent:" + str(parent)) - # conf.log("elements:" + str(elements)) - # conf.log("textures:" + str(textures)) + env.log(f"\nfile: {model_filepath}", vv_only=True) + # env.log("parent:" + str(parent)) + # env.log("elements:" + str(elements)) + # env.log("textures:" + str(textures)) return elements, textures -def add_model(model_filepath, obj_name="MinecraftModel"): +def add_model( + model_filepath: Path, obj_name: str = "MinecraftModel" +) -> Tuple[int, bpy.types.Object]: """Primary function for generating a model from json file.""" - mesh = bpy.data.meshes.new(obj_name) # add a new mesh - obj = bpy.data.objects.new(obj_name, mesh) # add a new object using the mesh - collection = bpy.context.collection view_layer = bpy.context.view_layer + + # Called recursively! + # Can raise ModelException due to permission or corrupted file data. + elements, textures = read_model(bpy.context, model_filepath) + + if elements is None: + return 1, None + + mesh = bpy.data.meshes.new(obj_name) # add a new mesh + obj = bpy.data.objects.new(obj_name, mesh) # add a new object using the mesh collection.objects.link(obj) # put the object into the scene (link) view_layer.objects.active = obj # set as the active object in the scene obj.select_set(True) # select object @@ -246,34 +297,26 @@ def add_model(model_filepath, obj_name="MinecraftModel"): mesh.uv_layers.new() uv_layer = bm.loops.layers.uv.verify() - # Called recursively! - # Can raise ModelException due to permission or corrupted file data. - elements, textures = read_model(bpy.context, model_filepath) - materials = [] if textures: for img in textures: if img != "particle": tex_pth = locate_image(bpy.context, textures, img, model_filepath) - mat = add_material(obj_name + "_" + img, tex_pth) - obj.data.materials.append(mat) - materials.append("#" + img) - - if elements is None: - elements = [ - {'from': [0, 0, 0], 'to':[0, 0, 0]}] # temp default elements + mat = add_material(f"{obj_name}_{img}", tex_pth, use_name=False) + obj_mats = obj.data.materials + if f"#{img}" not in materials: + obj_mats.append(mat) + materials.append(f"#{img}") for e in elements: rotation = e.get("rotation") if rotation is None: # rotation default rotation = {"angle": 0, "axis": "y", "origin": [8, 8, 8]} - element = add_element( e['from'], e['to'], rotation['origin'], rotation['axis'], rotation['angle']) verts = [bm.verts.new(v) for v in element[0]] # add a new vert uvs = [[1, 1], [0, 1], [0, 0], [1, 0]] - # face directions defaults face_dir = ["north", "south", "up", "down", "west", "east"] faces = e.get("faces") for i in range(len(element[2])): @@ -286,11 +329,7 @@ def add_model(model_filepath, obj_name="MinecraftModel"): if not d_face: continue - face = bm.faces.new( - (verts[f[0]], verts[f[1]], verts[f[2]], verts[f[3]]) - ) - face.normal_update() - + face_mat = d_face.get("texture") # uv can be rotated 0, 90, 180, or 270 degrees uv_rot = d_face.get("rotation") if uv_rot is None: @@ -303,6 +342,14 @@ def add_model(model_filepath, obj_name="MinecraftModel"): uv_coords = d_face.get("uv") # in the format [x1, y1, x2, y2] if uv_coords is None: uv_coords = [0, 0, 16, 16] + # Cake and cake slices don't store the UV keys + # in the JSON model, which causes issues. This + # workaround this fixes those texture issues + if "cake" in obj_name: + if face_mat == "#top": + uv_coords = [e['to'][0], e['to'][2], e['from'][0], e['from'][2]] + if "side" in face_mat: + uv_coords = [e['to'][0], -e['to'][1], e['from'][0], -e['from'][2]] # uv in the model is between 0 to 16 regardless of resolution, # in blender its 0 to 1 the y-axis is inverted when compared to @@ -315,20 +362,28 @@ def add_model(model_filepath, obj_name="MinecraftModel"): [uv_coords[2] / 16, 1 - (uv_coords[3] / 16)] # [x2, y2] ] + face = bm.faces.new( + (verts[f[0]], verts[f[1]], verts[f[2]], verts[f[3]]) + ) + + face.normal_update() for j in range(len(face.loops)): # uv coords order is determened by the rotation of the uv, # e.g. if the uv is rotated by 180 degrees, the first index # will be 2 then 3, 0, 1. face.loops[j][uv_layer].uv = uvs[(j + uv_idx) % len(uvs)] - face_mat = d_face.get("texture") + # Assign the material on face if face_mat is not None and face_mat in materials: face.material_index = materials.index(face_mat) + # Quick way to clean the model, hopefully it doesn't cause any UV issues + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.01) + # make the bmesh the object's mesh bm.to_mesh(mesh) bm.free() - return obj + return 0, obj # ----------------------------------------------------------------------------- @@ -336,7 +391,7 @@ def add_model(model_filepath, obj_name="MinecraftModel"): # ----------------------------------------------------------------------------- -def update_model_list(context): +def update_model_list(context: Context): """Update the model list. Prefer loading model names from the active resource pack, but fall back @@ -359,7 +414,7 @@ def update_model_list(context): if not os.path.isdir(active_pack): scn_props.model_list.clear() scn_props.model_list_index = 0 - conf.log("No models found for active path " + active_pack) + env.log(f"No models found for active path {active_pack}") return base_has_models = os.path.isdir(base_pack) @@ -374,7 +429,7 @@ def update_model_list(context): if os.path.isfile(os.path.join(active_pack, model)) and model.lower().endswith(".json")] else: - conf.log("Base resource pack has no models folder: " + base_pack) + env.log(f"Base resource pack has no models folder: {base_pack}") base_models = [] sorted_models = [ @@ -395,6 +450,13 @@ def update_model_list(context): # #fire or the likes in the file. if "template" in name: continue + # Filter the "unspawnable_for_now" + # Either entity block or block that doesn't good for json + blocks = env.json_data.get( + "unspawnable_for_now", + ["bed", "chest", "banner", "campfire"]) + if name in blocks: + continue item = scn_props.model_list.add() item.filepath = model item.name = name @@ -405,7 +467,7 @@ def update_model_list(context): scn_props.model_list_index = len(scn_props.model_list) - 1 -def draw_import_mcmodel(self, context): +def draw_import_mcmodel(self, context: Context): """Import bar layout definition.""" layout = self.layout layout.operator("mcprep.import_model_file", text="Minecraft Model (.json)") @@ -413,23 +475,23 @@ def draw_import_mcmodel(self, context): class ModelSpawnBase(): """Class to inheret reused MCprep item spawning settings and functions.""" - location = bpy.props.FloatVectorProperty( + location: bpy.props.FloatVectorProperty( default=(0, 0, 0), name="Location") - snapping = bpy.props.EnumProperty( + snapping: bpy.props.EnumProperty( name="Snapping", items=[ ("none", "No snap", "Keep exact location"), ("center", "Snap center", "Snap to block center"), ("offset", "Snap offset", "Snap to block center with 0.5 offset")], description="Automatically snap to whole block locations") - skipUsage = bpy.props.BoolProperty( + skipUsage: bpy.props.BoolProperty( default=False, options={'HIDDEN'}) @classmethod def poll(cls, context): - return context.mode == 'OBJECT' and util.bv28() + return context.mode == 'OBJECT' def place_model(self, obj): if self.snapping == "center": @@ -456,7 +518,7 @@ class MCPREP_OT_spawn_minecraft_model(bpy.types.Operator, ModelSpawnBase): bl_label = "Place model" bl_options = {'REGISTER', 'UNDO'} - filepath = bpy.props.StringProperty( + filepath: bpy.props.StringProperty( default="", subtype="FILE_PATH", options={'HIDDEN', 'SKIP_SAVE'}) @@ -472,13 +534,17 @@ def execute(self, context): return {'CANCELLED'} if not self.filepath.lower().endswith(".json"): self.report( - {"ERROR"}, "File is not json: " + self.filepath) + {"ERROR"}, f"File is not json: {self.filepath}") return {'CANCELLED'} try: - obj = add_model(os.path.normpath(self.filepath), name) + r, obj = add_model(os.path.normpath(self.filepath), name) + if r: + self.report( + {"ERROR"}, "The JSON model does not contain any geometry elements") + return {'CANCELLED'} except ModelException as e: - self.report({"ERROR"}, "Encountered error: " + str(e)) + self.report({"ERROR"}, f"Encountered error: {e}") return {'CANCELLED'} self.place_model(obj) @@ -494,7 +560,7 @@ class MCPREP_OT_import_minecraft_model_file( bl_options = {'REGISTER', 'UNDO'} filename_ext = ".json" - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.json", options={'HIDDEN'}, maxlen=255 # Max internal buffer length, longer would be clamped. @@ -510,13 +576,17 @@ def execute(self, context): return {'CANCELLED'} if not self.filepath.lower().endswith(".json"): self.report( - {"ERROR"}, "File is not json: " + self.filepath) + {"ERROR"}, f"File is not json: {self.filepath}") return {'CANCELLED'} try: - obj = add_model(os.path.normpath(self.filepath), filename) + r, obj = add_model(os.path.normpath(self.filepath), filename) + if r: + self.report( + {"ERROR"}, "The JSON model does not contain any geometry elements") + return {'CANCELLED'} except ModelException as e: - self.report({"ERROR"}, "Encountered error: " + str(e)) + self.report({"ERROR"}, f"Encountered error: {e}") return {'CANCELLED'} self.place_model(obj) @@ -543,17 +613,13 @@ def execute(self, context): def register(): - util.make_annotations(ModelSpawnBase) # Don't register, only annotate. for cls in classes: - util.make_annotations(cls) bpy.utils.register_class(cls) - if util.bv28(): - bpy.types.TOPBAR_MT_file_import.append(draw_import_mcmodel) + bpy.types.TOPBAR_MT_file_import.append(draw_import_mcmodel) def unregister(): - if util.bv28(): - bpy.types.TOPBAR_MT_file_import.remove(draw_import_mcmodel) + bpy.types.TOPBAR_MT_file_import.remove(draw_import_mcmodel) for cls in reversed(classes): bpy.utils.unregister_class(cls) diff --git a/MCprep_addon/spawner/meshswap.py b/MCprep_addon/spawner/meshswap.py old mode 100755 new mode 100644 index 589bd664..ea865051 --- a/MCprep_addon/spawner/meshswap.py +++ b/MCprep_addon/spawner/meshswap.py @@ -17,16 +17,19 @@ # ##### END GPL LICENSE BLOCK ##### +from dataclasses import dataclass +from typing import Dict, List, Union, Tuple import math +import mathutils import os import random import time import bpy -import mathutils +from bpy.types import Context, Collection from . import spawn_util -from .. import conf +from ..conf import env, VectorType from ..materials import generate from .. import util from .. import tracking @@ -40,7 +43,7 @@ meshswap_cache_path = None -def get_meshswap_cache(context, clear=False): +def get_meshswap_cache(context: Context, clear: bool=False) -> Dict[str, List[str]]: """Load groups/objects from meshswap lib if not cached, return key vars.""" global meshswap_cache global meshswap_cache_path # used to auto-clear path if bpy prop changed @@ -57,10 +60,10 @@ def get_meshswap_cache(context, clear=False): meshswap_cache = {"groups": [], "objects": []} if not os.path.isfile(meshswap_path): - conf.log("Meshswap path not found") + env.log("Meshswap path not found") return meshswap_cache if not meshswap_path.lower().endswith('.blend'): - conf.log("Meshswap path must be a .blend file") + env.log("Meshswap path must be a .blend file") return meshswap_cache with bpy.data.libraries.load(meshswap_path) as (data_from, _): @@ -69,25 +72,25 @@ def get_meshswap_cache(context, clear=False): meshswap_cache["groups"] = grp_list for obj in list(data_from.objects): if obj in meshswap_cache["groups"]: - # conf.log("Skipping meshwap obj already in cache: "+str(obj)) + # env.log("Skipping meshwap obj already in cache: "+str(obj)) continue # ignore list? e.g. Point.001, meshswap_cache["objects"].append(obj) return meshswap_cache -def getMeshswapList(context): +def getMeshswapList(context: Context) -> List[Tuple[str, str, str]]: """Only used for UI drawing of enum menus, full list.""" # may redraw too many times, perhaps have flag if not context.scene.mcprep_props.meshswap_list: updateMeshswapList(context) return [ - (itm.block, itm.name.title(), "Place {}".format(itm.name)) + (itm.block, itm.name.title(), f"Place {itm.name}") for itm in context.scene.mcprep_props.meshswap_list] -def move_assets_to_excluded_layer(context, collections): +def move_assets_to_excluded_layer(context: Context, collections: List[Collection]) -> None: """Utility to move source collections to excluded layer to not be rendered""" initial_view_coll = context.view_layer.active_layer_collection @@ -105,9 +108,9 @@ def move_assets_to_excluded_layer(context, collections): meshswap_exclude_vl.collection.children.link(grp) -def update_meshswap_path(self, context): +def update_meshswap_path(self, context: Context) -> None: """for UI list path callback""" - conf.log("Updating meshswap path", vv_only=True) + env.log("Updating meshswap path", vv_only=True) if not context.scene.meshswap_path.lower().endswith('.blend'): print("Meshswap file is not a .blend, and should be") if not os.path.isfile(bpy.path.abspath(context.scene.meshswap_path)): @@ -115,7 +118,7 @@ def update_meshswap_path(self, context): updateMeshswapList(context) -def updateMeshswapList(context): +def updateMeshswapList(context: Context) -> None: """Update the meshswap list""" meshswap_file = bpy.path.abspath(context.scene.meshswap_path) if not os.path.isfile(meshswap_file): @@ -134,7 +137,7 @@ def updateMeshswapList(context): continue if util.nameGeneralize(name).lower() in temp_meshswap_list: continue - description = "Place {x} block".format(x=name) + description = f"Place {name} block" meshswap_list.append((method, name, name.title(), description)) temp_meshswap_list.append(util.nameGeneralize(name).lower()) @@ -156,16 +159,16 @@ def updateMeshswapList(context): item.description = itm[3] -class face_struct(): +@dataclass +class FaceStruct: """Structure class for preprocessed faces of a mesh""" - def __init__(self, normal_coord, global_coord, local_coord): - self.n = normal_coord - self.g = global_coord - self.l = local_coord + n: VectorType # For normal_coord + g: VectorType # For global_coord + l: VectorType # For local_coord # ----------------------------------------------------------------------------- -# Mesh swap functions +# Mesh swap operators # ----------------------------------------------------------------------------- @@ -194,8 +197,8 @@ class MCPREP_OT_meshswap_spawner(bpy.types.Operator): def swap_enum(self, context): return getMeshswapList(context) - block = bpy.props.EnumProperty(items=swap_enum, name="Meshswap block") - method = bpy.props.EnumProperty( + block: bpy.props.EnumProperty(items=swap_enum, name="Meshswap block") + method: bpy.props.EnumProperty( name="Import method", items=[ # Making collection first to be effective default. @@ -203,32 +206,26 @@ def swap_enum(self, context): ("object", "Object asset", "Object asset"), ], options={'HIDDEN'}) - location = bpy.props.FloatVectorProperty( + location: bpy.props.FloatVectorProperty( default=(0, 0, 0), name="Location") - append_layer = bpy.props.IntProperty( - name="Append layer", - default=20, - min=0, - max=20, - description="Set the layer for appending groups, 0 means same as active layers") - prep_materials = bpy.props.BoolProperty( + prep_materials: bpy.props.BoolProperty( name="Prep materials", default=True, description="Run prep materials on objects after appending") - snapping = bpy.props.EnumProperty( + snapping: bpy.props.EnumProperty( name="Snapping", items=[ ("none", "No snap", "Keep exact location"), ("center", "Snap center", "Snap to block center"), ("offset", "Snap offset", "Snap to block center with 0.5 offset")], description="Automatically snap to whole block locations") - make_real = bpy.props.BoolProperty( + make_real: bpy.props.BoolProperty( name="Make real", default=False, # TODO: make True once able to retain animations like fire description="Automatically make groups real after placement") - # toLink = bpy.props.BoolProperty( + # toLink: bpy.props.BoolProperty( # name = "Library Link mob", # description = "Library link instead of append the group", # default = False @@ -295,7 +292,7 @@ def execute(self, context): self, context, block, pre_groups) if not group: - conf.log("No group identified, could not retrieve imported group") + env.log("No group identified, could not retrieve imported group") self.report({"ERROR"}, "Could not retrieve imported group") return {'CANCELLED'} @@ -303,8 +300,7 @@ def execute(self, context): for obj in util.get_objects_conext(context): util.select_set(obj, False) ob = util.addGroupInstance(group.name, self.location) - if util.bv28(): - util.move_to_collection(ob, context.collection) + util.move_to_collection(ob, context.collection) if self.snapping == "center": offset = 0 # could be 0.5 ob.location = [round(x + offset) - offset for x in self.location] @@ -314,38 +310,35 @@ def execute(self, context): ob["MCprep_noSwap"] = 1 if self.make_real: - if util.bv28(): - # make real doesn't select resulting output now (true for - # earlier versions of 2.8, not true in 2.82 at least where - # selects everything BUT the source of original instance - - pre_objs = list(bpy.data.objects) - bpy.ops.object.duplicates_make_real() - post_objs = list(bpy.data.objects) - new_objs = list(set(post_objs) - set(pre_objs)) - for obj in new_objs: - util.select_set(obj, True) - - # Re-apply animation if any - # TODO: Causes an issue for any animation that depends - # on direct xyz placement (noting that parents are - # cleared on Make Real). Could try adding a parent to - # any given object's current location and give the - # equivalent xyz offset at some point in time. - """ - orig_objs = [obo for obo in group.objects - if util.nameGeneralize(obj.name) == util.nameGeneralize(obo.name)] - if not orig_objs: - continue - obo = orig_objs[0] - if not obo or not obo.animation_data: - continue - if not obj.animation_data: - obj.animation_data_create() - obj.animation_data.action = obo.animation_data.action - """ - else: - bpy.ops.object.duplicates_make_real() + # make real doesn't select resulting output now (true for + # earlier versions of 2.8, not true in 2.82 at least where + # selects everything BUT the source of original instance + + pre_objs = list(bpy.data.objects) + bpy.ops.object.duplicates_make_real() + post_objs = list(bpy.data.objects) + new_objs = list(set(post_objs) - set(pre_objs)) + for obj in new_objs: + util.select_set(obj, True) + + # Re-apply animation if any + # TODO: Causes an issue for any animation that depends + # on direct xyz placement (noting that parents are + # cleared on Make Real). Could try adding a parent to + # any given object's current location and give the + # equivalent xyz offset at some point in time. + """ + orig_objs = [obo for obo in group.objects + if util.nameGeneralize(obj.name) == util.nameGeneralize(obo.name)] + if not orig_objs: + continue + obo = orig_objs[0] + if not obo or not obo.animation_data: + continue + if not obj.animation_data: + obj.animation_data_create() + obj.animation_data.action = obo.animation_data.action + """ if group is not None: spawn_util.fix_armature_target( @@ -373,27 +366,19 @@ def execute(self, context): util.set_active_object(context, ob) # Move source of group instance into excluded view layer - if util.bv28() and group: + if group: util.move_assets_to_excluded_layer(context, [group]) - self.track_param = "{}/{}".format(self.method, self.block) + self.track_param = f"{self.method}/{self.block}" return {'FINISHED'} - def prep_collection(self, context, block, pre_groups): + def prep_collection(self, context: Context, block: str, pre_groups: List[Collection]) -> Collection: """Prep the imported collection, ran only if newly imported (not cached)""" # Group method first, move append to according layer for ob in util.get_objects_conext(context): util.select_set(ob, False) - layers = [False] * 20 - if not hasattr(context.scene, "layers"): - # TODO: here add all subcollections to an MCprepLib collection. - pass - elif self.append_layer == 0: - layers = context.scene.layers - else: - layers[self.append_layer - 1] = True objlist = [] group = None for coll in util.collections(): @@ -424,9 +409,6 @@ def prep_collection(self, context, block, pre_groups): for ob in util.get_objects_conext(context): util.select_set(ob, False) - if hasattr(context.scene, "layers"): # 2.7 only - for obj in objlist: - obj.layers = layers return group @@ -460,35 +442,26 @@ class MCPREP_OT_meshswap(bpy.types.Operator): runcount = 0 # current counter status of swapped meshes # properties for draw - meshswap_join = bpy.props.BoolProperty( + meshswap_join: bpy.props.BoolProperty( name="Join same blocks", default=True, description=( "Join together swapped blocks of the same type " "(unless swapped with a group)")) - use_dupliverts = bpy.props.BoolProperty( + use_dupliverts: bpy.props.BoolProperty( name="Use dupliverts (faster)", default=True, description="Use dupliverts to add meshes") - link_groups = bpy.props.BoolProperty( + link_groups: bpy.props.BoolProperty( name="Link groups", default=False, description="Link groups instead of appending") - prep_materials = bpy.props.BoolProperty( + prep_materials: bpy.props.BoolProperty( name="Prep materials", default=False, description=( "Automatically apply prep materials (with default settings) " "to blocks added in")) - append_layer = bpy.props.IntProperty( - name="Append layer", - default=20, - min=0, - max=20, - description=( - "When groups are appended instead of linked, " - "the objects part of the group will be placed in this " - "layer, 0 means same as active layers")) @classmethod def poll(cls, context): @@ -517,8 +490,6 @@ def draw(self, context): row.prop(self, "link_groups") row.prop(self, "prep_materials") row = layout.row() - if not util.bv28(): - row.prop(self, "append_layer") # multi settings, to come # layout.split() @@ -565,15 +536,15 @@ def execute(self, context): # Assign vars used across operator self.runcount = 0 # counter; if zero by end, raise error nothing matched - objList = self.prep_obj_list(context) - selList = context.selected_objects # re-grab having made new objects + objList: List[bpy.types.Object] = self.prep_obj_list(context) + selList: List[bpy.types.Object] = context.selected_objects # re-grab having made new objects new_groups = [] # for new imported groups removeList = [] # for objects that should be removed new_objects = [] # all the newly added objects # setup the progress bar denom = len(objList) - conf.log("Meshswap to check over {} objects".format(denom)) + env.log(f"Meshswap to check over {denom} objects") bpy.context.window_manager.progress_begin(0, 100) tprep = time.time() - tprep @@ -589,9 +560,9 @@ def execute(self, context): t2s.append(t0s[-1]) t3s.append(t0s[-1]) bpy.context.window_manager.progress_update(iter_index / denom) - swapGen = util.nameGeneralize(swap.name) + swapGen: str = util.nameGeneralize(swap.name) # swapGen = generate.get_mc_canonical_name(swap.name) - conf.log("Simplified name: {x}".format(x=swapGen)) + env.log(f"Simplified name: {swapGen}") # IMPORTS, gets lists properties, etc swapProps = self.checkExternal(context, swapGen) @@ -609,19 +580,14 @@ def execute(self, context): if not (swapProps['meshSwap'] or swapProps['groupSwap']): continue - conf.log( - "Swapping '{x}', simplified name '{y}".format( - x=swap.name, y=swapGen)) + env.log( + f"Swapping '{swap.name}', simplified name '{swapGen}") # loop through each face or "polygon" of mesh, throw out invalids t1s[-1] = time.time() offset = 0.5 if self.track_exporter == 'Mineways' else 0 facebook = self.get_face_list(swap, offset) - if not util.bv28(): - for obj in context.selected_objects: - util.select_set(obj, False) - # removing duplicates and checking orientation # structure of: "x-y-z":[[x,y,z], [xr, yr, zr]] instance_configs = {} @@ -667,7 +633,7 @@ def execute(self, context): if context.selected_objects: new_objects.append(context.selected_objects[0]) else: - conf.log("No selected objects after join") + env.log("No selected objects after join") else: # no joining, so just directly append to new_objects new_objects += dupedObj # a list @@ -705,7 +671,7 @@ def execute(self, context): try: util.obj_unlink_remove(rm, True, context) except: - print("Failed to clear user/remove object: " + rm.name) + print(f"Failed to clear user/remove object: {rm.name}") for obj in selList: # Risk if object was joined against another object that its data @@ -723,27 +689,29 @@ def execute(self, context): # Create nicer nested organization of meshswapped assets, and excluding # this meshswap group to avoid showing in render. Also move newly # spawned instances into a collection of its own (2.8 only) - if util.bv28(): - swaped_vl = util.get_or_create_viewlayer(context, "Meshswap Render") - for obj in new_objects: - util.move_to_collection(obj, swaped_vl.collection) + swaped_vl = util.get_or_create_viewlayer(context, "Meshswap Render") + for obj in new_objects: + util.move_to_collection(obj, swaped_vl.collection) - util.move_assets_to_excluded_layer(context, new_groups) + util.move_assets_to_excluded_layer(context, new_groups) # end progress bar, end of primary section bpy.context.window_manager.progress_end() t5 = time.time() # run timing calculations - if conf.vv: + if env.very_verbose: loop_prep = sum(t1s) - sum(t0s) face_process = sum(t2s) - sum(t1s) instancing = sum(t3s) - sum(t2s) cleanup = t5 - t4 total = tprep + loop_prep + face_process + instancing + cleanup - conf.log("Total time: {}s, init: {}, prep: {}, poly process: {}, instance:{}, cleanup: {}".format( - round(total, 1), round(tprep, 1), round(loop_prep, 1), - round(face_process, 1), round(instancing, 1), round(cleanup, 1))) + env.log(( + f"Total time: {round(total, 1)}s, init: {round(tprep, 1)}, " + f"prep: {round(loop_prep, 1)}, " + f"poly process: {round(face_process, 1)}, " + f"instance:{round(instancing, 1)}, cleanup: {round(cleanup, 1)}" + )) if self.runcount == 0: self.report({'ERROR'}, ( @@ -753,10 +721,10 @@ def execute(self, context): elif self.runcount == 1: self.report({'INFO'}, "Swapped 1 object") return {'FINISHED'} - self.report({'INFO'}, "Swapped {} objects".format(self.runcount)) + self.report({'INFO'}, f"Swapped {self.runcount} objects") return {'FINISHED'} - def prep_obj_list(self, context): + def prep_obj_list(self, context: Context) -> List[bpy.types.Object]: """Initial operator prep to get list of objects to check over""" try: bpy.ops.object.convert(target='MESH') @@ -779,7 +747,7 @@ def prep_obj_list(self, context): obj.name = util.nameGeneralize(obj.active_material.name) return objList - def get_face_list(self, swap, offset): + def get_face_list(self, swap: bpy.types.Object, offset: float) -> List[VectorType]: """Returns list of relevant faces and mapped coordinates. Offset is for Mineways to virtually shift all block centers to half ints @@ -799,10 +767,10 @@ def get_face_list(self, swap, offset): if 0.015 < poly.area and poly.area < 0.016: # hack to avoid too many torches show up, both jmc2obj and Mineways continue - facebook.append(face_struct(n, g, l)) # g is global, l is local + facebook.append(FaceStruct(n, g, l)) # g is global, l is local return facebook - def checkExternal(self, context, name): + def checkExternal(self, context: Context, name: str) -> Union[bool, Dict[str, str]]: """Called for each object in the loop as soon as possible.""" groupSwap = False @@ -849,15 +817,15 @@ def checkExternal(self, context, name): # delete unnecessary ones first if name in rmable: removable = True - conf.log("Removable!") + env.log("Removable!") return {'removable': removable} # check the actual name against the library name = generate.get_mc_canonical_name(name)[0] cache = get_meshswap_cache(context) - if name in conf.json_data["blocks"]["canon_mapping_block"]: + if name in env.json_data["blocks"]["canon_mapping_block"]: # e.g. remaps entity/chest/normal back to chest - name_remap = conf.json_data["blocks"]["canon_mapping_block"][name] + name_remap = env.json_data["blocks"]["canon_mapping_block"][name] else: name_remap = None @@ -877,8 +845,7 @@ def checkExternal(self, context, name): return False # if not present, continue # now import - conf.log("about to link, group {} / mesh {}?".format( - groupSwap, meshSwap)) + env.log(f"About to link, group {groupSwap} / mesh {meshSwap}?") toLink = self.link_groups for ob in context.selected_objects: util.select_set(ob, False) @@ -886,11 +853,8 @@ def checkExternal(self, context, name): # import: guaranteed to have same name as "appendObj" for the first # instant afterwards. - # Used to check if to join or not. - grouped = False # Need to initialize to something, though this obj not used. importedObj = None - groupAppendLayer = self.append_layer # for blender 2.8 compatibility if hasattr(bpy.data, "groups"): @@ -900,41 +864,24 @@ def checkExternal(self, context, name): if groupSwap: if name not in util.collections(): - # if group not linked, put appended group data onto the GUI field layer - if hasattr(context.scene, "layers"): # blender 2.7x - activeLayers = list(context.scene.layers) - else: - activeLayers = None - if (not toLink) and (groupAppendLayer != 0): - x = [False] * 20 - x[groupAppendLayer - 1] = True - if hasattr(context.scene, "layers"): - context.scene.layers = x # Get prelist of groups/collections to check against afterwards pre_colls = list(util.collections()) # special cases, make another list for this? number of variants can vary.. if name == "torch" or name == "Torch": - if name + ".1" not in pre_colls: - util.bAppendLink(os.path.join(meshSwapPath, g_or_c), name + ".1", toLink) - if name + ".2" not in pre_colls: - util.bAppendLink(os.path.join(meshSwapPath, g_or_c), name + ".2", toLink) + if f"{name}.1" not in pre_colls: + util.bAppendLink(os.path.join(meshSwapPath, g_or_c), f"{name}.1", toLink) + if f"{name}.2" not in pre_colls: + util.bAppendLink(os.path.join(meshSwapPath, g_or_c), f"{name}.2", toLink) util.bAppendLink(os.path.join(meshSwapPath, g_or_c), name, toLink) - if util.bv28(): - post_colls = list(util.collections()) - new_groups += list(set(post_colls) - set(pre_colls)) + post_colls = list(util.collections()) + new_groups += list(set(post_colls) - set(pre_colls)) - grouped = True - # if activated a different layer, go back to the original ones - if hasattr(context.scene, "layers") and activeLayers: - context.scene.layers = activeLayers - # 2.8 handled later with everything at once - grouped = True # set properties for item in util.collections()[name].items(): - conf.log("GROUP PROPS:" + str(item)) + env.log(f"GROUP PROPS:{item}") try: x = item[1].name # will NOT work if property UI except: @@ -959,7 +906,7 @@ def checkExternal(self, context, name): # ## BE IN FILE, EG NAME OF MESH TO SWAP CHANGED, INDEX ERROR IS THROWN HERE # ## >> MAKE a more graceful error indication. # filter out non-meshes in case of parent grouping or other pull-ins - # conf.log("DEBUG - post importing {}, selected objects: {}".format( + # env.log("DEBUG - post importing {}, selected objects: {}".format( # name, list(bpy.context.selected_objects)), vv_only=True) for ob in bpy.context.selected_objects: @@ -994,16 +941,18 @@ def checkExternal(self, context, name): for ob in context.selected_objects: util.select_set(ob, False) # #### HERE set the other properties, e.g. variance and edgefloat, now that the obj exists - conf.log("groupSwap: {}, meshSwap: {}".format(groupSwap, meshSwap)) - conf.log("edgeFloat: {}, variance: {}, torchlike: {}".format( - edgeFloat, variance, torchlike)) + env.log(f"groupSwap: {groupSwap}, meshSwap: {meshSwap}") + env.log(f"edgeFloat: {edgeFloat}, variance: {variance}, torchlike: {torchlike}") return { 'importName': name, 'object': importedObj, 'meshSwap': meshSwap, 'groupSwap': groupSwap, 'variance': variance, 'edgeFlush': edgeFlush, 'edgeFloat': edgeFloat, 'torchlike': torchlike, 'removable': removable, 'doorlike': doorlike, 'new_groups': new_groups} - def proccess_poly_orientations(self, face, swapProps, swapGen, instance_configs): + def proccess_poly_orientations( + self, face: FaceStruct, swapProps: Dict[str, str], swapGen: str, + instance_configs: Dict[str, Tuple[VectorType, int]] + ) -> None: """Iterate over individual face, updating instance loc/rotation Arguments: @@ -1035,7 +984,7 @@ def proccess_poly_orientations(self, face, swapProps, swapGen, instance_configs) else: a, b, c = 0, 0, 0 - instance_key = "{}-{}-{}".format(x, y, z) + instance_key = f"{x}-{y}-{z}" loc = [x, y, z] # ### TORCHES, hack removes duplicates while not removing "edge" floats @@ -1045,21 +994,21 @@ def proccess_poly_orientations(self, face, swapProps, swapGen, instance_configs) # if not swapProps['edgeFloat']: # #continue # print("do nothing, this is for jmc2obj") - conf.log( + env.log( "Instance: loc, face.local, face.nrm, hanging offset, if_edgeFloat:", vv_only=True) - conf.log( + env.log( str([loc, face.l, face.n, [a, b, c], outside_hanging]), vv_only=True) # ## START HACK PATCH, FOR MINEWAYS (single-tex export) double-tall blocks # prevent double high grass... which mineways names sunflowers. hack_check = ["Sunflower", "Iron_Door", "Wooden_Door"] - if swapGen in hack_check and "{}-{}-{}".format(x, y - 1, z) in instance_configs: + if swapGen in hack_check and f"{x}-{y - 2}-{z}" in instance_configs: overwrite = -1 - elif swapGen in hack_check and "{}-{}-{}".format(x, y + 1, z) in instance_configs: + elif swapGen in hack_check and f"{x}-{y + 1}-{z}" in instance_configs: # dupList[dupList.index([x,y+1,z])] = [x,y,z] - instance_configs["{}-{}-{}".format(x, y + 1, z)][0] = loc # update loc only + instance_configs[f"{x}-{y + 1}-{z}"][0] = loc # update loc only overwrite = -1 else: overwrite = 0 # 0 = normal, -1 = skip, 1 = overwrite the block below @@ -1091,7 +1040,7 @@ def proccess_poly_orientations(self, face, swapProps, swapGen, instance_configs) else: rot_type = 0 elif swapProps['edgeFloat']: - conf.log("Edge float!", vv_only=True) + env.log("Edge float!", vv_only=True) if (y - face.l[1] < 0): rot_type = 8 elif (x_diff > 0.3): @@ -1120,9 +1069,9 @@ def proccess_poly_orientations(self, face, swapProps, swapGen, instance_configs) else: rot_type = 0 elif self.track_exporter == "Mineways": - conf.log("checking: {} {}".format(x_diff, z_diff)) + env.log(f"checking: {x_diff} {z_diff}") if swapProps['torchlike']: # needs fixing - conf.log("recognized it's a torchlike obj..") + env.log("recognized it's a torchlike obj..") if (x_diff > .1 and x_diff < 0.6): rot_type = 1 elif (z_diff > .1 and z_diff < 0.6): @@ -1169,7 +1118,13 @@ def proccess_poly_orientations(self, face, swapProps, swapGen, instance_configs) loc_unoffset = [pos + offset for pos in loc] instance_configs[instance_key] = [loc_unoffset, rot_type] - def add_instances_with_transforms(self, context, swap, swapProps, instance_configs): + def add_instances_with_transforms( + self, + context: Context, + swap: bpy.types.Object, + swapProps: Dict[str, str], + instance_configs: Dict[str, Tuple[VectorType, int]] + ) -> Tuple[bool, List[bpy.types.Object]]: """Creates all block instances for a single object. Will add and apply rotations, add loc variances, and run random group @@ -1189,10 +1144,7 @@ def add_instances_with_transforms(self, context, swap, swapProps, instance_confi self.runcount += 1 if (self.counterObject > self.countMax): self.counterObject = 0 - if not util.bv28(): - pass - # bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1) - elif hasattr(context, "view_layer"): + if hasattr(context, "view_layer"): context.view_layer.update() # but does not redraw ui loc = util.matmul(swap.matrix_world, mathutils.Vector(loc_local)) @@ -1200,8 +1152,8 @@ def add_instances_with_transforms(self, context, swap, swapProps, instance_confi # loc = swap.matrix_world*mathutils.Vector(set) #local to global if grouped: # definition for randimization, defined at top! - randGroup = util.randomizeMeshSawp(swapProps['importName'], 3) - conf.log("Rand group: {}".format(randGroup)) + randGroup = util.randomizeMeshSwap(swapProps['importName'], 3) + env.log(f"Rand group: {randGroup}") new_ob = util.addGroupInstance(randGroup, loc) if hasattr(new_ob, "empty_draw_size"): new_ob.empty_draw_size = 0.25 @@ -1279,17 +1231,12 @@ def add_instances_with_transforms(self, context, swap, swapProps, instance_confi y = (random.random() - 0.5) * 0.5 new_ob.location += mathutils.Vector((x, y, 0)) - # Clear selection before moving on with next iteration - for ob in context.selected_objects: - if util.bv28(): - continue - util.select_set(ob, False) return grouped, dupedObj - def offsetByHalf(self, obj): + def offsetByHalf(self, obj: bpy.types.Object) -> None: if obj.type != 'MESH': return - conf.log("doing offset") + env.log("doing offset") active = bpy.context.object # preserve current active util.set_active_object(bpy.context, obj) bpy.ops.object.mode_set(mode='EDIT') @@ -1310,17 +1257,17 @@ class MCPREP_OT_fix_mineways_scale(bpy.types.Operator): @tracking.report_error def execute(self, context): - conf.log("Attempting to fix Mineways scaling for meshswap") + env.log("Attempting to fix Mineways scaling for meshswap") # get cursor loc first? shouldn't matter which mode/location though - tmp_loc = util.get_cuser_location(context) + tmp_loc = util.get_cursor_location(context) if hasattr(context.space_data, "pivot_point"): tmp = context.space_data.pivot_point bpy.context.space_data.pivot_point = 'CURSOR' else: tmp = context.scene.tool_settings.transform_pivot_point context.scene.tool_settings.transform_pivot_point = 'CURSOR' - util.set_cuser_location((0, 0, 0), context) + util.set_cursor_location((0, 0, 0), context) bpy.ops.transform.resize(value=(10, 10, 10)) bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) @@ -1329,7 +1276,7 @@ def execute(self, context): bpy.context.space_data.pivot_point = tmp else: context.scene.tool_settings.transform_pivot_point = tmp - util.set_cuser_location(tmp_loc, context) + util.set_cursor_location(tmp_loc, context) return {'FINISHED'} @@ -1349,7 +1296,6 @@ def execute(self, context): def register(): for cls in classes: - util.make_annotations(cls) bpy.utils.register_class(cls) global meshswap_cache diff --git a/MCprep_addon/spawner/mobs.py b/MCprep_addon/spawner/mobs.py index 0d7219ab..91508d4a 100644 --- a/MCprep_addon/spawner/mobs.py +++ b/MCprep_addon/spawner/mobs.py @@ -17,15 +17,17 @@ # ##### END GPL LICENSE BLOCK ##### -# library imports +from pathlib import Path +from typing import List import errno import os import shutil import bpy from bpy_extras.io_utils import ImportHelper +from bpy.types import Context -from .. import conf +from ..conf import env from .. import util from .. import tracking @@ -37,7 +39,7 @@ # ----------------------------------------------------------------------------- -def get_rig_list(context): +def get_rig_list(context: Context) -> List[tuple]: """Only used for operator UI Enum property in redo last / popups""" # may redraw too many times, perhaps have flag to prevent re-runs @@ -49,25 +51,25 @@ def get_rig_list(context): return ret_list -def update_rig_path(self, context): +def update_rig_path(self, context: Context) -> None: """List for UI mobs callback of property spawn_rig_category.""" - conf.log("Updating rig path", vv_only=True) - conf.rig_categories = [] + env.log("Updating rig path", vv_only=True) + env.rig_categories = [] update_rig_list(context) spawn_rigs_categories(self, context) -def update_rig_list(context): +def update_rig_list(context: Context) -> None: """Update the rig list and subcategory list""" - def _add_rigs_from_blend(path, blend_name, category): + def _add_rigs_from_blend(path: Path, blend_name: str, category: str): """Block for loading blend file groups to get rigs""" with bpy.data.libraries.load(path) as (data_from, data_to): extensions = [".png", ".jpg", ".jpeg"] icon_folder = os.path.join(os.path.dirname(path), "icons") run_icons = os.path.isdir(icon_folder) - if not conf.use_icons or conf.preview_collections["mobs"] == "": + if not env.use_icons or env.preview_collections["mobs"] == "": run_icons = False mob_names = spawn_util.filter_collections(data_from) @@ -87,10 +89,9 @@ def _add_rigs_from_blend(path, blend_name, category): mob.category = category mob.index = len(context.scene.mcprep_props.mob_list_all) if category: - mob.mcmob_type = os.path.join( - category, blend_name) + ":/:" + name + mob.mcmob_type = f"{os.path.join(category, blend_name)}:/:{name}" else: - mob.mcmob_type = blend_name + ":/:" + name + mob.mcmob_type = f"{blend_name}:/:{name}" # if available, load the custom icon too if not run_icons: @@ -104,7 +105,7 @@ def _add_rigs_from_blend(path, blend_name, category): and os.path.splitext(f.lower())[-1] in extensions] if not icons: continue - conf.preview_collections["mobs"].load( + env.preview_collections["mobs"].load( "mob-{}".format(mob.index), os.path.join(icon_folder, icons[0]), 'IMAGE') @@ -113,15 +114,15 @@ def _add_rigs_from_blend(path, blend_name, category): context.scene.mcprep_props.mob_list.clear() context.scene.mcprep_props.mob_list_all.clear() - if conf.use_icons and conf.preview_collections["mobs"]: + if env.use_icons and env.preview_collections["mobs"]: print("Removing mobs preview collection") try: - bpy.utils.previews.remove(conf.preview_collections["mobs"]) + bpy.utils.previews.remove(env.preview_collections["mobs"]) except: - conf.log("MCPREP: Failed to remove icon set, mobs") + env.log("MCPREP: Failed to remove icon set, mobs") if os.path.isdir(rigpath) is False: - conf.log("Rigpath directory not found") + env.log("Rigpath directory not found") return categories = [ @@ -157,13 +158,13 @@ def _add_rigs_from_blend(path, blend_name, category): update_rig_category(context) -def update_rig_category(context): +def update_rig_category(context: Context): """Update the list of mobs for the given category from the master list""" scn_props = context.scene.mcprep_props if not scn_props.mob_list_all: - conf.log("No rigs found, failed to update category") + env.log("No rigs found, failed to update category") scn_props.mob_list.clear() return @@ -202,7 +203,7 @@ class MCPREP_OT_reload_mobs(bpy.types.Operator): @tracking.report_error def execute(self, context): - conf.rig_categories = [] + env.rig_categories = [] update_rig_list(context) return {'FINISHED'} @@ -217,8 +218,8 @@ class MCPREP_OT_mob_spawner(bpy.types.Operator): def riglist_enum(self, context): return get_rig_list(context) - mcmob_type = bpy.props.EnumProperty(items=riglist_enum, name="Mob Type") - relocation = bpy.props.EnumProperty( + mcmob_type: bpy.props.EnumProperty(items=riglist_enum, name="Mob Type") + relocation: bpy.props.EnumProperty( items=[ ('Cursor', 'Cursor', 'Move the rig to the cursor'), ('Clear', 'Origin', 'Move the rig to the origin'), @@ -226,22 +227,22 @@ def riglist_enum(self, context): 'Offset the root bone to cursor while leaving the rest pose ' 'at the origin'))], name="Relocation") - toLink = bpy.props.BoolProperty( + toLink: bpy.props.BoolProperty( name="Library Link", description="Library link instead of append the group", default=False) - clearPose = bpy.props.BoolProperty( + clearPose: bpy.props.BoolProperty( name="Clear Pose", description="Clear the pose to rest position", default=True) - prep_materials = bpy.props.BoolProperty( + prep_materials: bpy.props.BoolProperty( name="Prep materials (will reset nodes)", description=( "Prep materials of the added rig, will replace cycles node groups " "with default"), default=True) - skipUsage = bpy.props.BoolProperty( + skipUsage: bpy.props.BoolProperty( default=False, options={'HIDDEN'}) @@ -267,11 +268,11 @@ def execute(self, context): try: [path, name] = self.mcmob_type.split(':/:') except Exception as err: - conf.log("Error: Failed to parse mcmob_type") + env.log("Error: Failed to parse mcmob_type") self.report({'ERROR'}, "Failed to parse mcmob_type, try reloading mobs") return {'CANCELLED'} path = os.path.join(context.scene.mcprep_mob_path, path) - conf.log("Path is now ", path) + env.log("Path is now ", path) try: # must be in object mode, this make similar behavior to other objs @@ -283,7 +284,7 @@ def execute(self, context): if self.toLink: if path == '//': - conf.log("This is the local file. Cancelling...") + env.log("This is the local file. Cancelling...") return {'CANCELLED'} _ = spawn_util.load_linked(self, context, path, name) else: @@ -325,7 +326,7 @@ def set_fake_users(self, context, new_objs): for obj in mod_objs: if obj not in list(context.scene.collection.all_objects): obj.use_fake_user = True - conf.log("Set {} as fake user".format(obj.name)) + env.log("Set {} as fake user".format(obj.name)) class MCPREP_OT_install_mob(bpy.types.Operator, ImportHelper): @@ -337,7 +338,7 @@ class MCPREP_OT_install_mob(bpy.types.Operator, ImportHelper): "in selected blend file will become individually spawnable") filename_ext = ".blend" - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.blend", options={'HIDDEN'}, ) @@ -357,7 +358,7 @@ def getCategories(self, context): ret.append(("no_category", "No Category", "Uncategorized mob")) # last entry return ret - mob_category = bpy.props.EnumProperty( + mob_category: bpy.props.EnumProperty( items=getCategories, name="Mob Category") @@ -367,19 +368,19 @@ def execute(self, context): newrig = bpy.path.abspath(self.filepath) if not os.path.isfile(newrig): - conf.log("Error: Rig blend file not found!") + env.log("Error: Rig blend file not found!") self.report({'ERROR'}, "Rig blend file not found!") return {'CANCELLED'} if not newrig.lower().endswith('.blend'): - conf.log("Error: Not a blend file! Select a .blend file with a rig") + env.log("Error: Not a blend file! Select a .blend file with a rig") self.report({'ERROR'}, "Not a blend file! Select a .blend file with a rig") return {'CANCELLED'} # now check the rigs folder indeed exists drpath = bpy.path.abspath(drpath) if not os.path.isdir(drpath): - conf.log("Error: Rig directory is not valid!") + env.log("Error: Rig directory is not valid!") self.report({'ERROR'}, "Rig directory is not valid!") return {'CANCELLED'} @@ -391,7 +392,7 @@ def execute(self, context): install_groups.pop(install_groups.index('Collection')) if not install_groups: - conf.log("Error: no groups found in blend file!") + env.log("Error: no groups found in blend file!") self.report({'ERROR'}, "No groups found in blend file!") return {'CANCELLED'} @@ -430,7 +431,7 @@ def execute(self, context): # copy all relevant icons, based on groups installed # ## matching same folde or subfolder icons to append - if conf.use_icons: + if env.use_icons: basedir = os.path.dirname(newrig) icon_files = self.identify_icons(install_groups, basedir) icondir = os.path.join(basedir, "icons") @@ -444,18 +445,17 @@ def execute(self, context): except OSError as exc: if exc.errno == errno.EACCES: print("Permission denied, try running blender as admin") - print(dst) print(exc) elif exc.errno != errno.EEXIST: - print("Path does not exist: " + dst) + print(f"Path does not exist: {dst}") print(exc) for icn in icon_files: icn_base = os.path.basename(icn) try: shutil.copy2(icn, os.path.join(dst, icn_base)) except IOError as err: - print("Failed to copy over icon file " + icn) - print("to " + os.path.join(icondir, icn_base)) + print(f"Failed to copy over icon file {icn}") + print(f"to {os.path.join(icondir, icn_base)}") print(err) # reload the cache @@ -560,22 +560,22 @@ def execute(self, context): path = os.path.join(context.scene.mcprep_mob_path, path) except Exception as e: self.report({'ERROR'}, "Could not resolve file to delete") - print("Error trying to remove mob file: " + str(e)) + print(f"Error trying to remove mob file: {e}") return {'CANCELLED'} if os.path.isfile(path) is False: - conf.log("Error: Source filepath not found, didn't delete: " + path) + env.log(f"Error: Source filepath not found, didn't delete: {path}") self.report({'ERROR'}, "Source filepath not found, didn't delete") return {'CANCELLED'} else: try: os.remove(path) except Exception as err: - conf.log("Error: could not delete file: " + str(err)) + env.log(f"Error: could not delete file: {err}") self.report({'ERROR'}, "Could not delete file") return {'CANCELLED'} - self.report({'INFO'}, "Removed: " + str(path)) - conf.log("Removed file: " + str(path)) + self.report({'INFO'}, f"Removed: {path}") + env.log(f"Removed file: {path}") bpy.ops.mcprep.reload_mobs() return {'FINISHED'} @@ -585,11 +585,11 @@ class MCPREP_OT_install_mob_icon(bpy.types.Operator, ImportHelper): bl_idname = "mcprep.mob_install_icon" bl_label = "Install mob icon" - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="", options={'HIDDEN'}) fileselectparams = "use_filter_blender" - filter_image = bpy.props.BoolProperty( + filter_image: bpy.props.BoolProperty( default=True, options={'HIDDEN', 'SKIP_SAVE'}) @@ -624,7 +624,7 @@ def execute(self, context): if exc.errno == errno.EACCES: print("Permission denied, try running blender as admin") elif exc.errno != errno.EEXIST: - print("Path does not exist: " + icon_dir) + print(f"Path does not exist: {icon_dir}") # if the file exists already, remove it. if os.path.isfile(new_file): @@ -648,12 +648,12 @@ def execute(self, context): # if successful, load or reload icon id icon_id = "mob-{}".format(mob.index) - if icon_id in conf.preview_collections["mobs"]: + if icon_id in env.preview_collections["mobs"]: print("Deloading old icon for this mob") - print(dir(conf.preview_collections["mobs"][icon_id])) - conf.preview_collections["mobs"][icon_id].reload() + print(dir(env.preview_collections["mobs"][icon_id])) + env.preview_collections["mobs"][icon_id].reload() else: - conf.preview_collections["mobs"].load(icon_id, new_file, 'IMAGE') + env.preview_collections["mobs"].load(icon_id, new_file, 'IMAGE') print("Icon reloaded") return {'FINISHED'} @@ -663,31 +663,31 @@ def execute(self, context): # ----------------------------------------------------------------------------- -def spawn_rigs_categories(self, context): +def spawn_rigs_categories(self, context: Context) -> List[tuple]: """Used as enum UI list for spawn_rig_category dropdown""" items = [] items.append(("all", "All Mobs", "Show all mobs loaded")) - categories = conf.rig_categories - if not conf.rig_categories: + categories = env.rig_categories + if not env.rig_categories: it = context.scene.mcprep_mob_path try: categories = [ f for f in os.listdir(it) if os.path.isdir(os.path.join(it, f))] - conf.rig_categories = categories + env.rig_categories = categories except FileNotFoundError: pass # Directory has changed or is not found. for item in categories: ui_name = item + " mobs" items.append(( item, ui_name.title(), - "Show all mobs in the '" + item + "' category")) + f"Show all mobs in the '{item}' category")) items.append(("no_category", "Uncategorized", "Show all uncategorized mobs")) return items -def spawn_rigs_category_load(self, context): +def spawn_rigs_category_load(self, context: Context) -> None: """Update function for UI property spawn rig category""" update_rig_category(context) return @@ -709,7 +709,6 @@ def spawn_rigs_category_load(self, context): def register(): for cls in classes: - util.make_annotations(cls) bpy.utils.register_class(cls) diff --git a/MCprep_addon/spawner/spawn_util.py b/MCprep_addon/spawner/spawn_util.py old mode 100755 new mode 100644 index fb1103da..a8031347 --- a/MCprep_addon/spawner/spawn_util.py +++ b/MCprep_addon/spawner/spawn_util.py @@ -18,15 +18,19 @@ import os import re +from typing import List, Optional +from pathlib import Path import bpy +from bpy.types import Context, Collection, BlendDataLibraries -from .. import conf +from ..conf import env from .. import util from .. import tracking from . import mobs from . import effects + # Top-level names used for inclusion or exclusions when filtering through # collections in blend files for spawners: mobs, meshswap, and entities. INCLUDE_COLL = "mcprep" @@ -34,20 +38,17 @@ SKIP_COLL_LEGACY = "noimport" # Supporting older MCprep Meshswap lib. # Icon backwards compatibility. -if util.bv30(): - COLL_ICON = 'OUTLINER_COLLECTION' -elif util.bv28(): - COLL_ICON = 'COLLECTION_NEW' -else: - COLL_ICON = 'GROUP' +COLL_ICON = 'OUTLINER_COLLECTION' if util.bv30() else 'COLLECTION_NEW' + # ----------------------------------------------------------------------------- # Reusable functions for spawners # ----------------------------------------------------------------------------- -def filter_collections(data_from): - """Generalized way to prefilter collections in a blend file. +def filter_collections(data_from: BlendDataLibraries) -> List[str]: + """ TODO 2.7 groups + Generalized way to prefilter collections in a blend file. Enforces the awareness of inclusion and exlcusion collection names, and some hard coded cases to always ignore. @@ -80,10 +81,10 @@ def filter_collections(data_from): short = name.replace(" ", "").replace("-", "").replace("_", "") if SKIP_COLL in short.lower(): - conf.log("Skipping collection: " + name) + env.log(f"Skipping collection: {name}") continue if SKIP_COLL_LEGACY in short.lower(): - conf.log("Skipping legacy collection: " + name) + env.log(f"Skipping legacy collection: {name}") continue elif INCLUDE_COLL in name.lower(): any_mcprep = True @@ -91,13 +92,12 @@ def filter_collections(data_from): all_names.append(name) if any_mcprep: - conf.log("Filtered from {} down to {} MCprep collections".format( - len(all_names), len(mcprep_names))) + env.log(f"Filtered from {len(all_names)} down to {len(mcprep_names)} MCprep collections") all_names = mcprep_names return all_names -def check_blend_eligible(this_file, all_files): +def check_blend_eligible(this_file: Path, all_files: List[Path]) -> bool: """Returns true if this_file is the BEST blend file variant for this rig. Created to better support older blender versions without having to @@ -192,7 +192,7 @@ def tuple_from_match(match): return latest_allowed != this_file -def attemptScriptLoad(path): +def attemptScriptLoad(path: Path) -> None: """Search for script that matches name of the blend file""" # TODO: should also look into the blend if appropriate @@ -202,13 +202,12 @@ def attemptScriptLoad(path): path = path[:-5] + "py" if os.path.basename(path) in [txt.name for txt in bpy.data.texts]: - conf.log("Script {} already imported, not importing a new one".format( - os.path.basename(path))) + env.log(f"Script {os.path.basename(path)} already imported, not importing a new one") return if not os.path.isfile(path): return # no script found - conf.log("Script found, loading and running it") + env.log("Script found, loading and running it") text = bpy.data.texts.load(filepath=path, internal=True) try: ctx = bpy.context.copy() @@ -221,13 +220,13 @@ def attemptScriptLoad(path): ctx.use_fake_user = True ctx.use_module = True except: - conf.log("Failed to run the script, not registering") + env.log("Failed to run the script, not registering") return - conf.log("Ran the script") + env.log("Ran the script") text.use_module = True -def fix_armature_target(self, context, new_objs, src_coll): +def fix_armature_target(self, context: Context, new_objs: List[bpy.types.Object], src_coll: Collection) -> None: """Addresses 2.8 bug where make real might not update armature source""" src_armas = [ @@ -261,17 +260,17 @@ def fix_armature_target(self, context, new_objs, src_coll): if old_target.animation_data: new_target.animation_data_create() new_target.animation_data.action = old_target.animation_data.action - conf.log( - "Updated animation of armature for instance of " + src_coll.name) + env.log( + f"Updated animation of armature for instance of {src_coll.name}") if mod.object in new_objs: continue # Was already the new object target for modifier. mod.object = new_target - conf.log( - "Updated target of armature for instance of " + src_coll.name) + env.log( + f"Updated target of armature for instance of {src_coll.name}") -def prep_collection(self, context, name, pre_groups): +def prep_collection(self, context: Context, name: str, pre_groups: List[Collection]) -> Optional[Collection]: """Prep the imported collection, ran only if newly imported (not cached)""" # Group method first, move append to according layer @@ -322,7 +321,7 @@ def prep_collection(self, context, name, pre_groups): return group -def get_rig_from_objects(objects): +def get_rig_from_objects(objects: List[bpy.types.Object]) -> bpy.types.Object: """From a list of objects, return the the primary rig (best guess)""" prox_obj = None for obj in objects: @@ -338,11 +337,11 @@ def get_rig_from_objects(objects): return prox_obj -def offset_root_bone(context, armature): +def offset_root_bone(context: Context, armature: bpy.types.Object) -> bool: """Used to offset bone to world location (cursor)""" - conf.log("Attempting offset root") + env.log("Attempting offset root") set_bone = False - lower_bones = [bone.name.lower() for bone in armature.pose.bones] + lower_bones: List[str] = [bone.name.lower() for bone in armature.pose.bones] lower_name = None for name in ["main", "root", "base", "master"]: if name in lower_bones: @@ -356,7 +355,7 @@ def offset_root_bone(context, armature): continue bone.location = util.matmul( bone.bone.matrix.inverted(), - util.get_cuser_location(context), + util.get_cursor_location(context), armature.matrix_world.inverted() ) @@ -365,7 +364,7 @@ def offset_root_bone(context, armature): return set_bone -def load_linked(self, context, path, name): +def load_linked(self, context: Context, path: str, name: str) -> None: """Process for loading mob or entity via linked library. Used by mob spawner when chosing to link instead of append. @@ -374,10 +373,10 @@ def load_linked(self, context, path, name): path = bpy.path.abspath(path) act = None if hasattr(bpy.data, "groups"): - res = util.bAppendLink(path + '/Group', name, True) + res = util.bAppendLink(f"{path}/Group", name, True) act = context.object # assumption of object after linking, 2.7 only elif hasattr(bpy.data, "collections"): - res = util.bAppendLink(path + '/Collection', name, True) + res = util.bAppendLink(f"{path}/Collection", name, True) if len(context.selected_objects) > 0: act = context.selected_objects[0] # better for 2.8 else: @@ -399,8 +398,7 @@ def load_linked(self, context, path, name): # act = context.selected_objects[0] # better for 2.8 # elif context.object: # act = context.object # assumption of object after linking - conf.log("Identified new obj as: {}".format( - act), vv_only=True) + env.log(f"Identified new obj as: {act}", vv_only=True) if not act: self.report({'ERROR'}, "Could not grab linked in object if any.") @@ -451,7 +449,7 @@ def load_linked(self, context, path, name): # but not clear how this happens print("WARNING: Assigned fallback gl of (0,0,0)") gl = (0, 0, 0) - # cl = util.get_cuser_location(context) + # cl = util.get_cursor_location(context) if self.relocation == "Offset": # do the offset stuff, set active etc @@ -477,7 +475,7 @@ def load_linked(self, context, path, name): {'INFO'}, "This addon works better when the root bone's name is 'MAIN'") -def load_append(self, context, path, name): +def load_append(self, context: Context, path: Path, name: str) -> None: """Append an entire collection/group into this blend file and fix armature. Used for both mob spawning and entity spawning with appropriate handling @@ -488,7 +486,7 @@ def load_append(self, context, path, name): # Could try to recopy thsoe elements, or try re-appending with # renaming of the original group self.report({'ERROR'}, "Group name already exists in local file") - conf.log("Group already appended/is here") + env.log("Group already appended/is here") return path = bpy.path.abspath(path) @@ -506,7 +504,7 @@ def load_append(self, context, path, name): else: raise Exception("No Group or Collection bpy API endpoint") - conf.log(os.path.join(path, subpath) + ', ' + name) + env.log(f"{os.path.join(path, subpath)}, {name}") pregroups = list(util.collections()) res = util.bAppendLink(os.path.join(path, subpath), name, False) postgroups = list(util.collections()) @@ -520,10 +518,10 @@ def load_append(self, context, path, name): return if not new_groups and name in util.collections(): # this is more likely to fail but serves as a fallback - conf.log("Mob spawn: Had to go to fallback group name grab") + env.log("Mob spawn: Had to go to fallback group name grab") grp_added = util.collections()[name] elif not new_groups: - conf.log("Warning, could not detect imported group") + env.log("Warning, could not detect imported group") self.report({'WARNING'}, "Could not detect imported group") return else: @@ -534,8 +532,7 @@ def load_append(self, context, path, name): # group is a subcollection of another name. grp_added = grp - conf.log("Identified collection/group {} as the primary imported".format( - grp_added), vv_only=True) + env.log(f"Identified collection/group {grp_added} as the primary imported", vv_only=True) # if rig not centered in original file, assume its group is if hasattr(grp_added, "dupli_offset"): # 2.7 @@ -543,32 +540,24 @@ def load_append(self, context, path, name): elif hasattr(grp_added, "instance_offset"): # 2.8 gl = grp_added.instance_offset else: - conf.log("Warning, could not set offset for group; null type?") + env.log("Warning, could not set offset for group; null type?") gl = (0, 0, 0) - cl = util.get_cuser_location(context) + cl = util.get_cursor_location(context) # For some reason, adding group objects on its own doesn't work all_objects = context.selected_objects addedObjs = [ob for ob in grp_added.objects] for ob in all_objects: if ob not in addedObjs: - conf.log("This obj not in group {}: {}".format( - grp_added.name, ob.name)) + env.log(f"This obj not in group {grp_added.name}: {ob.name}") # removes things like random bone shapes pulled in, # without deleting them, just unlinking them from the scene util.obj_unlink_remove(ob, False, context) - if not util.bv28(): - grp_added.name = "reload-blend-to-remove-this-empty-group" - for obj in grp_added.objects: - grp_added.objects.unlink(obj) - util.select_set(obj, True) - grp_added.user_clear() - else: - for obj in grp_added.objects: - if obj not in context.view_layer.objects[:]: - continue - util.select_set(obj, True) + for obj in grp_added.objects: + if obj not in context.view_layer.objects[:]: + continue + util.select_set(obj, True) # try: # util.collections().remove(grp_added) @@ -577,14 +566,14 @@ def load_append(self, context, path, name): # pass rig_obj = get_rig_from_objects(addedObjs) if not rig_obj: - conf.log("Could not get rig object") + env.log("Could not get rig object") self.report({'WARNING'}, "No armatures found!") else: - conf.log("Using object as primary rig: " + rig_obj.name) + env.log(f"Using object as primary rig: {rig_obj.name}") try: util.set_active_object(context, rig_obj) except RuntimeError: - conf.log("Failed to set {} as active".format(rig_obj)) + env.log(f"Failed to set {rig_obj} as active") rig_obj = None if rig_obj and self.clearPose or rig_obj and self.relocation == "Offset": @@ -593,7 +582,7 @@ def load_append(self, context, path, name): try: bpy.ops.object.mode_set(mode='POSE') except Exception as e: - self.report({'ERROR'}, "Failed to enter pose mode: " + str(e)) + self.report({'ERROR'}, f"Failed to enter pose mode: {e}") print("Failed to enter pose mode, see logs") print("Exception: ", str(e)) print(bpy.context.object) @@ -618,7 +607,7 @@ def load_append(self, context, path, name): if self.relocation == "Offset" and posemode: set_bone = offset_root_bone(context, rig_obj) if not set_bone: - conf.log( + env.log( "This addon works better when the root bone's name is 'MAIN'") self.report( {'INFO'}, @@ -661,7 +650,7 @@ def execute(self, context): # to prevent re-drawing "load spawners!" if any one of the above # loaded nothing for any reason. - conf.loaded_all_spawners = True + env.loaded_all_spawners = True return {'FINISHED'} @@ -714,23 +703,23 @@ def execute(self, context): class MCPREP_UL_mob(bpy.types.UIList): """For mob asset listing UIList drawing""" def draw_item(self, context, layout, data, set, icon, active_data, active_propname, index): - icon = "mob-{}".format(set.index) + icon = f"mob-{set.index}" if self.layout_type in {'DEFAULT', 'COMPACT'}: - if not conf.use_icons: + if not env.use_icons: layout.label(text=set.name) - elif conf.use_icons and icon in conf.preview_collections["mobs"]: + elif env.use_icons and icon in env.preview_collections["mobs"]: layout.label( text=set.name, - icon_value=conf.preview_collections["mobs"][icon].icon_id) + icon_value=env.preview_collections["mobs"][icon].icon_id) else: layout.label(text=set.name, icon="BLANK1") elif self.layout_type in {'GRID'}: layout.alignment = 'CENTER' - if conf.use_icons and icon in conf.preview_collections["mobs"]: + if env.use_icons and icon in env.preview_collections["mobs"]: layout.label( text="", - icon_value=conf.preview_collections["mobs"][icon].icon_id) + icon_value=env.preview_collections["mobs"][icon].icon_id) else: layout.label(text="", icon='QUESTION') @@ -768,23 +757,23 @@ def draw_item(self, context, layout, data, set, icon, active_data, active_propna class MCPREP_UL_item(bpy.types.UIList): """For item asset listing UIList drawing""" def draw_item(self, context, layout, data, set, icon, active_data, active_propname, index): - icon = "item-{}".format(set.index) + icon = f"item-{set.index}" if self.layout_type in {'DEFAULT', 'COMPACT'}: - if not conf.use_icons: + if not env.use_icons: layout.label(text=set.name) - elif conf.use_icons and icon in conf.preview_collections["items"]: + elif env.use_icons and icon in env.preview_collections["items"]: layout.label( text=set.name, - icon_value=conf.preview_collections["items"][icon].icon_id) + icon_value=env.preview_collections["items"][icon].icon_id) else: layout.label(text=set.name, icon="BLANK1") elif self.layout_type in {'GRID'}: layout.alignment = 'CENTER' - if conf.use_icons and icon in conf.preview_collections["items"]: + if env.use_icons and icon in env.preview_collections["items"]: layout.label( text="", - icon_value=conf.preview_collections["items"][icon].icon_id) + icon_value=env.preview_collections["items"][icon].icon_id) else: layout.label(text="", icon='QUESTION') @@ -792,7 +781,7 @@ def draw_item(self, context, layout, data, set, icon, active_data, active_propna class MCPREP_UL_effects(bpy.types.UIList): """For effects asset listing UIList drawing""" def draw_item(self, context, layout, data, set, icon, active_data, active_propname, index): - icon = "effects-{}".format(set.index) + icon = f"effects-{set.index}" if self.layout_type in {'DEFAULT', 'COMPACT'}: # Add icons based on the type of effect. @@ -803,10 +792,10 @@ def draw_item(self, context, layout, data, set, icon, active_data, active_propna elif set.effect_type == effects.COLLECTION: layout.label(text=set.name, icon=COLL_ICON) elif set.effect_type == effects.IMG_SEQ: - if conf.use_icons and icon in conf.preview_collections["effects"]: + if env.use_icons and icon in env.preview_collections["effects"]: layout.label( text=set.name, - icon_value=conf.preview_collections["effects"][icon].icon_id) + icon_value=env.preview_collections["effects"][icon].icon_id) else: layout.label(text=set.name, icon="RENDER_RESULT") else: @@ -814,10 +803,10 @@ def draw_item(self, context, layout, data, set, icon, active_data, active_propna elif self.layout_type in {'GRID'}: layout.alignment = 'CENTER' - if conf.use_icons and icon in conf.preview_collections["effects"]: + if env.use_icons and icon in env.preview_collections["effects"]: layout.label( text="", - icon_value=conf.preview_collections["effects"][icon].icon_id) + icon_value=env.preview_collections["effects"][icon].icon_id) else: layout.label(text="", icon='QUESTION') @@ -825,47 +814,47 @@ def draw_item(self, context, layout, data, set, icon, active_data, active_propna class MCPREP_UL_material(bpy.types.UIList): """For material library UIList drawing""" def draw_item(self, context, layout, data, set, icon, active_data, active_propname, index): - icon = "material-{}".format(set.index) + icon = f"material-{set.index}" if self.layout_type in {'DEFAULT', 'COMPACT'}: - if not conf.use_icons: + if not env.use_icons: layout.label(text=set.name) - elif conf.use_icons and icon in conf.preview_collections["materials"]: + elif env.use_icons and icon in env.preview_collections["materials"]: layout.label( text=set.name, - icon_value=conf.preview_collections["materials"][icon].icon_id) + icon_value=env.preview_collections["materials"][icon].icon_id) else: layout.label(text=set.name, icon="BLANK1") elif self.layout_type in {'GRID'}: layout.alignment = 'CENTER' - if conf.use_icons and icon in conf.preview_collections["materials"]: + if env.use_icons and icon in env.preview_collections["materials"]: layout.label( text="", - icon_value=conf.preview_collections["materials"][icon].icon_id) + icon_value=env.preview_collections["materials"][icon].icon_id) else: layout.label(text="", icon='QUESTION') class ListMobAssetsAll(bpy.types.PropertyGroup): """For listing hidden group of all mobs, regardless of category""" - description = bpy.props.StringProperty() - category = bpy.props.StringProperty() - mcmob_type = bpy.props.StringProperty() - index = bpy.props.IntProperty(min=0, default=0) # for icon drawing + description: bpy.props.StringProperty() + category: bpy.props.StringProperty() + mcmob_type: bpy.props.StringProperty() + index: bpy.props.IntProperty(min=0, default=0) # for icon drawing class ListMobAssets(bpy.types.PropertyGroup): """For UI drawing of mob assets and holding data""" - description = bpy.props.StringProperty() - category = bpy.props.StringProperty() # category it belongs to - mcmob_type = bpy.props.StringProperty() - index = bpy.props.IntProperty(min=0, default=0) # for icon drawing + description: bpy.props.StringProperty() + category: bpy.props.StringProperty() # category it belongs to + mcmob_type: bpy.props.StringProperty() + index: bpy.props.IntProperty(min=0, default=0) # for icon drawing class ListMeshswapAssets(bpy.types.PropertyGroup): """For UI drawing of meshswap assets and holding data""" - block = bpy.props.StringProperty() # block name only like "fire" - method = bpy.props.EnumProperty( + block: bpy.props.StringProperty() # block name only like "fire" + method: bpy.props.EnumProperty( name="Import method", # Collection intentionally first to be default for operator calls. items=[ @@ -873,39 +862,39 @@ class ListMeshswapAssets(bpy.types.PropertyGroup): ("object", "Object asset", "Object asset"), ] ) - description = bpy.props.StringProperty() + description: bpy.props.StringProperty() class ListEntityAssets(bpy.types.PropertyGroup): """For UI drawing of meshswap assets and holding data""" - entity = bpy.props.StringProperty() # virtual enum, Group/name - description = bpy.props.StringProperty() + entity: bpy.props.StringProperty() # virtual enum, Group/name + description: bpy.props.StringProperty() class ListItemAssets(bpy.types.PropertyGroup): """For UI drawing of item assets and holding data""" # inherited: name - description = bpy.props.StringProperty() - path = bpy.props.StringProperty(subtype='FILE_PATH') - index = bpy.props.IntProperty(min=0, default=0) # for icon drawing + description: bpy.props.StringProperty() + path: bpy.props.StringProperty(subtype='FILE_PATH') + index: bpy.props.IntProperty(min=0, default=0) # for icon drawing class ListModelAssets(bpy.types.PropertyGroup): """For UI drawing of mc model assets and holding data""" - filepath = bpy.props.StringProperty(subtype="FILE_PATH") - description = bpy.props.StringProperty() - # index = bpy.props.IntProperty(min=0, default=0) # icon pulled by name. + filepath: bpy.props.StringProperty(subtype="FILE_PATH") + description: bpy.props.StringProperty() + # index: bpy.props.IntProperty(min=0, default=0) # icon pulled by name. class ListEffectsAssets(bpy.types.PropertyGroup): """For UI drawing for different kinds of effects""" # inherited: name - filepath = bpy.props.StringProperty(subtype="FILE_PATH") - subpath = bpy.props.StringProperty( + filepath: bpy.props.StringProperty(subtype="FILE_PATH") + subpath: bpy.props.StringProperty( description="Collection/particle/nodegroup within this file", default="") - description = bpy.props.StringProperty() - effect_type = bpy.props.EnumProperty( + description: bpy.props.StringProperty() + effect_type: bpy.props.EnumProperty( name="Effect type", items=( (effects.GEO_AREA, 'Geonode area', 'Instance wide-area geonodes effect'), @@ -913,7 +902,7 @@ class ListEffectsAssets(bpy.types.PropertyGroup): (effects.COLLECTION, 'Collection effect', 'Instance pre-animated collection'), (effects.IMG_SEQ, 'Image sequence', 'Instance an animated image sequence effect'), )) - index = bpy.props.IntProperty(min=0, default=0) # for icon drawing + index: bpy.props.IntProperty(min=0, default=0) # for icon drawing # ----------------------------------------------------------------------------- @@ -944,7 +933,6 @@ class ListEffectsAssets(bpy.types.PropertyGroup): def register(): for cls in classes: - util.make_annotations(cls) bpy.utils.register_class(cls) diff --git a/MCprep_addon/tracking.py b/MCprep_addon/tracking.py index ebd635fc..d3020d4c 100644 --- a/MCprep_addon/tracking.py +++ b/MCprep_addon/tracking.py @@ -43,6 +43,7 @@ import textwrap import time from datetime import datetime + from .conf import env except Exception as err: print("[MCPREP Error] Failed tracker module load, invalid import module:") print('\t'+str(err)) @@ -484,18 +485,19 @@ def string_trunc(self, value): class TRACK_OT_toggle_enable_tracking(bpy.types.Operator): """Enabled or disable usage tracking""" - bl_idname = IDNAME+".toggle_enable_tracking" + bl_idname = f"{IDNAME}.toggle_enable_tracking" bl_label = "Toggle opt-in for analytics tracking" - bl_description = "Toggle anonymous usage tracking to help the developers. "+\ - " The only data tracked is what MCprep functions are used, key "+\ - "blender/addon information, and the timestamp of the addon installation" + bl_description = ( + "Toggle anonymous usage tracking to help the developers. " + "The only data tracked is what MCprep functions are used, key blender" + "/addon information, and the timestamp of the addon installation") options = {'REGISTER', 'UNDO'} - tracking = bpy.props.EnumProperty( - items = [('toggle', 'Toggle', 'Toggle operator use tracking'), + tracking: bpy.props.EnumProperty( + items=[('toggle', 'Toggle', 'Toggle operator use tracking'), ('enable', 'Enable', 'Enable operator use tracking'), ('disable', 'Disable', 'Disable operator use tracking (if already on)')], - name = "tracking") + name="tracking") def execute(self, context): if not VALID_IMPORT: @@ -543,13 +545,13 @@ class TRACK_OT_popup_report_error(bpy.types.Operator): bl_label = "MCprep Error, press OK below to send this report to developers" bl_description = "Report error to database, add additional comments for context" - error_report = bpy.props.StringProperty(default="") - comment = bpy.props.StringProperty( + error_report: bpy.props.StringProperty(default="") + comment: bpy.props.StringProperty( default="", maxlen=USER_COMMENT_LENGTH, options={'SKIP_SAVE'}) - action = bpy.props.EnumProperty( + action: bpy.props.EnumProperty( items = [('report', 'Send', 'Send the error report to developers, fully anonymous'), ('ignore', "Don't send", "Ignore this error report")], ) @@ -611,9 +613,9 @@ def draw(self, context): def execute(self, context): # if in headless mode, skip if bpy.app.background: - conf.log("Skip Report logging, running headless") - conf.log("Would have reported:") - #conf.log(self.error_report) + env.log("Skip Report logging, running headless") + env.log("Would have reported:") + #env.log(self.error_report) raise Exception(self.error_report) return {'CANCELLED'} @@ -861,7 +863,7 @@ def wrapper(self, context): elif hasattr(self, "skipUsage") and self.skipUsage is True: return res # skip running usage elif VALID_IMPORT is False: - conf.log("Skipping usage, VALID_IMPORT is False") + env.log("Skipping usage, VALID_IMPORT is False") return try: @@ -884,12 +886,12 @@ def wrapper_safe_handler(self): and Tracker._last_request.get("function") == self.track_function and Tracker._last_request.get("time") + Tracker._debounce > time.time() ): - conf.log("Skipping usage due to debounce") + env.log("Skipping usage due to debounce") run_track = False # If successful completion, run analytics function if relevant if bpy.app.background and run_track: - conf.log("Background mode, would have tracked usage: " + self.track_function) + env.log("Background mode, would have tracked usage: " + self.track_function) elif run_track: param = None exporter = None @@ -926,27 +928,6 @@ def layout_split(layout, factor=0.0, align=False): return layout.split(factor=factor, align=align) -def make_annotations(cls): - """Add annotation attribute to class 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 - - classes = ( TRACK_OT_toggle_enable_tracking, TRACK_OT_popup_feedback, @@ -999,7 +980,7 @@ def register(bl_info): # used to define which server source, not just if's below if VALID_IMPORT: - Tracker.dev = conf.dev # True or False + Tracker.dev = env.dev_build # True or False else: Tracker.dev = False @@ -1015,7 +996,6 @@ def register(bl_info): Tracker.tracking_enabled = True # User accepted on download for cls in classes: - make_annotations(cls) bpy.utils.register_class(cls) # register install diff --git a/MCprep_addon/util.py b/MCprep_addon/util.py old mode 100755 new mode 100644 index c218eab1..a9230d92 --- a/MCprep_addon/util.py +++ b/MCprep_addon/util.py @@ -17,6 +17,8 @@ # ##### END GPL LICENSE BLOCK ##### from subprocess import Popen, PIPE +from typing import List, Optional, Union, Tuple +import enum import json import operator import os @@ -26,9 +28,18 @@ import subprocess import bpy - -from . import conf - +from bpy.types import ( + Preferences, + Context, + Collection, + Material, + Image, + Node, + UILayout +) +from mathutils import Vector, Matrix + +from .conf import env # Commonly used name for an excluded collection in Blender 2.8+ SPAWNER_EXCLUDE = "Spawner Exclude" @@ -38,7 +49,7 @@ # ----------------------------------------------------------------------------- -def apply_colorspace(node, color_enum): +def apply_colorspace(node: Node, color_enum: Tuple) -> None: """Apply color space in a cross compatible way, for version and language. Use enum nomeclature matching Blender 2.8x Default, not 2.7 or other lang @@ -47,7 +58,7 @@ def apply_colorspace(node, color_enum): noncolor_override = None if not node.image: - conf.log("Node has no image applied yet, cannot change colorspace") + env.log("Node has no image applied yet, cannot change colorspace") # For later 2.8, fix images color space user if hasattr(node, "color_space"): # 2.7 and earlier 2.8 versions @@ -64,7 +75,7 @@ def apply_colorspace(node, color_enum): noncolor_override = 'Non-Colour Data' -def nameGeneralize(name): +def nameGeneralize(name: str) -> str: """Get base name from datablock, accounts for duplicates and animated tex.""" if duplicatedDatablock(name) is True: name = name[:-4] # removes .001 @@ -87,12 +98,12 @@ def nameGeneralize(name): return name -def materialsFromObj(obj_list): +def materialsFromObj(obj_list: List[bpy.types.Object]) -> List[Material]: """Gets all materials on input list of objects. Loop over every object, adding each material if not already added """ - mat_list = [] + mat_list:list = [] for obj in obj_list: # also capture obj materials from dupliverts/instances on e.g. empties if hasattr(obj, "dupli_group") and obj.dupli_group: # 2.7 @@ -111,7 +122,7 @@ def materialsFromObj(obj_list): return mat_list -def bAppendLink(directory, name, toLink, active_layer=True): +def bAppendLink(directory: str, name: str, toLink: bool, active_layer: bool=True) -> bool: """For multiple version compatibility, this function generalized appending/linking blender post 2.71 changed to new append/link methods @@ -126,7 +137,7 @@ def bAppendLink(directory, name, toLink, active_layer=True): Returns: true if successful, false if not. """ - conf.log("Appending " + directory + " : " + name, vv_only=True) + env.log(f"Appending {directory} : {name}", vv_only=True) # for compatibility, add ending character if directory[-1] != "/" and directory[-1] != os.path.sep: @@ -134,7 +145,7 @@ def bAppendLink(directory, name, toLink, active_layer=True): if "link_append" in dir(bpy.ops.wm): # OLD method of importing, e.g. in blender 2.70 - conf.log("Using old method of append/link, 2.72 <=", vv_only=True) + env.log("Using old method of append/link, 2.72 <=", vv_only=True) try: bpy.ops.wm.link_append(directory=directory, filename=name, link=toLink) return True @@ -142,32 +153,25 @@ def bAppendLink(directory, name, toLink, active_layer=True): print("bAppendLink", e) return False elif "link" in dir(bpy.ops.wm) and "append" in dir(bpy.ops.wm): - conf.log("Using post-2.72 method of append/link", vv_only=True) + env.log("Using post-2.72 method of append/link", vv_only=True) if toLink: bpy.ops.wm.link(directory=directory, filename=name) - elif bv28(): - try: - bpy.ops.wm.append( - directory=directory, - filename=name) - return True - except RuntimeError as e: - print("bAppendLink", e) - return False else: - conf.log("{} {} {}".format(directory, name, active_layer)) try: bpy.ops.wm.append( directory=directory, - filename=name, - active_layer=active_layer) + filename=name) return True except RuntimeError as e: print("bAppendLink", e) return False -def obj_copy(base, context=None, vertex_groups=True, modifiers=True): +def obj_copy( + base: bpy.types.Object, + context: Optional[Context] = None, + vertex_groups: bool = True, + modifiers: bool = True) -> bpy.types.Object: """Copy an object's data, vertex groups, and modifiers without operators. Input must be a valid object in bpy.data.objects @@ -203,24 +207,26 @@ def obj_copy(base, context=None, vertex_groups=True, modifiers=True): setattr(dest, prop, getattr(mod_src, prop)) return new_ob -def min_bv(version, *, inclusive=True): + +def min_bv(version: Tuple, *, inclusive: bool = True) -> bool: if hasattr(bpy.app, "version"): if inclusive is False: return bpy.app.version > version return bpy.app.version >= version -def bv28(): +def bv28() -> bool: """Check if blender 2.8, for layouts, UI, and properties. """ + env.deprecation_warning() return min_bv((2, 80)) -def bv30(): +def bv30() -> bool: """Check if we're dealing with Blender 3.0""" return min_bv((3, 00)) -def is_atlas_export(context): +def is_atlas_export(context: Context) -> bool: """Check if the selected objects are textureswap/animate tex compatible. Atlas textures are ones where all textures are combined into a single file, @@ -250,7 +256,7 @@ def is_atlas_export(context): return file_types["ATLAS"] == 0 -def face_on_edge(faceLoc): +def face_on_edge(faceLoc: Union[tuple, Vector]) -> bool: """Check if a face is on the boundary between two blocks (local coordinates).""" face_decimals = [loc - loc // 1 for loc in faceLoc] if face_decimals[0] > 0.4999 and face_decimals[0] < 0.501: @@ -262,21 +268,21 @@ def face_on_edge(faceLoc): return False -def randomizeMeshSawp(swap, variations): +def randomizeMeshSwap(swap: str, variations: int) -> str: """Randomization for model imports, add extra statements for exta cases.""" - randi = '' + randi:str = '' if swap == 'torch': randomized = random.randint(0, variations - 1) if randomized != 0: - randi = ".{x}".format(x=randomized) + randi = f".{randomized}" elif swap == 'Torch': randomized = random.randint(0, variations - 1) if randomized != 0: - randi = ".{x}".format(x=randomized) + randi = f".{randomized}" return swap + randi -def duplicatedDatablock(name): +def duplicatedDatablock(name: str) -> bool: """Check if datablock is a duplicate or not, e.g. ending in .00# """ try: if name[-4] != ".": @@ -289,7 +295,7 @@ def duplicatedDatablock(name): return False -def loadTexture(texture): +def loadTexture(texture: str) -> Image: """Load texture once, reusing existing texture if present.""" base = nameGeneralize(bpy.path.basename(texture)) if base in bpy.data.images: @@ -297,34 +303,22 @@ def loadTexture(texture): if base_filepath == bpy.path.abspath(texture): data_img = bpy.data.images[base] data_img.reload() - conf.log("Using already loaded texture", vv_only=True) + env.log("Using already loaded texture", vv_only=True) else: data_img = bpy.data.images.load(texture, check_existing=True) - conf.log("Loading new texture image", vv_only=True) + env.log("Loading new texture image", vv_only=True) else: data_img = bpy.data.images.load(texture, check_existing=True) - conf.log("Loading new texture image", vv_only=True) + env.log("Loading new texture image", vv_only=True) return data_img -def remap_users(old, new): - """Consistent, general way to remap datablock users.""" - # Todo: write equivalent function of user_remap for older blender versions - try: - old.user_remap(new) - return 0 - except: - return "not available prior to blender 2.78" - - -def get_objects_conext(context): +def get_objects_conext(context: Context) -> List[bpy.types.Object]: """Returns list of objects, either from view layer if 2.8 or scene if 2.8""" - if bv28(): - return context.view_layer.objects - return context.scene.objects + return context.view_layer.objects -def link_selected_objects_to_scene(): +def link_selected_objects_to_scene() -> None: """Quick script for linking all objects back into a scene. Not used by addon, but shortcut useful in one-off cases to copy/run code @@ -334,14 +328,14 @@ def link_selected_objects_to_scene(): obj_link_scene(ob) -def open_program(executable): +def open_program(executable: str) -> Union[int, str]: # Open an external program from filepath/executbale executable = bpy.path.abspath(executable) - conf.log("Open program request: " + executable) + env.log(f"Open program request: {executable}") # input could be .app file, which appears as if a folder if not os.path.isfile(executable): - conf.log("File not executable") + env.log("File not executable") if not os.path.isdir(executable): return -1 elif not executable.lower().endswith(".app"): @@ -351,7 +345,7 @@ def open_program(executable): osx_or_linux = platform.system() == "Darwin" osx_or_linux = osx_or_linux or 'linux' in platform.system().lower() if executable.lower().endswith('.exe') and osx_or_linux: - conf.log("Opening program via wine") + env.log("Opening program via wine") p = Popen(['which', 'wine'], stdin=PIPE, stdout=PIPE, stderr=PIPE) stdout, err = p.communicate(b"") has_wine = stdout and not err @@ -359,7 +353,7 @@ def open_program(executable): if has_wine: # wine is installed; this will hang blender until mineways closes. p = Popen(['wine', executable], stdin=PIPE, stdout=PIPE, stderr=PIPE) - conf.log( + env.log( "Opening via wine + direct executable, will hang blender till closed") # communicating with process makes it hang, so trust it works @@ -372,26 +366,26 @@ def open_program(executable): try: # attempt to use blender's built-in method res = bpy.ops.wm.path_open(filepath=executable) if res == {"FINISHED"}: - conf.log("Opened using built in path opener") + env.log("Opened using built in path opener") return 0 else: - conf.log("Did not get finished response: ", str(res)) + env.log("Did not get finished response: ", str(res)) except: - conf.log("failed to open using builtin mehtod") + env.log("failed to open using builtin mehtod") pass if platform.system() == "Darwin" and executable.lower().endswith(".app"): # for mac, if folder, check that it has .app otherwise throw -1 # (right now says will open even if just folder!!) - conf.log("Attempting to open .app via system Open") + env.log("Attempting to open .app via system Open") p = Popen(['open', executable], stdin=PIPE, stdout=PIPE, stderr=PIPE) stdout, err = p.communicate(b"") if err != b"": - return "Error occured while trying to open executable: " + str(err) + return f"Error occured while trying to open executable: {err}" return "Failed to open executable" -def open_folder_crossplatform(folder): +def open_folder_crossplatform(folder: str) -> bool: """Cross platform way to open folder in host operating system.""" folder = bpy.path.abspath(folder) if not os.path.isdir(folder): @@ -406,7 +400,7 @@ def open_folder_crossplatform(folder): try: # windows... untested - subprocess.Popen('explorer "{x}"'.format(x=folder)) + subprocess.Popen('explorer "{folder}"') return True except: pass @@ -424,7 +418,7 @@ def open_folder_crossplatform(folder): return False -def addGroupInstance(group_name, loc, select=True): +def addGroupInstance(group_name: str, loc: Tuple, select: bool=True) -> bpy.types.Object: """Add object instance not working, so workaround function.""" # The built in method fails, bpy.ops.object.group_instance_add(...) # UPDATE: I reported the bug, and they fixed it nearly instantly =D @@ -432,22 +426,19 @@ def addGroupInstance(group_name, loc, select=True): scene = bpy.context.scene ob = bpy.data.objects.new(group_name, None) - if bv28(): - ob.instance_type = 'COLLECTION' - ob.instance_collection = collections().get(group_name) - scene.collection.objects.link(ob) # links to scene collection - else: - ob.dupli_type = 'GROUP' - ob.dupli_group = collections().get(group_name) - scene.objects.link(ob) + + ob.instance_type = 'COLLECTION' + ob.instance_collection = collections().get(group_name) + scene.collection.objects.link(ob) # links to scene collection + ob.location = loc select_set(ob, select) return ob -def load_mcprep_json(): +def load_mcprep_json() -> bool: """Load in the json file, defered so not at addon enable time.""" - path = conf.json_path + path = env.json_path default = { "blocks": { "reflective": [], @@ -465,21 +456,21 @@ def load_mcprep_json(): "make_real": [] } if not os.path.isfile(path): - conf.log("Error, json file does not exist: " + path) - conf.json_data = default + env.log(f"Error, json file does not exist: {path}") + env.json_data = default return False with open(path) as data_file: try: - conf.json_data = json.load(data_file) - conf.log("Successfully read the JSON file") + env.json_data = json.load(data_file) + env.log("Successfully read the JSON file") return True except Exception as err: print("Failed to load json file:") print('\t', err) - conf.json_data = default + env.json_data = default -def ui_scale(): +def ui_scale() -> float: """Returns scale of UI, for width drawing. Compatible down to blender 2.72""" prefs = get_preferences() if not hasattr(prefs, "view"): @@ -492,36 +483,44 @@ def ui_scale(): return 1 -def uv_select(obj, action='TOGGLE'): +class UvSelAct(enum.Enum): + SELECT = 'SELECT' + DESELECT = 'DESELECT' + TOGGLE = 'TOGGLE' + + +def uv_select( + obj: bpy.types.Object, action: UvSelAct = UvSelAct.TOGGLE) -> None: """Direct way to select all UV verts of an object, assumings 1 uv layer. Actions are: SELECT, DESELECT, TOGGLE. """ + # TODO: Use or remove this function, not referenced. if not obj.data.uv_layers.active: return # consider raising error - if action == 'TOGGLE': + if action == UvSelAct.TOGGLE: for face in obj.data.polygons: face.select = not face.select - elif action == 'SELECT': + elif action == UvSelAct.SELECT: for face in obj.data.polygons: # if len(face.loop_indices) < 3: # continue face.select = True - elif action == 'DESELECT': + elif action == UvSelAct.DESELECT: for face in obj.data.polygons: # if len(face.loop_indices) < 3: # continue face.select = False -def move_to_collection(obj, collection): +def move_to_collection(obj: bpy.types.Object, collection: Collection) -> None: """Move out of all collections and into this specified one. 2.8 only""" for col in obj.users_collection: col.objects.unlink(obj) collection.objects.link(obj) -def get_or_create_viewlayer(context, collection_name): +def get_or_create_viewlayer(context: Context, collection_name: str) -> Collection: """Returns or creates the view layer for a given name. 2.8 only. Only searches within same viewlayer; not exact match but a non-case @@ -546,7 +545,7 @@ def get_or_create_viewlayer(context, collection_name): return response_vl -def natural_sort(elements): +def natural_sort(elements: list) -> list: """Use human or natural sorting for subnumbers within string list.""" def convert(text): return int(text) if text.isdigit() else text.lower() @@ -557,7 +556,6 @@ def alphanum_key(key): return sorted(elements, key=alphanum_key) - # ----------------------------------------------------------------------------- # Cross blender 2.7 and 2.8 functions # ----------------------------------------------------------------------------- @@ -565,6 +563,7 @@ def alphanum_key(key): def make_annotations(cls): """Add annotation attribute to class fields to avoid Blender 2.8 warnings""" + env.deprecation_warning() if not hasattr(bpy.app, "version") or bpy.app.version < (2, 80): return cls if bpy.app.version < (2, 93, 0): @@ -584,21 +583,23 @@ def make_annotations(cls): return cls -def layout_split(layout, factor=0.0, align=False): - """Intermediate method for pre and post blender 2.8 split UI function""" +def layout_split(layout: UILayout, factor:float =0.0, align: bool=False) -> UILayout: + """ TODO remove 2.7 + 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""" +def get_user_preferences(context: Optional[Context]=None) -> Optional[Preferences]: + """ + Intermediate method for 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"): + + if hasattr(context, "preferences"): prefs = context.preferences.addons.get(__package__, None) if prefs: return prefs.preferences @@ -607,24 +608,25 @@ def get_user_preferences(context=None): return None -def get_preferences(context=None): - """Function to easily get general user prefs in 2.7 and 2.8 friendly way""" - if hasattr(context, "user_preferences"): - return context.user_preferences - elif hasattr(context, "preferences"): +def get_preferences(context: Optional[Context]=None) -> Optional[Preferences]: + """ + Function to easily get general user prefs friendly way""" + + if hasattr(context, "preferences"): return context.preferences return None -def set_active_object(context, obj): - """Get the active object in a 2.7 and 2.8 compatible way""" +def set_active_object(context: Context, obj: bpy.types.Object) -> None: + """ TODO remove 2.7 + Get the active object in a 2.7 and 2.8 compatible way""" if hasattr(context, "view_layer"): context.view_layer.objects.active = obj # the 2.8 way else: context.scene.objects.active = obj # the 2.7 way -def select_get(obj): +def select_get(obj: bpy.types.Object) -> bool: """Multi version compatibility for getting object selection""" if hasattr(obj, "select_get"): return obj.select_get() @@ -632,7 +634,7 @@ def select_get(obj): return obj.select -def select_set(obj, state): +def select_set(obj: bpy.types.Object, state: bool) -> None: """Multi version compatibility for setting object selection""" if hasattr(obj, "select_set"): obj.select_set(state) @@ -640,7 +642,7 @@ def select_set(obj, state): obj.select = state -def hide_viewport(obj, state): +def hide_viewport(obj: bpy.types.Object, state: bool) -> None: """Multi version compatibility for setting the viewport hide state""" if hasattr(obj, "hide_viewport"): obj.hide_viewport = state # where state is a boolean True or False @@ -648,16 +650,18 @@ def hide_viewport(obj, state): obj.hide = state -def collections(): - """Returns group or collection object for 2.7 and 2.8""" +def collections() -> List[Collection]: + """ TODO remove 2.7 + Returns group or collection object for 2.7 and 2.8""" if hasattr(bpy.data, "collections"): return bpy.data.collections else: return bpy.data.groups -def viewport_textured(context=None): - """Returns state of viewport solid being textured or not""" +def viewport_textured(context: Optional[Context]=None) -> Optional[bool]: + """ TODO remove 2.7 + Returns state of viewport solid being textured or not""" if not context: context = bpy.context @@ -669,7 +673,7 @@ def viewport_textured(context=None): return None # unsure -def get_cuser_location(context=None): +def get_cursor_location(context: Optional[Context]=None) -> tuple: """Returns the location vector of the 3D cursor""" if not context: context = bpy.context @@ -685,7 +689,7 @@ def get_cuser_location(context=None): return (0, 0, 0) -def set_cuser_location(loc, context=None): +def set_cursor_location(loc: Tuple, context: Optional[Context]=None) -> None: """Returns the location vector of the 3D cursor""" if not context: context = bpy.context @@ -695,16 +699,18 @@ def set_cuser_location(loc, context=None): context.scene.cursor.location = loc -def instance_collection(obj): - """Cross compatible way to get an objects dupligroup or collection""" +def instance_collection(obj: bpy.types.Object) -> Collection: + """ TODO 2.7 + Cross compatible way to get an objects dupligroup or collection""" if hasattr(obj, "dupli_group"): return obj.dupli_group elif hasattr(obj, "instance_collection"): return obj.instance_collection -def obj_link_scene(obj, context=None): - """Links object to scene, or for 2.8, the scene master collection""" +def obj_link_scene(obj: bpy.types.Object, context: Optional[Context]=None): + """ TODO 2.7 + Links object to scene, or for 2.8, the scene master collection""" if not context: context = bpy.context if hasattr(context.scene.objects, "link"): @@ -714,7 +720,7 @@ def obj_link_scene(obj, context=None): # context.scene.update() # needed? -def obj_unlink_remove(obj, remove, context=None): +def obj_unlink_remove(obj: bpy.types.Object, remove: bool, context: Optional[Context]=None) -> None: """Unlink an object from the scene, and remove from data if specified""" if not context: context = bpy.context @@ -733,31 +739,27 @@ def obj_unlink_remove(obj, remove, context=None): bpy.data.objects.remove(obj) -def users_collection(obj): - """Returns the collections/group of an object""" +def users_collection(obj: bpy.types.Object) -> List[Collection]: + """ TODO 2.7 + Returns the collections/group of an object""" if hasattr(obj, "users_collection"): return obj.users_collection elif hasattr(obj, "users_group"): return obj.users_group -def matmul(v1, v2, v3=None): +def matmul(v1: Union[Vector, Matrix], v2: Union[Vector, Matrix], v3: Optional[Union[Vector, Matrix]]=None): """Multiplciation of matrix and/or vectors in cross compatible way. This is a workaround for the syntax that otherwise could be used a @ b. """ - if bv28(): - # does not exist pre 2.7<#?>, syntax error - mtm = getattr(operator, "matmul") - if v3: - return mtm(v1, mtm(v2, v3)) - return mtm(v1, v2) + mtm = getattr(operator, "matmul") if v3: - return v1 * v2 * v3 - return v1 * v2 + return mtm(v1, mtm(v2, v3)) + return mtm(v1, v2) -def scene_update(context=None): +def scene_update(context: Optional[Context] = None) -> None: """Update scene in cross compatible way, to update desp graph""" if not context: context = bpy.context @@ -767,12 +769,12 @@ def scene_update(context=None): context.view_layer.update() -def move_assets_to_excluded_layer(context, collections): +def move_assets_to_excluded_layer(context: Context, collections: List[Collection]) -> None: """Utility to move source collections to excluded layer to not be rendered""" - initial_view_coll = context.view_layer.active_layer_collection + initial_view_coll:Collection = context.view_layer.active_layer_collection # Then, setup the exclude view layer - spawner_exclude_vl = get_or_create_viewlayer( + spawner_exclude_vl:Collection = get_or_create_viewlayer( context, SPAWNER_EXCLUDE) spawner_exclude_vl.exclude = True @@ -780,4 +782,4 @@ def move_assets_to_excluded_layer(context, collections): if grp.name not in initial_view_coll.collection.children: continue # not linked, likely a sub-group not added to scn spawner_exclude_vl.collection.children.link(grp) - initial_view_coll.collection.children.unlink(grp) + initial_view_coll.collection.children.unlink(grp) \ No newline at end of file diff --git a/MCprep_addon/util_operators.py b/MCprep_addon/util_operators.py index 335d6526..06a65950 100644 --- a/MCprep_addon/util_operators.py +++ b/MCprep_addon/util_operators.py @@ -68,18 +68,8 @@ def execute(self, context): pass # now change the active drawing level to a minimum of solid mode - view27 = ['TEXTURED', 'MATEIRAL', 'RENDERED'] view28 = ['SOLID', 'MATERIAL', 'RENDERED'] - engine = bpy.context.scene.render.engine - if not util.bv28() and view.viewport_shade not in view27: - if not hasattr(context.space_data, "viewport_shade"): - self.report({"WARNING"}, "Improve UI is meant for the 3D view") - return {'FINISHED'} - if engine == 'CYCLES': - view.viewport_shade = 'TEXTURED' - else: - view.viewport_shade = 'SOLID' - elif util.bv28() and context.scene.display.shading.type not in view28: + if context.scene.display.shading.type not in view28: if not scn_disp or not scn_disp_shade: self.report({"WARNING"}, "Improve UI is meant for the 3D view") return {'FINISHED'} @@ -94,7 +84,7 @@ class MCPREP_OT_show_preferences(bpy.types.Operator): bl_idname = "mcprep.open_preferences" bl_label = "Show MCprep preferences" - tab = bpy.props.EnumProperty( + tab: bpy.props.EnumProperty( items=[ ('settings', 'Open settings', 'Open MCprep preferences settings'), ('tutorials', 'Open tutorials', 'View MCprep tutorials'), @@ -119,7 +109,6 @@ def execute(self, context): if not addon_blinfo["show_expanded"]: has_prefs = hasattr(bpy.ops, "preferences") has_prefs = has_prefs and hasattr(bpy.ops.preferences, "addon_expand") - has_prefs = has_prefs and util.bv28() has_exp = hasattr(bpy.ops, "wm") has_exp = has_exp and hasattr(bpy.ops.wm, "addon_expand") @@ -142,7 +131,7 @@ class MCPREP_OT_open_folder(bpy.types.Operator): bl_idname = "mcprep.openfolder" bl_label = "Open folder" - folder = bpy.props.StringProperty( + folder: bpy.props.StringProperty( name="Folderpath", default="//") @@ -171,7 +160,7 @@ class MCPREP_OT_open_help(bpy.types.Operator): bl_label = "Open help page" bl_description = "Need help? Click to open a reference page" - url = bpy.props.StringProperty( + url: bpy.props.StringProperty( name="Url", default="") @@ -190,11 +179,11 @@ class MCPREP_OT_prep_material_legacy(bpy.types.Operator): bl_label = "MCprep Materials" bl_options = {'REGISTER', 'UNDO'} - useReflections = bpy.props.BoolProperty( + useReflections: bpy.props.BoolProperty( name="Use reflections", description="Allow appropriate materials to be rendered reflective", default=True) - combineMaterials = bpy.props.BoolProperty( + combineMaterials: bpy.props.BoolProperty( name="Combine materials", description="Consolidate duplciate materials & textures", default=False) @@ -235,7 +224,6 @@ def execute(self, context): def register(): for cls in classes: - util.make_annotations(cls) bpy.utils.register_class(cls) diff --git a/MCprep_addon/world_tools.py b/MCprep_addon/world_tools.py index 5dec4ba2..fd598020 100644 --- a/MCprep_addon/world_tools.py +++ b/MCprep_addon/world_tools.py @@ -19,12 +19,14 @@ import os import math from pathlib import Path +from typing import List, Optional import shutil import bpy +from bpy.types import Context, Camera from bpy_extras.io_utils import ExportHelper, ImportHelper -from . import conf +from .conf import env, VectorType from . import util from . import tracking from .materials import generate @@ -46,7 +48,7 @@ time_obj_cache = None -def get_time_object(): +def get_time_object() -> None: """Returns the time object if present in the file""" global time_obj_cache # to avoid re parsing every time @@ -77,9 +79,12 @@ class ObjHeaderOptions: """Wrapper functions to avoid typos causing issues.""" def __init__(self): - self._exporter = None - self._file_type = None - + self._exporter: Optional[str] = None + self._file_type: Optional[str] = None + + """ + Wrapper functions to avoid typos causing issues + """ def set_mineways(self): self._exporter = "Mineways" @@ -108,7 +113,7 @@ def texture_type(self): obj_header = ObjHeaderOptions() -def detect_world_exporter(filepath): +def detect_world_exporter(filepath: Path) -> None: """Detect whether Mineways or jmc2obj was used, based on prefix info. Primary heruistic: if detect Mineways header, assert Mineways, else @@ -144,7 +149,7 @@ def detect_world_exporter(filepath): obj_header.set_seperated() return except UnicodeDecodeError: - print("failed to read first line of obj: " + filepath) + print(f"Failed to read first line of obj: {filepath}") return obj_header.set_jmc2obj() # Since this is the default for Jmc2Obj, @@ -243,6 +248,31 @@ def convert_mtl(filepath): return True +def enble_obj_importer() -> Optional[bool]: + """Checks if obj import is avail and tries to activate if not. + + If we fail to enable obj importing, return false. True if enabled, and Non + if nothing changed. + """ + enable_addon = None + if util.min_bv((4, 0)): + return None # No longer an addon, native built in. + else: + in_import_scn = "obj_import" not in dir(bpy.ops.wm) + in_wm = "" + if not in_import_scn and not in_wm: + enable_addon = "io_scene_obj" + + if enable_addon is None: + return None + + try: + bpy.ops.preferences.addon_enable(module=enable_addon) + return True + except RuntimeError: + return False + + # ----------------------------------------------------------------------------- # open mineways/jmc2obj related # ----------------------------------------------------------------------------- @@ -255,7 +285,7 @@ class MCPREP_OT_open_jmc2obj(bpy.types.Operator): bl_description = "Open the jmc2obj executbale" # poll, and prompt to download if not present w/ tutorial link - skipUsage = bpy.props.BoolProperty( + skipUsage: bpy.props.BoolProperty( default=False, options={'HIDDEN'}) @@ -333,7 +363,7 @@ class MCPREP_OT_open_mineways(bpy.types.Operator): bl_description = "Open the Mineways executbale" # poll, and prompt to download if not present w/ tutorial link - skipUsage = bpy.props.BoolProperty( + skipUsage: bpy.props.BoolProperty( default=False, options={'HIDDEN'}) @@ -416,11 +446,11 @@ class MCPREP_OT_import_world_split(bpy.types.Operator, ImportHelper): bl_label = "Import World" bl_options = {'REGISTER', 'UNDO'} - filter_glob = bpy.props.StringProperty( + filter_glob: bpy.props.StringProperty( default="*.obj;*.mtl", options={'HIDDEN'}) fileselectparams = "use_filter_blender" - skipUsage = bpy.props.BoolProperty( + skipUsage: bpy.props.BoolProperty( default=False, options={'HIDDEN'}) @@ -444,15 +474,16 @@ def execute(self, context): self.report({"ERROR"}, "You must select a .obj file to import") return {'CANCELLED'} - if "obj" not in dir(bpy.ops.import_scene): - try: - bpy.ops.preferences.addon_enable(module="io_scene_obj") - self.report( - {"INFO"}, - "FYI: had to enable OBJ imports in user preferences") - except RuntimeError: - self.report({"ERROR"}, "Built-in OBJ importer could not be enabled") - return {'CANCELLED'} + res = enble_obj_importer() + if res is None: + pass + elif res is True: + self.report( + {"INFO"}, + "FYI: had to enable OBJ imports in user preferences") + elif res is False: + self.report({"ERROR"}, "Built-in OBJ importer could not be enabled") + return {'CANCELLED'} # There are a number of bug reports that come from the generic call # of obj importing. If this fails, should notify the user to try again @@ -567,8 +598,7 @@ def execute(self, context): obj["MCPREP_OBJ_HEADER"] = True obj["MCPREP_OBJ_FILE_TYPE"] = obj_header.texture_type() - if util.bv28(): - self.split_world_by_material(context) + self.split_world_by_material(context) addon_prefs = util.get_user_preferences(context) self.track_exporter = addon_prefs.MCprep_exporter_type # Soft detect. @@ -587,7 +617,7 @@ def obj_name_to_material(self, obj): return obj.name = util.nameGeneralize(mat.name) - def split_world_by_material(self, context): + def split_world_by_material(self, context: Context) -> None: """2.8-only function, split combined object into parts by material""" world_name = os.path.basename(self.filepath) world_name = os.path.splitext(world_name)[0] @@ -595,11 +625,9 @@ def split_world_by_material(self, context): # Create the new world collection prefs = util.get_user_preferences(context) if prefs is not None and prefs.MCprep_exporter_type != '(choose)': - name = "{} world: {}".format( - prefs.MCprep_exporter_type, - world_name) + name = f"{prefs.MCprep_exporter_type} world: {world_name}" else: - name = "minecraft_world: " + world_name + name = f"minecraft_world: {world_name}" worldg = util.collections().new(name=name) context.scene.collection.children.link(worldg) # Add to outliner. @@ -618,7 +646,7 @@ class MCPREP_OT_prep_world(bpy.types.Operator): bl_description = "Prep world render settings to something generally useful" bl_options = {'REGISTER', 'UNDO'} - skipUsage = bpy.props.BoolProperty( + skipUsage: bpy.props.BoolProperty( default=False, options={'HIDDEN'}) @@ -640,7 +668,7 @@ def execute(self, context): self.report({'ERROR'}, "Must be cycles, eevee, or blender internal") return {'FINISHED'} - def prep_world_cycles(self, context): + def prep_world_cycles(self, context: Context) -> None: if not context.scene.world or not context.scene.world.use_nodes: context.scene.world.use_nodes = True @@ -658,7 +686,11 @@ def prep_world_cycles(self, context): world_links.new(skynode.outputs["Color"], background.inputs[0]) world_links.new(background.outputs["Background"], output.inputs[0]) - context.scene.world.light_settings.use_ambient_occlusion = False + if hasattr(context.scene.world.light_settings, "use_ambient_occlusion"): + # pre 4.0 + context.scene.world.light_settings.use_ambient_occlusion = False + else: + print("Unable to disbale use_ambient_occlusion") if hasattr(context.scene, "cycles"): context.scene.cycles.caustics_reflective = False @@ -674,7 +706,7 @@ def prep_world_cycles(self, context): context.scene.cycles.ao_bounces = 2 context.scene.cycles.ao_bounces_render = 2 - def prep_world_eevee(self, context): + def prep_world_eevee(self, context: Context) -> None: """Default world settings for Eevee rendering""" if not context.scene.world or not context.scene.world.use_nodes: context.scene.world.use_nodes = True @@ -705,8 +737,13 @@ def prep_world_eevee(self, context): background_camera.outputs["Background"], mix_shader.inputs[2]) world_links.new(mix_shader.outputs["Shader"], output.inputs[0]) - # is not great - context.scene.world.light_settings.use_ambient_occlusion = False + # Increase render speeds by disabling ambienet occlusion. + if hasattr(context.scene.world.light_settings, "use_ambient_occlusion"): + # pre 4.0 + context.scene.world.light_settings.use_ambient_occlusion = False + else: + print("Unable to disbale use_ambient_occlusion") + if hasattr(context.scene, "cycles"): context.scene.cycles.caustics_reflective = False context.scene.cycles.caustics_refractive = False @@ -745,37 +782,23 @@ def prep_world_internal(self, context): sky_used = True break if sky_used: - conf.log("MCprep sky being used with atmosphere") + env.log("MCprep sky being used with atmosphere") context.scene.world.use_sky_blend = False context.scene.world.horizon_color = (0.00938029, 0.0125943, 0.0140572) else: - conf.log("No MCprep sky with atmosphere") + env.log("No MCprep sky with atmosphere") context.scene.world.use_sky_blend = True context.scene.world.horizon_color = (0.647705, 0.859927, 0.940392) context.scene.world.zenith_color = (0.0954261, 0.546859, 1) -class MCPREP_OT_add_mc_world(bpy.types.Operator): - """Please used the new operator, mcprep.add_mc_sky""" - bl_idname = "mcprep.add_mc_world" - bl_label = "Create MC World" - bl_options = {'REGISTER', 'UNDO'} - - track_function = "world_time" - track_param = "Deprecated" - @tracking.report_error - def execute(self, context): - self.report({"ERROR"}, "Use the new operator, mcprep.add_mc_sky") - return {'CANCELLED'} - - class MCPREP_OT_add_mc_sky(bpy.types.Operator): """Add sun lamp and time of day (dynamic) driver, setup sky with sun and moon""" bl_idname = "mcprep.add_mc_sky" bl_label = "Create MC Sky" bl_options = {'REGISTER', 'UNDO'} - def enum_options(self, context): + def enum_options(self, context: Context) -> List[tuple]: """Dynamic set of enums to show based on engine""" engine = bpy.context.scene.render.engine enums = [] @@ -802,13 +825,13 @@ def enum_options(self, context): "Create static sky, with no sun or moon")) return enums - world_type = bpy.props.EnumProperty( + world_type: bpy.props.EnumProperty( name="Sky type", description=( "Decide to improt dynamic (time/hour-controlled) vs static sky " "(daytime only), and the type of sun/moon (if any) to use"), items=enum_options) - initial_time = bpy.props.EnumProperty( + initial_time: bpy.props.EnumProperty( name="Set time (dynamic only)", description="Set initial time of day, only supported for dynamic sky types", items=( @@ -818,11 +841,11 @@ def enum_options(self, context): ("0", "Midnight", "Set initial time to 12am"), ("6", "Sunrise", "Set initial time to 6am")) ) - add_clouds = bpy.props.BoolProperty( + add_clouds: bpy.props.BoolProperty( name="Add clouds", description="Add in a cloud mesh", default=True) - remove_existing_suns = bpy.props.BoolProperty( + remove_existing_suns: bpy.props.BoolProperty( name="Remove initial suns", description="Remove any existing sunlamps", default=True) @@ -890,9 +913,9 @@ def execute(self, context): if not os.path.isfile(blendfile): self.report( {'ERROR'}, - "Source MCprep world blend file does not exist: " + blendfile) - conf.log( - "Source MCprep world blend file does not exist: " + blendfile) + f"Source MCprep world blend file does not exist: {blendfile}") + env.log( + f"Source MCprep world blend file does not exist: {blendfile}") return {'CANCELLED'} if wname in bpy.data.worlds: prev_world = bpy.data.worlds[wname] @@ -924,18 +947,18 @@ def execute(self, context): time_obj = get_time_object() if not time_obj: - conf.log( + env.log( "TODO: implement create time_obj, parent sun to it & driver setup") if self.world_type in ("world_static_mesh", "world_mesh"): if not os.path.isfile(blendfile): self.report( {'ERROR'}, - "Source MCprep world blend file does not exist: " + blendfile) - conf.log( - "Source MCprep world blend file does not exist: " + blendfile) + f"Source MCprep world blend file does not exist: {blendfile}") + env.log( + f"Source MCprep world blend file does not exist: {blendfile}") return {'CANCELLED'} - resource = blendfile + "/Object" + resource = f"{blendfile}/bpy.types.Object" util.bAppendLink(resource, "MoonMesh", False) non_empties = [ @@ -992,7 +1015,7 @@ def execute(self, context): self.track_param = engine return {'FINISHED'} - def create_sunlamp(self, context): + def create_sunlamp(self, context: Context) -> bpy.types.Object: """Create new sun lamp from primitives""" if hasattr(bpy.data, "lamps"): # 2.7 newlamp = bpy.data.lamps.new("Sun", "SUN") @@ -1011,7 +1034,7 @@ def create_sunlamp(self, context): obj.use_contact_shadow = True return obj - def create_dynamic_world(self, context, blendfile, wname): + def create_dynamic_world(self, context: Context, blendfile: Path, wname: str) -> List[bpy.types.Object]: """Setup fpr creating a dynamic world and setting up driver targets""" resource = blendfile + "/World" obj_list = [] @@ -1021,7 +1044,7 @@ def create_dynamic_world(self, context, blendfile, wname): try: util.obj_unlink_remove(time_obj_cache, True, context) except Exception as e: - print("Error, could not unlink time_obj_cache " + str(time_obj_cache)) + print(f"Error, could not unlink time_obj_cache {time_obj_cache}") print(e) time_obj_cache = None # force reset to use newer cache object @@ -1035,7 +1058,7 @@ def create_dynamic_world(self, context, blendfile, wname): context.scene.world["mcprep_world"] = True else: self.report({'ERROR'}, "Failed to import new world") - conf.log("Failed to import new world") + env.log("Failed to import new world") # assign sun/moon shader accordingly use_shader = 1 if self.world_type == "world_shader" else 0 @@ -1057,23 +1080,22 @@ def create_dynamic_world(self, context, blendfile, wname): # if needed: create time object and setup drivers # if not time_obj: - # conf.log("Creating time_obj") - # time_obj = bpy.data.objects.new('MCprep Time Control', None) - # util.obj_link_scene(time_obj, context) - # global time_obj_cache - # time_obj_cache = time_obj - # if hasattr(time_obj, "empty_draw_type"): # 2.7 - # time_obj.empty_draw_type = 'SPHERE' - # else: # 2.8 - # time_obj.empty_display_type = 'SPHERE' - + # env.log("Creating time_obj") + # time_obj = bpy.data.objects.new('MCprep Time Control', None) + # util.obj_link_scene(time_obj, context) + # global time_obj_cache + # time_obj_cache = time_obj + # if hasattr(time_obj, "empty_draw_type"): # 2.7 + # time_obj.empty_draw_type = 'SPHERE' + # else: # 2.8 + # time_obj.empty_display_type = 'SPHERE' # first, get the driver # if (not world.node_tree.animation_data - # or not world.node_tree.animation_data.drivers - # or not world.node_tree.animation_data.drivers[0].driver): - # conf.log("Could not get driver from imported dynamic world") - # self.report({'WARNING'}, "Could not update driver for dynamic world") - # driver = None + # or not world.node_tree.animation_data.drivers + # or not world.node_tree.animation_data.drivers[0].driver): + # env.log("Could not get driver from imported dynamic world") + # self.report({'WARNING'}, "Could not update driver for dynamic world") + # driver = None # else: # driver = world.node_tree.animation_data.drivers[0].driver # if driver and driver.variables[0].targets[0].id_type == 'OBJECT': @@ -1090,7 +1112,7 @@ class MCPREP_OT_time_set(bpy.types.Operator): bl_options = {'REGISTER', 'UNDO'} # subject center to place lighting around - time_enum = bpy.props.EnumProperty( + time_enum: bpy.props.EnumProperty( name="Time selection", description="Select between the different reflections", items=[ @@ -1104,7 +1126,7 @@ class MCPREP_OT_time_set(bpy.types.Operator): ("18000", "Midnight", "Time=18,000, moon at zenish"), ("23000", "Sunrise", "Time set day=23,000, sun first visible") ]) - day_offset = bpy.props.IntProperty( + day_offset: bpy.props.IntProperty( name="Day offset", description="Offset by number of days (ie +/- 24000*n)", default=0) @@ -1155,7 +1177,7 @@ class MCPREP_OT_render_helper(): def cleanup_scene(self): # Clean up - conf.log("Cleanup pano rendering") + env.log("Cleanup pano rendering") for i in range(len(self.render_queue_cleanup)): util.obj_unlink_remove(self.render_queue_cleanup[i]["camera"], True) @@ -1182,7 +1204,7 @@ def cleanup_scene(self): if self.open_folder: bpy.ops.mcprep.openfolder(folder=self.filepath) - def create_panorama_cam(self, name, camera_data, rot, loc): + def create_panorama_cam(self, name: str, camera_data: Camera, rot: VectorType, loc: VectorType) -> bpy.types.Object: """Create a camera""" camera = bpy.data.objects.new(name, camera_data) @@ -1191,12 +1213,12 @@ def create_panorama_cam(self, name, camera_data, rot, loc): util.obj_link_scene(camera) return camera - def cancel_render(self, scene): - conf.log("Cancelling pano render queue") + def cancel_render(self, scene) -> None: + env.log("Cancelling pano render queue") self.render_queue = [] self.cleanup_scene() - def display_current(self, use_rendered=False): + def display_current(self, use_rendered: bool=False) -> None: """Display the most recent image in a window.""" if self.rendered_count == 0: bpy.ops.render.view_show("INVOKE_DEFAULT") @@ -1213,12 +1235,11 @@ def display_current(self, use_rendered=False): return if self.rendering: - header_text = "Pano render in progress: {}/6 done".format( - self.rendered_count) + header_text = f"Pano render in progress: {self.rendered_count}/6 done" else: header_text = "Pano render finished" - conf.log(header_text) + env.log(header_text) area.header_text_set(header_text) area.show_menus = False @@ -1248,18 +1269,19 @@ def render_next_in_queue(self, scene, dummy): self.prior_frame = self.current_render if not self.render_queue: - conf.log("Finished pano render queue") + env.log("Finished pano render queue") self.cleanup_scene() return self.current_render = self.render_queue.pop() + file_name = self.current_render["filename"] bpy.context.scene.camera = self.current_render["camera"] bpy.context.scene.render.filepath = os.path.join( - self.filepath, self.current_render["filename"]) + self.filepath, file_name) - conf.log("Starting pano render {}".format(self.current_render["filename"])) + env.log(f"Starting pano render {file_name}") self.display_current() bpy.app.timers.register( @@ -1269,13 +1291,13 @@ def render_next_in_queue(self, scene, dummy): render_helper = MCPREP_OT_render_helper() -def init_render_timer(): +def init_render_timer() -> None: """Helper for pano renders to offset the start of the queue from op run.""" - conf.log("Initial render timer started pano queue") + env.log("Initial render timer started pano queue") render_helper.render_next_in_queue(None, None) -def render_pano_frame_timer(): +def render_pano_frame_timer() -> None: """Pano render timer callback, giving a chance to refresh display.""" bpy.ops.render.render( 'EXEC_DEFAULT', write_still=True, use_viewport=False) @@ -1288,17 +1310,17 @@ class MCPREP_OT_render_panorama(bpy.types.Operator, ExportHelper): bl_description = "Render Panorama for texture Pack" bl_options = {'REGISTER', 'UNDO'} - panorama_resolution = bpy.props.IntProperty( + panorama_resolution: bpy.props.IntProperty( name="Render resolution", description="The resolution of the output images", default=1024 ) - open_folder = bpy.props.BoolProperty( + open_folder: bpy.props.BoolProperty( name="Open folder when done", description="Open the output folder when render completes", default=False) - filepath = bpy.props.StringProperty(subtype='DIR_PATH') + filepath: bpy.props.StringProperty(subtype='DIR_PATH') filename_ext = "" # Not used, but required by ExportHelper. def draw(self, context): @@ -1381,7 +1403,6 @@ def execute(self, context): MCPREP_OT_open_mineways, MCPREP_OT_install_mineways, MCPREP_OT_prep_world, - MCPREP_OT_add_mc_world, MCPREP_OT_add_mc_sky, MCPREP_OT_time_set, MCPREP_OT_import_world_split, @@ -1391,7 +1412,6 @@ def execute(self, context): def register(): for cls in classes: - util.make_annotations(cls) bpy.utils.register_class(cls) diff --git a/README.md b/README.md index 884bef65..b46fe8aa 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,10 @@ MCprep Addon ![MCprep Stars](https://img.shields.io/github/stars/TheDuckCow/MCprep) [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/TheDuckCow/MCprep/blob/master/CONTRIBUTING.md) - MCprep is an addon dedicated to speeding up the workflow of Minecraft animators in Blender by automatically fixing up materials and providing other tools such as mob spawing, effects spawning, etc. ## Installing MCprep -### Click the link below and download the .zip file (re-zip if auto-unzipped into a folder necessary), install into blender (2.78 through 3.5 supported) +### Click the link below and download the .zip file (re-zip if auto-unzipped into a folder necessary), install into blender (2.80 through 3.5 supported) [![Install MCprep](/visuals/mcprep_download.png)](https://theduckcow.com/dev/blender/mcprep-download/) @@ -21,10 +20,6 @@ It should remain a zip folder. In blender, go to preferences, then the addons ta *Again, please download from the link above or the releases page, __not__ by clicking `download zip` button above.* -*The preferences panel should look like this after installing the zip file* -![Installed MCprep 2.7](/visuals/install.png?raw=true) - -*Or, if using Blender 2.8, follow these similar steps* ![Installed MCprep 2.8](/visuals/install_28.png?raw=true) **If you like the addon, [please consider donating](http://bit.ly/donate2TheDuckCow) for the continued quality development! [Share this addon](https://twitter.com/intent/tweet?text=Make+easier+Minecraft+renders+using+the+MCprep+addon+bit.ly/MCprep+by+@TheDuckCow) so others can benefit from it! Or help by [taking this quick survey](http://bit.ly/MCprepSurvey)!** diff --git a/bpy-build.yaml b/bpy-build.yaml new file mode 100644 index 00000000..8ef9a0e1 --- /dev/null +++ b/bpy-build.yaml @@ -0,0 +1,23 @@ +addon_folder: MCprep_addon +build_name: MCprep_addon + +install_versions: + - '4.0' + - '3.6' + - '3.5' + - '3.4' + - '3.3' + - '3.2' + - '3.1' + - '3.0' + - '2.93' + - '2.90' + - '2.80' + +# Run using a command like: bpy-addon-build --during-build dev +during_build: + default: + - [] # Re-enable later: [flake8 --extend-ignore W191 .] + dev: + - create_file("mcprep_dev.txt") + diff --git a/compile.bat b/compile.bat deleted file mode 100644 index d4e5dbb4..00000000 --- a/compile.bat +++ /dev/null @@ -1,84 +0,0 @@ -:: Script to automatically install an addon to multiple blender versions. -@echo off - -:: Name for subfolders as well as the install folder under \addons\ -set NAME=MCprep_addon - -:: One line per blender roaming folder, e.g: -:: C:\Users\...\AppData\Roaming\Blender Foundation\Blender\3.0\scripts\addons\ -set BLENDER_INSTALLS=blender_installs.txt - -:: Run the main build sequence. -call:detect_installs -call:build -call:install_all -call:clean - -:: Exit before function definitions. -EXIT /B %ERRORLEVEL% - - -:: Function definitions ------------------------------------------------------- - -:: Cleanup generated files -:clean -echo Cleaning up files -rd /s /q "build\%NAME%" -goto:eof - - -:: Create a local build zip of the local addon. -:build -echo Running build -call:clean -if not exist "build" ( - mkdir "build" -) -mkdir "build\%NAME%" - -copy MCprep_addon\*.py "build\%NAME%\" -copy MCprep_addon\*.txt "build\%NAME%\" -xcopy /e /k /q MCprep_addon\icons\ "build\%NAME%\icons\" -xcopy /e /k /q MCprep_addon\materials\ "build\%NAME%\materials\" -xcopy /e /k /q MCprep_addon\spawner\ "build\%NAME%\spawner\" -xcopy /e /k /q MCprep_addon\MCprep_resources\ "build\%NAME%\MCprep_resources\" - -:: Built in command for windwos 10+ -cd build -tar -a -cf "%NAME%.zip" -c "%NAME%" . -cd .. -echo Finished zipping, check for errors -goto:eof - - -:: Detect if the installs script is present. -:detect_installs -if not exist %BLENDER_INSTALLS% ( - echo The blender_installs.txt file is missing - please create it! - EXIT /B -) -goto:eof - -:: Install addon to a specific path (addons folder), delete prior install. -:install_path -set installpath=%~1 -echo Installing addon to: %installpath% -rd /s /q "%installpath%\%NAME%" -mkdir "%installpath%\%NAME%" -xcopy /e /k /q "build\%NAME%\" "%installpath%\%NAME%\" /Y -goto:eof - - -:: Install addon to all paths defined in config file. -:install_all -echo Installing addon to all files in blender_installs.txt -set "file=%BLENDER_INSTALLS%" - -for /F "usebackq delims=" %%a in ("%file%") do ( - call:install_path "%%a" -) -goto:eof - - -@echo on -EXIT /B 0 \ No newline at end of file diff --git a/compile.sh b/compile.sh deleted file mode 100755 index ed9b9088..00000000 --- a/compile.sh +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env bash -# -# Compile the addon into a zip and install into blender (if available). -# Will install into blender if addons paths defined by blender_installs.txt -# which should have a path that ends in e.g. Blender/2.90/scripts/addons - -# To skip zipping and do a fast reload - -if [ -z "$1" ] || [ "$1" != "-fast" ] -then - echo "Running a slow compile" - FAST_RELOAD=false -else - echo "Running a fast compile" - FAST_RELOAD=true -fi - -NAME=MCprep_addon -BLENDER_INSTALLS=blender_installs.txt - - -# Remove left over files in the build folder, but leaves the zip. -function clean(){ - echo "Cleaning build folder" - rm -r build/$NAME/ -} - -# Create a local build zip of the local addon. -function build() { - # If fast reloading, don't clean up old files. - # if [ "$FAST_RELOAD" = false ] - # then - # clean - # fi - - clean - - # Create the build dir as needed - if [ ! -d build ] - then - mkdir -p build - fi - mkdir -p build/$NAME - - # Specific files and subfolders to copy into the MCprep build - echo "Creating local build" - - cp $NAME/*.py build/$NAME/ - cp $NAME/*.txt build/$NAME/ - cp -r $NAME/icons build/$NAME/ - cp -r $NAME/materials build/$NAME/ - cp -r $NAME/spawner build/$NAME/ - - - if [ "$FAST_RELOAD" = false ] - then - echo "Copying resources and building zip" - # These resources are the most intense to reload, so don't do if 'fast' - cp -r $NAME/MCprep_resources build/$NAME/ - - # Making the zip with all the sub files is also slow. - cd build - rm $NAME.zip # Compeltely remove old version (else it's append/replace) - zip $NAME.zip -rq $NAME - cd ../ - fi - -} - - -# Autogenerate the blender_installs.txt file if missing, -# populating the highest versions of blender in the file first. -# Note: For every version of blender included, one install will be made -# and potentially tested by run_tests.sh. -function detect_installs() { - if [ ! -f "$BLENDER_INSTALLS" ] - then - echo "Generating new $BLENDER_INSTALLS" - - if [ "$(uname)" == "Darwin" ] - then - # Add all - ls -rd -- /Users/*/Library/Application\ Support/Blender/*/scripts/addons/ > $BLENDER_INSTALLS - elif [ "$(expr substr $(uname -s) 1 5)" == "Linux" ] - then - echo "TODO support platform, manually populate" - exit - else - echo "Unsupported platform, manually populate" - exit - fi - else - echo "Loading installs from $BLENDER_INSTALLS" - fi -} - -# Install the addon to this path -function install_path(){ - i=$1 - - if [ "$FAST_RELOAD" = false ] - then - # echo "Remove prior: $i/$NAME/" - # ls "$i/$NAME/" - rm -r "$i/$NAME/" - fi - - mkdir -p "$i/$NAME" - cp -R build/$NAME "$i/" - echo "Installed at: $i/" -} - -function install_all(){ - # Load in all target blender version(s) - IFS=$'\n' read -d '' -r -a lines < $BLENDER_INSTALLS - - for i in "${lines[@]}" - do - install_path "$i" - done -} - -# Main build calls. - -detect_installs -build -install_all -clean - -echo "Reloaded addon" diff --git a/docs/asset_standards.md b/docs/asset_standards.md new file mode 100644 index 00000000..f721a59e --- /dev/null +++ b/docs/asset_standards.md @@ -0,0 +1,61 @@ +MCprep thrives on the contribution of rigs from the community. However, to maintain a feel of consistency and usability, we set a number of standards when accepting a rig. + +In the past, the MCprep team (mostly @TheDuckCow) have accepted rigs and created manual changes to fix any of the QA issues found below. Going forward, due to volume and where time could be better spent, we are more likely to ask asset contributors to address these issues themselves, and hence this detail page outlining those expectations. + +All contributors are welcomed and encouraged, but we do recommend you have a baseline level of experience in the areas of modeling, UV, and rigging to ensure quality submissions. However, any feedback given will always be constructive if any standards below are not met. + +_Anything unclear? Report [an issue](https://github.com/Moo-Ack-Productions/MCprep/issues/)!_ + + +## Mob + Entity standards checklist + +Treat the below steps as a checklist in a way to audit your own rig. + +- The most important QA test: use "install rig" in MCprep, and then test importing + - There should be no errors, and materials should work, and the armature and everything should be there with no unexpected parented objects or "extras" coming in (e.g. custom bone shapes popping into the scene) + - Do this test in BOTH blender 2.80 and the latest stable release (e.g. 3.6 as on August 2023). + - Make sure the rig still looks correct when posed or animated from all sides. +- The blend file must be last saved in blender 2.80 OR have version partitioning across all versions of blender 2.80+ + - NOT SAVING IN 2.80 WILL LIKELY BE THE MOST COMMON REASON A RIG WILL BE REJECTED / REQUESTED FOR UPDATES. + - This is important as we support 2.80+, so all rigs need to adhere to that + - You can check the last version saved by opening the given blend file, going to the Script tab, and then typing this command into the Interactive Python Console: `bpy.data.version`, it should print out something like the screenshot below + - Not sure what rig partitioning means? Take this as an example, and hopefully it clarifies: + - `Warden - DigDanAnimates pre2.80.0.blend` Will be used by anything _before_ blender 2.80 (in other words, we'll be deleting this file soon!) + - `Warden - DigDanAnimates pre3.0.0.blend` Will be used by anything _before_ blender 3.0 (e.g. 2.80, 2.93) + - `Warden - DigDanAnimates.blend` Will be used by blender 3.0 and higher (due to no other "pre#.#.# being higher than 3.0.0) + - We should aim to NOT partition rigs by version as much as possible, as it bloats download size and becomes even more to maintain. But sometimes, we can't avoid it. +- The rig/asset needs to have the correct scale (1 Blender unit = 1 meter), and the scale should be applied (control+a, scale) +- The rig must be self contained. That means textures are packed (File > External Data > Pack Resources) + - This is to negate the issue of relative vs absolute paths, which just become a pain + - Best practice is still to run "relpaths" beforehand, to avoid having any user's paths hard coded into the file, which is more of a privacy thing than anything. +- Extra unused data should be purged, to minimize bloat +- All rigs should be defined in their own top level collection. All other collections in the file not related to the rig must be removed + - If a rig itself has multiple nested collections, that's ok - the top level collection just needs to have "MCprep" in the name, e.g. "Warden MCprep" will be what gets listed in the UI list, and it won't list any sub collections or other collections in the file. The warden rig is again an example of this. But prefer to just have a single collection where possible. + - The rig's root bone, while in rest mode, should be placed at the origin of the armature, and the origin of the armature should be matching the same 3D point as the origin for the collection. If there is only one rig in the file, it should be at the origin (0,0,0). If there are multiple rigs in the file, it is allowed to move the whole rig in object mode to be offset, as long as the collection origin is updated to match. +- Custom bone shapes are ok and in fact encouraged, just don't put them in the rig scene. If you want to put them into their own collection, either give it the name MCskip OR make sure the primary collection (s) have the text MCprep in it, so that the +- The root-most bone of the rig must: + 1. Exist. Ie one central bone that everything is parented to, so if you move this everything, including control-only panels, also move + 2. Be named one of: `["main", "root", "base", "master"]`; case does not matter, though convention is to primarily use ROOT in all uppercase. +- _All bones_ should be named, no name.001's or anything like that. + - Furthermore, left/right bones should be named as per the blender guidelines. [See here](https://docs.blender.org/manual/en/latest/animation/armatures/bones/editing/naming.html), which helps ensure animation tools will work like flip pose left/right. +- _All meshes_ should be named as well. No cube.001's, give them representation names +- The rig must be a reasonably faithful representation of a Vanilla mob or entity + - No custom mobs that aren't in game (yet) + - No highly detailed or stylized looks that would differ from the rest of the library, e.g. generally there shouldn't be beveling + - Some extra details that accentuate and make high fidelity animations possible are OK. Examples include: Knees and elbows that bend (but it should still look vanilla if they don't bend), extra face controls or facial details (in the case of player rigs) +- Drivers are ok, but shouldn't be excessive - and minimize the use of python expression drivers where possible + - Drivers add a lot of overhead, especially if you have many of one mob type in a scene. + - Simple drivers, such as "SUM position" used to translate a control panel to bone transforms are fine - but they should NOT be python expressions, rather they should be SUM of [variable transform channels], as these run much quicker. +- As much as possible, the rig should continue to look and render correctly if someone were to use prep materials on it (this is in fact the default of what happens on import into blender, for compatibility). +- A fun note: The default position of the rig should show off its flaire! Ie, the saved pose _should_ be posted. + - MCprep has controls for clearing the post on import. Meanwhile, having a posed starting point helps show off what the rig can do. +- If relevant, the rig can have a single default animation. There should _not_ be more than one animation though, since it wouldn't be obvious and would clutter the file during subsequent imports + - The bat is an example of this, with a simple flying pattern. The Enchanting table is also an example (although that is a "block" and not an entity or rig) +- Technically, rigs can have a single python script named after the same blend file that are used to draw custom interfaces + - But this is strongly not encouraged, as we cannot guarantee maintaining these ad hoc scripts. Much preferred to have drivers in the system itself, so that we don't have to rely on the user finding a custom panel or anything like that + - However, this feature of having python script loading will be kept, as it is very useful for users who have their own custom rigs they like to install and use. + + +## Other asset contributions + +QA will be done on a case by case basis. This would apply to meshswap mesh contributions, geometry node weather effects, and anything else not mentioned. \ No newline at end of file diff --git a/docs/dev_utils.md b/docs/dev_utils.md new file mode 100644 index 00000000..a48a8385 --- /dev/null +++ b/docs/dev_utils.md @@ -0,0 +1,337 @@ +### Utilities +#### util.py + +**apply_colorspace(node, color_enum)** + +Apply colorspace on a node (eg: Image Texture) with cross version compability + +**nameGeneralize(name)** + +Return the base name of a datablock without any number extension. + +**materialsFromObj(obj_list)** + +Returns a list of materials from object list. + +**bAppendLink(directory, name, toLink, active_layer=True)** + +`directory` a path to xyz.blend/Type, where Type is: Collection, Group, Material. + +`name` asset name + +`toLink` bool + +Returns true if successful, false if not + +**obj_copy(base, context=None, vertex_groups=True, modifiers=True)** + +`base` object need to be duplicate must be valid + +`context` use the active context + +`vertex_groups` copy the vertex groups + +`modifiers` copy the modifiers + +Returns that new copy object. + +**min_bv(version, \*, inclusive=True)** + + + + If `inclusive` is + + true returns true if the result of current Blender is equal to `version`, + + false returns false unless current Blender is higher than `version` + + +**bv30()** + + Check for version is 3.0, sees `min_bv()` + + + +**is_atlas_export(context)** + +Check if the selected objects are valid for textureswap/animated texture. Due to Mineways has a big texture atlas option. + +Returns a bool. If false, the UI will show a warning and link to doc + + about using the right settings. + +** face_on_edge(faceloc)** + + Check if a face is on the boundary between two blocks in local coordinates + + Returns a bool. + +**ramdomizeMeshSwap(swap, variations)** + +Randomization for model imports, add extra statements for exta cases (eg: torch -> torch.1) + +`swap` a string of a block + +`variations` a integer seed + + + +**duplicatedDatablock(name)** + +Returns true if datablock is a duplicate or not, e.g. ending in .00# + + + +**loadTexture(texture)** + +Returns a texture. Load a texture from its path once, reusing existing texture if present. + + + +**get_objects_conext(context)** + + + +Returns list of objects, either from view layer or scene for 2.8 + + + +**open_program(executable)** + +Open external program from path + +returns 0 if success else string "Error" + + + +**open_folder_crossplatform(folder)** + +Open folder crossplatform return a bool if open successful + + + +**addGroupInstance(group_name, loc, select=True)** + +Create a collection instance object. Returns that object. + +**load_mcprep_json()** + +Load in the json file. Returns a bool if successful + +``` +{ + { + "blocks": { + "reflective": [], + "water": [], + "solid": [], + "emit": [], + "desaturated": [], + "animated": [], + "block_mapping_mc": {}, + "block_mapping_jmc": {}, + "block_mapping_mineways": {}, + "canon_mapping_block": {} + }, + "mob_skip_prep": [], + "make_real": [] +} + +``` + +**ui_scale()** + + Returns the UI Scale + +**uv_select(obj, action='TOGGLE')** + +Select all UV verts of an object on 1 uv layer. + +`action` (Optional) SELECT, DESELECT, TOGGLE. + +**move_to_collection(obj, collection)** (2.8+) + +Move object out of collects into sepcify collection. + +**get_or_create_viewlayer(context: Context, collection_name: str)** (2.8+) + +Returns or creates collection in the active context view layer for a given name. + + + +**natural_sort(elements)** + +Returns a sorted list + +**make_annotations(cls): + +Add annotation attribute to class fields to avoid Blender 2.8 warnings + +**layout_split(layout, factor=0.0, align=False)** + + +Split UI for 2.8+ and below compability + + + +**get_user_preferences(context=None)** + + + +Returns user preferences for 2.8+ and below compability else None + + + +**get_preferences(context=None)** + + + +Returns user preferences compability in more friendly way. See `get_user_preferences()` + + + +**set_active_object(context, obj)** + + + +Set the active object with 2.8+ and below compability + + + +**select_get(obj)** + + + +Returns a bool of object is selected with multiversion compability + + + +**select_set(obj, state)** + +Set bool selection `state` of object with multiversion compability + + + +**hide_viewport(obj, state)** + +Set the object viewport hide `state` with multiversion compability + + + +**collections()** + + + +Returns a list of collection + + + +**viewport_textured(context=None)** + + + +Returns if viewport solid is textured else None + + + +**get_cursor_location(context=None)** + +Returns the location vector of the 3D cursor else `(0,0,0)`` + + + +**set_cursor_location(loc, context=None)** + + + +Set the location vector of the 3D cursor + + + +**instance_collection(obj)** + + + +Get the collection of instance collection object + + + +**obj_link_scene(obj, context=None)** + + + +Links object to scene or scene master collection + + + +**obj_unlink_remove(obj, remove, context=None)** + + + +Unlink an object from the scene, and remove the datablock if `remove` is true + + + +**users_collection(obj)** + + +Returns the collections of an object + + + +**matmul(v1, v2, v3=None)** + +Return multiplciation of matrix and/or vectors in cross compatible way. v1 @ v2 + + + +**scene_update(context=None)** + + + +Update scene, despgraph in cross compatible way + + + +**move_assets_to_excluded_layer(context, collections)** + +Move collections not to rendered to an excluded collection if not exist, create. + +#### materials/generated.py + +**get_mc_canonical_name(name)** + +Returns the general canon name of the block material. + +**find_from_texturepack(blockname, resource_folder)** + +Returns the path of the block from the resource pack. + +**get_textures(material)** + +Get the image texture datablocks passes of the material. + +**create_node(tree_nodes, node_type,** ** **attrs)** + +Returns the created node in the specified node tree from the node type. + +**get_node_socket(node, is_input:=True)** + +Returns a list of sockets of input or output of the node. + +#### materials/skin.py + +**getMatsFromSelected(selected, new_material=False)** + +Returns a list of materials and objects from selected objects. This function used in skinswap. + +**download_user(self, context, username)** + +`self.download_user(context, "theduckcow")` + +Download skin from website. + +#### spawner/spawn_util.py +**get_rig_from_objects(objects)** + +Returns the rig armature object from list of objects. diff --git a/mcprep_data_base.json b/mcprep_data_base.json index 5f00830b..3360161f 100644 --- a/mcprep_data_base.json +++ b/mcprep_data_base.json @@ -103,6 +103,26 @@ "Strider", "Warden" ], +"unspawnable_for_now": [ + "banner", + "bed", + "bell", + "brewing_stand", + "cauldron", + "chest", + "double_chest", + "ender_chest", + "enchanting_table", + "end_portal", + "hopper", + "moving_piston", + "piston", + "scaffolding", + "shulker_box", + "sunflower", + "water_still", + "water" +], "make_real": [ "chest", "chest_double"] diff --git a/mcprep_data_refresh.py b/mcprep_data_refresh.py index 09a86a36..8a3248b2 100755 --- a/mcprep_data_refresh.py +++ b/mcprep_data_refresh.py @@ -1,4 +1,3 @@ -#!/opt/homebrew/bin/python3 # Tool to pull down material names from jmc2obj and Mineways import json diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 00000000..c2fc808a --- /dev/null +++ b/poetry.lock @@ -0,0 +1,256 @@ +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. + +[[package]] +name = "bpy-addon-build" +version = "0.2.1" +description = "A build system to make building and testing Blender addons 10 times easier" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "bpy_addon_build-0.2.1-py3-none-any.whl", hash = "sha256:443a0df1e940070a956278040ab04774bcbf42090aba8a05240d7b51928e443f"}, + {file = "bpy_addon_build-0.2.1.tar.gz", hash = "sha256:175e2afad291d1def1612b5e55ce70fe686f9a108f9f78f5d823e4c3cd6745ae"}, +] + +[package.dependencies] +docopt = ">=0.6.2,<0.7.0" +pyyaml = ">=6.0,<7.0" +rich = ">=13.3.5,<14.0.0" + +[[package]] +name = "docopt" +version = "0.6.2" +description = "Pythonic argument parser, that will make you smile" +optional = false +python-versions = "*" +files = [ + {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, +] + +[[package]] +name = "fake-bpy-module-2-80" +version = "20230117" +description = "Collection of the fake Blender Python API module for the code completion." +optional = false +python-versions = ">=3.7" +files = [ + {file = "fake-bpy-module-2.80-20230117.tar.gz", hash = "sha256:eee7e6edb0b27c723bed3eb7b9e818cf2cbb535909c340a9e3ba950190458c74"}, + {file = "fake_bpy_module_2.80-20230117-py3-none-any.whl", hash = "sha256:911b38d2581d4298a244ba654f7eb2c105146038b963bea378669492b5155c1f"}, +] + +[[package]] +name = "flake8" +version = "5.0.4" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.6.1" +files = [ + {file = "flake8-5.0.4-py2.py3-none-any.whl", hash = "sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248"}, + {file = "flake8-5.0.4.tar.gz", hash = "sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db"}, +] + +[package.dependencies] +importlib-metadata = {version = ">=1.1.0,<4.3", markers = "python_version < \"3.8\""} +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.9.0,<2.10.0" +pyflakes = ">=2.5.0,<2.6.0" + +[[package]] +name = "importlib-metadata" +version = "4.2.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.6" +files = [ + {file = "importlib_metadata-4.2.0-py3-none-any.whl", hash = "sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b"}, + {file = "importlib_metadata-4.2.0.tar.gz", hash = "sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31"}, +] + +[package.dependencies] +typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} +zipp = ">=0.5" + +[package.extras] +docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pep517", "pyfakefs", "pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy"] + +[[package]] +name = "markdown-it-py" +version = "2.2.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.7" +files = [ + {file = "markdown-it-py-2.2.0.tar.gz", hash = "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1"}, + {file = "markdown_it_py-2.2.0-py3-none-any.whl", hash = "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" +typing_extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["attrs", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "pycodestyle" +version = "2.9.1" +description = "Python style guide checker" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pycodestyle-2.9.1-py2.py3-none-any.whl", hash = "sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b"}, + {file = "pycodestyle-2.9.1.tar.gz", hash = "sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785"}, +] + +[[package]] +name = "pyflakes" +version = "2.5.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pyflakes-2.5.0-py2.py3-none-any.whl", hash = "sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2"}, + {file = "pyflakes-2.5.0.tar.gz", hash = "sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3"}, +] + +[[package]] +name = "pygments" +version = "2.16.1" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, + {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, +] + +[package.extras] +plugins = ["importlib-metadata"] + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "rich" +version = "13.5.2" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.5.2-py3-none-any.whl", hash = "sha256:146a90b3b6b47cac4a73c12866a499e9817426423f57c5a66949c086191a8808"}, + {file = "rich-13.5.2.tar.gz", hash = "sha256:fb9d6c0a0f643c99eed3875b5377a184132ba9be4d61516a55273d3554d75a39"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "typing-extensions" +version = "4.7.1" +description = "Backported and Experimental Type Hints for Python 3.7+" +optional = false +python-versions = ">=3.7" +files = [ + {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, + {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, +] + +[[package]] +name = "zipp" +version = "3.15.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.7" +files = [ + {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, + {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.7" +content-hash = "47abfe0a2df11a2ed20dc95d08450e596a5c0691f20601813536697f4b153d3c" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..98ba6724 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[tool.poetry] +name = "MCprep" +version = "3.5" +description = "Blender python addon to increase workflow for creating minecraft renders and animations" +authors = ["TheDuckCow"] +license = "GPL-3.0-only" +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.7" +fake-bpy-module-2-80 = "^20230117" +flake8 = "^5.0.4" +bpy-addon-build = ">=0.1.8,<0.3.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..06be29e2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +bpy-addon-build>=0.1.8,<0.3.0 +fake-bpy-module-2.80==20230117 +flake8==5.0.4 diff --git a/run_tests.bat b/run_tests.bat deleted file mode 100644 index e1e86553..00000000 --- a/run_tests.bat +++ /dev/null @@ -1,25 +0,0 @@ -:: Run all blender addon tests -:: -:: %1: pass in -all or e.g. -single or none, to only do first blender exec only. -:: %2: Decide which specific test class+case to run, defined in py test files. - -set RUN_ALL=%1 -set RUN_ONLY=%2 - -echo Doing initial reinstall of addon -::call compile.bat - -:: Text file where each line is a path to a specific blender executable (.exe) -set BLENDER_EXECS=blender_execs.txt - -set TEST_RUNNER="test_files\addon_tests.py" -echo "Starting tests" - -set "file=%BLENDER_EXECS%" -:: TODO: implement RUN_ALL control. -for /F "usebackq delims=" %%a in ("%file%") do ( - echo Doing test for %%a - "%%a" -b -y -P %TEST_RUNNER% -- --auto_run %1 %2 %3 %4 -) - -goto:eof diff --git a/run_tests.py b/run_tests.py new file mode 100644 index 00000000..6cef3fd8 --- /dev/null +++ b/run_tests.py @@ -0,0 +1,177 @@ +# ##### 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 ##### + +"""MCprep unit test runner + +Example usage: + +# Run all tests for the first blender exec listed in blender_execs.txt +python3 run_tests.py + +# Run all tests across all available versions of blender in blender_execs.txt +python3 run_tests.py -a + +# Run a specific test, across specific versions of blender +python3 run_tests.py -v 3.6,3.5 -t test_disable_enable + +Run script with -h to see all options. Must run with bpy-addon-build installed +""" + +from enum import Enum +from typing import List +import argparse +import os +import subprocess +import time + + +COMPILE_CMD = ["bpy-addon-build", "--during-build", "dev"] +DATA_CMD = ["python", "mcprep_data_refresh.py", "-auto"] # TODO, include in build +DCC_EXES = "blender_execs.txt" +TEST_RUNNER = os.path.join("test_files", "test_runner.py") +TEST_CSV_OUTPUTS = "test_results.csv" +SPACER = "-" * 79 + + +class TestSpeed(Enum): + """Which tests to run.""" + SLOW = 'slow' # All tests, including any UI-triggering ones. + MEDIUM = 'medium' # All but the slowest tests. + FAST = 'fast' # Only fast-running tests (TBD target runtime). + + def __str__(self): + return self.value + + +def main(): + args = get_args() + + # Read arguments + blender_execs = get_blender_binaries() + if not len(blender_execs): + print("ERROR : No Blender exe paths found!") + return + + # Compile the addon + res = subprocess.check_output(COMPILE_CMD) + print(res.decode("utf-8")) + + reset_test_file() + + # Loop over all binaries and run tests. + t0 = time.time() + for ind, binary in enumerate(blender_execs): + run_all = args.all_execs is True + run_specific = args.version is not None + if ind > 0 and not (run_all or run_specific): + continue # Only run the first test unless specified + if not os.path.exists(binary): + print(f"Blender EXE not found: {binary}") + continue + cmd = [binary, + "--background", + "--factory-startup", + "-y", + "--python", + TEST_RUNNER, + "--"] # TODO: Option to specify specific test or test class + if args.test_specific is not None: + cmd.extend(["-t", args.test_specific]) + if args.version is not None: + cmd.extend(["-v", args.version]) + output = subprocess.check_output(cmd) + print(output.decode("utf-8")) + + t1 = time.time() + + output_results() + round_s = round(t1 - t0) + print(f"tests took {round_s}s to run") + + +def get_args(): + """Sets up and returns argument parser for module functions.""" + parser = argparse.ArgumentParser(description="Run MCprep tests.") + # Would use action=argparse.BooleanOptionalAction, + # but that's python 3.9+ only; we need to support 3.7.0+ (Blender 2.80) + parser.add_argument( + "-a", "--all_execs", action="store_true", + help="Run the test suite once per each executable in blender_execs.txt" + ) + parser.set_defaults(all_execs=False) + parser.add_argument( + "-t", "--test_specific", + help="Run only a specific test function or class matching this name") + parser.add_argument( + "-v", "--version", + help="Specify the blender version(s) to test, in #.# or #.##,#.#") + parser.add_argument( + "-s", "--speed", + type=TestSpeed, + choices=list(TestSpeed), + help="Which speed of tests to run") + return parser.parse_args() + + +def get_blender_binaries() -> List: + """Extracts a list of strings meant to be blender binary paths.""" + if not os.path.exists(DCC_EXES): + print(f"ERROR : The {DCC_EXES} file is missing! Please create it!") + return [] + + with open(DCC_EXES, "r") as f: + blender_execs = [ln.rstrip() for ln in f.readlines()] + + return blender_execs + + +def reset_test_file(): + """Removes and reinitializes the output csv test file.""" + try: + os.remove(TEST_CSV_OUTPUTS) + except Exception as e: + print(e) + + with open(TEST_CSV_OUTPUTS, "w") as fopen: + header = ["bversion", "ran_tests", "ran", "skips", "failed", "errors"] + fopen.write(",".join(header) + "\n") + + +def output_results(): + """Reads in and prints out the csv of test results.""" + if not os.path.isfile(TEST_CSV_OUTPUTS): + print("Could not find test results csv!") + return + print(SPACER) + with open(TEST_CSV_OUTPUTS, "r") as fopen: + lns = fopen.readlines() + for idx, line in enumerate(lns): + line = line.strip() + cols = line.split(",") + + # Update the first column to be a nicer written format, add + # spacing to help columns align. + cols[0] = cols[0].replace(". ", ".") + " " # + tabline = "\t".join(cols) + print(tabline) + if idx == 0: + print(SPACER) + + +if __name__ == "__main__": + main() diff --git a/run_tests.sh b/run_tests.sh deleted file mode 100755 index 28d6f196..00000000 --- a/run_tests.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env bash -# -# Run all blender addon tests. -# -# Run all tests for only the first executable listed in blender_execs.txt -# ./run_tests.sh -# -# Run all tests on all versions of blender listed in blender_execs.txt -# ./run_tests.sh -all -# -# Run only a single unit test within the first version of blender listed -# ./run_tests.sh -run change_skin -# -# Run only a single unit test, but across all blender versions -# ./run_tests.sh -all -run change_skin -# -# Add -v to any argument above to allow print statements within tests. - -# File containing 1 line per blender executable complete path. The first -# line is the blender executable that will be used in 'quick' (-single) tests. -# All executables will run all tests if TEST_ALL == "-all". -BLENDER_EXECS=blender_execs.txt -IFS=$'\n' read -d '' -r -a VERSIONS < $BLENDER_EXECS - -TEST_ALL=$1 # Check later if this is -all or not - - -TEST_RUNNERS=( - "test_files/addon_tests.py" -) - -# Update the mappings. -./mcprep_data_refresh.py -auto - -# First, do a soft reload of python files. -echo "Soft py file reload" -./compile.sh -fast - - -# Remove old test results. -rm test_results.tsv -echo -e "blender\tfailed_test\tshort_err" > test_results.tsv - -for ((i = 0; i < ${#VERSIONS[@]}; i++)) -do - echo "RUNNING TESTS with blender: ${VERSIONS[$i]}" - for ((j = 0; j < ${#TEST_RUNNERS[@]}; j++)) - do - echo "Running test ${TEST_RUNNERS[$j]}" - # -b for background, -y for auto run scripts, -P to run specific script - "${VERSIONS[$i]}" -b -y -P "${TEST_RUNNERS[$j]}" -- --auto_run $1 $2 $3 $4 - done - - echo "FINISHED ALL TESTS FOR blender: ${VERSIONS[$i]}" - echo "" - if [ -z "$TEST_ALL" ] || [ "$TEST_ALL" != "-all" ] - then - echo "-all not specified, skipping further blender version tests" - exit - fi -done - -echo "View results in: test_results.csv" -open test_results.tsv diff --git a/test_files/__init__.py b/test_files/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test_files/addon_test.py b/test_files/addon_test.py new file mode 100644 index 00000000..60ab2bf2 --- /dev/null +++ b/test_files/addon_test.py @@ -0,0 +1,38 @@ +# ##### 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 unittest + +import bpy + + +class AddonTest(unittest.TestCase): + """Create addon level tests, and ensures enabled for later tests.""" + + def test_enable(self): + """Ensure the addon can be directly enabled.""" + bpy.ops.preferences.addon_enable(module="MCprep_addon") + + def test_disable_enable(self): + """Ensure we can safely disable and re-enable addon without error.""" + bpy.ops.preferences.addon_disable(module="MCprep_addon") + bpy.ops.preferences.addon_enable(module="MCprep_addon") + + +if __name__ == '__main__': + unittest.main(exit=False) diff --git a/test_files/addon_tests.py b/test_files/addon_tests.py deleted file mode 100644 index 13c43dff..00000000 --- a/test_files/addon_tests.py +++ /dev/null @@ -1,2530 +0,0 @@ -# ##### MCprep ##### -# -# Developed by Patrick W. Crawford, see more at -# http://theduckcow.com/dev/blender/MCprep -# -# ##### 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 ##### - - -from contextlib import redirect_stdout -import filecmp -import importlib -import io -import os -import shutil -import sys -import tempfile -import time -import traceback - -import bpy -from mathutils import Vector - -TEST_FILE = "test_results.tsv" - -# ----------------------------------------------------------------------------- -# Primary test loop -# ----------------------------------------------------------------------------- - - -class mcprep_testing(): - # {status}, func, prefunc caller (reference only) - - def __init__(self): - self.suppress = True # hold stdout - self.test_status = {} # {func.__name__: {"check":-1, "res":-1,0,1}} - self.test_cases = [ - self.enable_mcprep, - self.prep_materials, - self.prep_materials_pbr, - self.prep_missing_passes, - self.openfolder, - self.spawn_mob, - self.spawn_mob_linked, - self.check_blend_eligible, - self.check_blend_eligible_middle, - self.check_blend_eligible_real, - self.change_skin, - self.import_world_split, - self.import_world_fail, - self.import_jmc2obj, - self.import_mineways_separated, - self.import_mineways_combined, - self.name_generalize, - self.canonical_name_no_none, - self.canonical_test_mappings, - self.meshswap_spawner, - self.meshswap_jmc2obj, - self.meshswap_mineways_separated, - self.meshswap_mineways_combined, - self.detect_desaturated_images, - self.detect_extra_passes, - self.find_missing_images_cycles, - self.qa_meshswap_file, - self.item_spawner, - self.item_spawner_resize, - self.entity_spawner, - self.model_spawner, - self.geonode_effect_spawner, - self.particle_area_effect_spawner, - self.collection_effect_spawner, - self.img_sequence_effect_spawner, - self.particle_plane_effect_spawner, - self.sync_materials, - self.sync_materials_link, - self.load_material, - self.uv_transform_detection, - self.uv_transform_no_alert, - self.uv_transform_combined_alert, - self.world_tools, - self.test_enable_obj_importer, - self.test_generate_material_sequence, - self.qa_effects, - self.qa_rigs, - self.convert_mtl_simple, - self.convert_mtl_skip, - ] - self.run_only = None # Name to give to only run this test - - self.mcprep_json = {} - - def run_all_tests(self): - """For use in command line mode, run all tests and checks""" - if self.run_only and self.run_only not in [tst.__name__ for tst in self.test_cases]: - print("{}No tests ran!{} Test function not found: {}".format( - COL.FAIL, COL.ENDC, self.run_only)) - - for test in self.test_cases: - if self.run_only and test.__name__ != self.run_only: - continue - self.mcrprep_run_test(test) - - failed_tests = [ - tst for tst in self.test_status - if self.test_status[tst]["check"] < 0] - passed_tests = [ - tst for tst in self.test_status - if self.test_status[tst]["check"] > 0] - - print("\n{}COMPLETED, {} passed and {} failed{}".format( - COL.HEADER, - len(passed_tests), len(failed_tests), - COL.ENDC)) - if passed_tests: - print("{}Passed tests:{}".format(COL.OKGREEN, COL.ENDC)) - print("\t" + ", ".join(passed_tests)) - if failed_tests: - print("{}Failed tests:{}".format(COL.FAIL, COL.ENDC)) - for tst in self.test_status: - if self.test_status[tst]["check"] > 0: - continue - ert = suffix_chars(self.test_status[tst]["res"], 70) - print("\t{}{}{}: {}".format(COL.UNDERLINE, tst, COL.ENDC, ert)) - - # indicate if all tests passed for this blender version - if not failed_tests: - with open(TEST_FILE, 'a') as tsv: - tsv.write("{}\t{}\t-\n".format(bpy.app.version, "ALL PASSED")) - - def write_placeholder(self, test_name): - """Append placeholder, presuming if not changed then blender crashed""" - with open(TEST_FILE, 'a') as tsv: - tsv.write("{}\t{}\t-\n".format( - bpy.app.version, "CRASH during " + test_name)) - - def update_placeholder(self, test_name, test_failure): - """Update text of (if error) or remove placeholder row of file""" - with open(TEST_FILE, 'r') as tsv: - contents = tsv.readlines() - - if not test_failure: # None or "" - contents = contents[:-1] - else: - this_failure = "{}\t{}\t{}\n".format( - bpy.app.version, test_name, suffix_chars(test_failure, 20)) - contents[-1] = this_failure - with open(TEST_FILE, 'w') as tsv: - for row in contents: - tsv.write(row) - - def mcrprep_run_test(self, test_func): - """Run a single MCprep test""" - print("\n{}Testing {}{}".format(COL.HEADER, test_func.__name__, COL.ENDC)) - self.write_placeholder(test_func.__name__) - self._clear_scene() - try: - if self.suppress: - stdout = io.StringIO() - with redirect_stdout(stdout): - res = test_func() - else: - res = test_func() - if not res: - print("\t{}TEST PASSED{}".format(COL.OKGREEN, COL.ENDC)) - self.test_status[test_func.__name__] = {"check": 1, "res": res} - else: - print("\t{}TEST FAILED:{}".format(COL.FAIL, COL.ENDC)) - print("\t" + res) - self.test_status[test_func.__name__] = {"check": -1, "res": res} - except Exception: - print("\t{}TEST FAILED{}".format(COL.FAIL, COL.ENDC)) - print(traceback.format_exc()) # other info, e.g. line number/file - res = traceback.format_exc() - self.test_status[test_func.__name__] = {"check": -1, "res": res} - # print("\tFinished test {}".format(test_func.__name__)) - self.update_placeholder(test_func.__name__, res) - - def setup_env_paths(self): - """Adds the MCprep installed addon path to sys for easier importing.""" - to_add = None - - for base in bpy.utils.script_paths(): - init = os.path.join(base, "addons", "MCprep_addon", "__init__.py") - if os.path.isfile(init): - to_add = init - break - if not to_add: - raise Exception("Could not add the environment path for direct importing") - - # add to path and bind so it can use relative improts (3.5 trick) - spec = importlib.util.spec_from_file_location("MCprep", to_add) - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - - from MCprep import conf - conf.init() - - def get_mcprep_path(self): - """Returns the addon basepath installed in this blender instance""" - for base in bpy.utils.script_paths(): - init = os.path.join( - base, "addons", "MCprep_addon") # __init__.py folder - if os.path.isdir(init): - return init - return None - - # ----------------------------------------------------------------------------- - # Testing utilities, not tests themselves (ie assumed to work) - # ----------------------------------------------------------------------------- - - def _clear_scene(self): - """Clear scene and data without printouts""" - # if not self.suppress: - # stdout = io.StringIO() - # with redirect_stdout(stdout): - bpy.ops.wm.read_homefile(app_template="") - for obj in bpy.data.objects: - bpy.data.objects.remove(obj) - for mat in bpy.data.materials: - bpy.data.materials.remove(mat) - # for txt in bpy.data.texts: - # bpy.data.texts.remove(txt) - - def _add_character(self): - """Add a rigged character to the scene, specifically Alex""" - bpy.ops.mcprep.reload_mobs() - # mcmob_type='player/Simple Rig - Boxscape-TheDuckCow.blend:/:Simple Player' - # mcmob_type='player/Alex FancyFeet - TheDuckCow & VanguardEnni.blend:/:alex' - # Formatting os.path.sep below required for windows support. - mcmob_type = 'hostile{}mobs - Rymdnisse.blend:/:silverfish'.format( - os.path.sep) - bpy.ops.mcprep.mob_spawner(mcmob_type=mcmob_type) - - def _import_jmc2obj_full(self): - """Import the full jmc2obj test set""" - testdir = os.path.dirname(__file__) - obj_path = os.path.join(testdir, "jmc2obj", "jmc2obj_test_1_15_2.obj") - bpy.ops.mcprep.import_world_split(filepath=obj_path) - - def _import_mineways_separated(self): - """Import the full jmc2obj test set""" - testdir = os.path.dirname(__file__) - obj_path = os.path.join( - testdir, "mineways", "separated_textures", - "mineways_test_separated_1_15_2.obj") - bpy.ops.mcprep.import_world_split(filepath=obj_path) - - def _import_mineways_combined(self): - """Import the full jmc2obj test set""" - testdir = os.path.dirname(__file__) - obj_path = os.path.join( - testdir, "mineways", "combined_textures", - "mineways_test_combined_1_15_2.obj") - bpy.ops.mcprep.import_world_split(filepath=obj_path) - - def _create_canon_mat(self, canon=None, test_pack=False): - """Creates a material that should be recognized""" - name = canon if canon else "dirt" - mat = bpy.data.materials.new(name) - mat.use_nodes = True - img_node = mat.node_tree.nodes.new(type="ShaderNodeTexImage") - if canon and not test_pack: - base = self.get_mcprep_path() - filepath = os.path.join( - base, "MCprep_resources", - "resourcepacks", "mcprep_default", "assets", "minecraft", "textures", - "block", canon + ".png") - img = bpy.data.images.load(filepath) - elif canon and test_pack: - testdir = os.path.dirname(__file__) - filepath = os.path.join( - testdir, "test_resource_pack", "textures", canon + ".png") - img = bpy.data.images.load(filepath) - else: - img = bpy.data.images.new(name, 16, 16) - img_node.image = img - return mat, img_node - - def _set_test_mcprep_texturepack_path(self, reset=False): - """Assigns or resets the local texturepack path.""" - testdir = os.path.dirname(__file__) - path = os.path.join(testdir, "test_resource_pack") - if not os.path.isdir(path): - raise Exception("Failed to set test texturepack path") - bpy.context.scene.mcprep_texturepack_path = path - - # Seems that infolog doesn't update in background mode - def _get_last_infolog(self): - """Return back the latest info window log""" - for txt in bpy.data.texts: - bpy.data.texts.remove(txt) - _ = bpy.ops.ui.reports_to_textblock() - print("DEVVVV get last infolog:") - for ln in bpy.data.texts['Recent Reports'].lines: - print(ln.body) - print("END printlines") - return bpy.data.texts['Recent Reports'].lines[-1].body - - def _set_exporter(self, name): - """Sets the exporter name""" - # from MCprep.util import get_user_preferences - if name not in ['(choose)', 'jmc2obj', 'Mineways']: - raise Exception('Invalid exporter set tyep') - context = bpy.context - if hasattr(context, "user_preferences"): - prefs = context.user_preferences.addons.get("MCprep_addon", None) - elif hasattr(context, "preferences"): - prefs = context.preferences.addons.get("MCprep_addon", None) - prefs.preferences.MCprep_exporter_type = name - - def _debug_save_file_state(self): - """Saves the current scene state to a test file for inspection.""" - testdir = os.path.dirname(__file__) - path = os.path.join(testdir, "debug_save_state.blend") - bpy.ops.wm.save_as_mainfile(filepath=path) - - # ----------------------------------------------------------------------------- - # Operator unit tests - # ----------------------------------------------------------------------------- - - def enable_mcprep(self): - """Ensure we can both enable and disable MCprep""" - - # brute force enable - # stdout = io.StringIO() - # with redirect_stdout(stdout): - try: - if hasattr(bpy.ops, "preferences") and "addon_enable" in dir(bpy.ops.preferences): - bpy.ops.preferences.addon_enable(module="MCprep_addon") - else: - bpy.ops.wm.addon_enable(module="MCprep_addon") - except Exception: - pass - - # see if we can safely toggle off and back on - if hasattr(bpy.ops, "preferences") and "addon_enable" in dir(bpy.ops.preferences): - bpy.ops.preferences.addon_disable(module="MCprep_addon") - bpy.ops.preferences.addon_enable(module="MCprep_addon") - else: - bpy.ops.wm.addon_disable(module="MCprep_addon") - bpy.ops.wm.addon_enable(module="MCprep_addon") - - def prep_materials(self): - # run once when nothing is selected, no active object - self._clear_scene() - - status = 'fail' - print("Checking blank usage") - try: - bpy.ops.mcprep.prep_materials( - animateTextures=False, - packFormat="simple", - autoFindMissingTextures=False, - improveUiSettings=False) - except RuntimeError as e: - if "Error: No objects selected" in str(e): - status = 'success' - else: - return "prep_materials other err: " + str(e) - if status == 'fail': - return "Prep should have failed with error on no objects" - - # add object, no material. Should still fail as no materials - bpy.ops.mesh.primitive_plane_add() - obj = bpy.context.object - status = 'fail' - try: - res = bpy.ops.mcprep.prep_materials( - animateTextures=False, - packFormat="simple", - autoFindMissingTextures=False, - improveUiSettings=False) - except RuntimeError as e: - if 'No materials found' in str(e): - status = 'success' # Expect to fail when nothing selected. - if status == 'fail': - return "mcprep.prep_materials-02 failure" - - # TODO: Add test where material is added but without an image/nodes - - # add object with canonical material name. Assume cycles - new_mat, _ = self._create_canon_mat() - obj.active_material = new_mat - status = 'fail' - try: - res = bpy.ops.mcprep.prep_materials( - animateTextures=False, - packFormat="simple", - autoFindMissingTextures=False, - improveUiSettings=False) - except Exception as e: - return "Unexpected error: " + str(e) - # how to tell if prepping actually occured? Should say 1 material prepped - # print(self._get_last_infolog()) # error in 2.82+, not used anyways - if res != {'FINISHED'}: - return "Did not return finished" - - def prep_materials_pbr(self): - # add object with canonical material name. Assume cycles - self._clear_scene() - bpy.ops.mesh.primitive_plane_add() - - obj = bpy.context.object - new_mat, _ = self._create_canon_mat() - obj.active_material = new_mat - try: - res = bpy.ops.mcprep.prep_materials( - animateTextures=False, - autoFindMissingTextures=False, - packFormat="specular", - improveUiSettings=False) - except Exception as e: - return "Unexpected error: " + str(e) - if res != {'FINISHED'}: - return "Did not return finished" - - self._clear_scene() - bpy.ops.mesh.primitive_plane_add() - - obj = bpy.context.object - new_mat, _ = self._create_canon_mat() - obj.active_material = new_mat - try: - res = bpy.ops.mcprep.prep_materials( - animateTextures=False, - autoFindMissingTextures=False, - packFormat="seus", - improveUiSettings=False) - except Exception as e: - return "Unexpected error: " + str(e) - if res != {'FINISHED'}: - return "Did not return finished" - - def prep_missing_passes(self): - """Test when prepping with passes after simple, n/s passes loaded.""" - - self._clear_scene() - bpy.ops.mesh.primitive_plane_add() - - self._set_test_mcprep_texturepack_path() - - obj = bpy.context.object - new_mat, _ = self._create_canon_mat("diamond_ore", test_pack=True) - obj.active_material = new_mat - - # Start with simple material loaded, non pbr/no extra passes - try: - res = bpy.ops.mcprep.prep_materials( - animateTextures=False, - autoFindMissingTextures=False, - packFormat="simple", - useExtraMaps=False, - syncMaterials=False, - improveUiSettings=False) - except Exception as e: - return "Unexpected error: " + str(e) - if res != {'FINISHED'}: - return "Did not return finished" - count_images = 0 - missing_images = 0 - for node in new_mat.node_tree.nodes: - if node.type != "TEX_IMAGE": - continue - elif node.image: - count_images += 1 - else: - missing_images += 1 - if count_images != 1: - return "Should have only 1 pass after simple load - had {}".format( - count_images) - if missing_images != 0: - return ( - "Should have only 0 unloaded passes after simple load - " - "had {}".format(count_images)) - - # Now try pbr load, should find the adjacent _n and _s passes and auto - # load it in. - self._set_test_mcprep_texturepack_path() - # return bpy.context.scene.mcprep_texturepack_path - try: - res = bpy.ops.mcprep.prep_materials( - animateTextures=False, - autoFindMissingTextures=False, - packFormat="specular", - useExtraMaps=True, - syncMaterials=False, # For some reason, had to turn off. - improveUiSettings=False) - except Exception as e: - return "Unexpected error: " + str(e) - if res != {'FINISHED'}: - return "Did not return finished" - count_images = 0 - missing_images = 0 - new_mat = obj.active_material - for node in new_mat.node_tree.nodes: - if node.type != "TEX_IMAGE": - continue - elif node.image: - count_images += 1 - else: - missing_images += 1 - self._debug_save_file_state() - if count_images != 3: - return "Should have all 3 passes after pbr load - had {}".format( - count_images) - if missing_images != 0: - return "Should have 0 unloaded passes after pbr load - had {}".format( - count_images) - - def prep_materials_cycles(self): - """Cycles-specific tests""" - - def find_missing_images_cycles(self): - """Find missing images from selected materials, cycles. - - Scenarios in which we find new textures - One: material is empty with no image block assigned at all, though has - image node and material is a canonical name - Two: material has image block but the filepath is missing, find it - Three: image is there, or image is packed; ie assume is fine (don't change) - """ - - # first, import a material that has no filepath - self._clear_scene() - mat, node = self._create_canon_mat("sugar_cane") - bpy.ops.mesh.primitive_plane_add() - bpy.context.object.active_material = mat - - pre_path = node.image.filepath - bpy.ops.mcprep.replace_missing_textures(animateTextures=False) - post_path = node.image.filepath - if pre_path != post_path: - return "Pre/post path differed, should be the same" - - # now save the texturefile somewhere - tmp_dir = tempfile.gettempdir() - tmp_image = os.path.join(tmp_dir, "sugar_cane.png") - shutil.copyfile(node.image.filepath, tmp_image) # leave original intact - - # Test that path is unchanged even when with a non canonical path - node.image.filepath = tmp_image - if node.image.filepath != tmp_image: - os.remove(tmp_image) - return "fialed to setup test, node path not = " + tmp_image - pre_path = node.image.filepath - bpy.ops.mcprep.replace_missing_textures(animateTextures=False) - post_path = node.image.filepath - if pre_path != post_path: - os.remove(tmp_image) - return ( - "Pre/post path differed in tmp dir when there should have " - "been no change: pre {} vs post {}".format(pre_path, post_path)) - - # test that an empty node within a canonically named material is fixed - pre_path = node.image.filepath - node.image = None # remove the image from block - if node.image: - os.remove(tmp_image) - return "failed to setup test, image block still assigned" - bpy.ops.mcprep.replace_missing_textures(animateTextures=False) - post_path = node.image.filepath - if not post_path: - os.remove(tmp_image) - return "No post path found, should have loaded file" - elif post_path == pre_path: - os.remove(tmp_image) - return "Should have loaded image as new datablock from canon location" - elif not os.path.isfile(post_path): - os.remove(tmp_image) - return "New path file does not exist" - - # test an image with broken texturepath is fixed for cannon material name - - # node.image.filepath = tmp_image # assert it's not the canonical path - # pre_path = node.image.filepath # the original path before renaming - # os.rename(tmp_image, tmp_image+"x") - # if os.path.isfile(bpy.path.abspath(node.image.filepath)) or pre_path != node.image.filepath: - # os.remove(pre_path) - # os.remove(tmp_image+"x") - # return "Failed to setup test, original file exists/img path updated" - # bpy.ops.mcprep.replace_missing_textures(animateTextures=False) - # post_path = node.image.filepath - # if pre_path == post_path: - # os.remove(tmp_image+"x") - # return "Should have updated missing image to canonical, still is "+post_path - # elif post_path != canonical_path: - # os.remove(tmp_image+"x") - # return "New path not canonical: "+post_path - # os.rename(tmp_image+"x", tmp_image) - - # Example where we save and close the blend file, move the file, - # and re-open. First, load the scene - self._clear_scene() - mat, node = self._create_canon_mat("sugar_cane") - bpy.ops.mesh.primitive_plane_add() - bpy.context.object.active_material = mat - # Then, create the textures locally - bpy.ops.file.pack_all() - bpy.ops.file.unpack_all(method='USE_LOCAL') - unpacked_path = bpy.path.abspath(node.image.filepath) - # close and open, moving the file in the meantime - save_tmp_file = os.path.join(tmp_dir, "tmp_test.blend") - os.rename(unpacked_path, unpacked_path + "x") - bpy.ops.wm.save_mainfile(filepath=save_tmp_file) - bpy.ops.wm.open_mainfile(filepath=save_tmp_file) - # now run the operator - img = bpy.data.images['sugar_cane.png'] - pre_path = img.filepath - if os.path.isfile(pre_path): - os.remove(unpacked_path + "x") - return "Failed to setup test for save/reopn move" - bpy.ops.mcprep.replace_missing_textures(animateTextures=False) - post_path = img.filepath - if post_path == pre_path: - os.remove(unpacked_path + "x") - return "Did not change path from " + pre_path - elif not os.path.isfile(post_path): - os.remove(unpacked_path + "x") - return "File for blend reloaded image does not exist: {}".format( - node.image.filepath) - os.remove(unpacked_path + "x") - - # address the example of sugar_cane.png.001 not being detected as canonical - # as a front-end name (not image file) - self._clear_scene() - mat, node = self._create_canon_mat("sugar_cane") - bpy.ops.mesh.primitive_plane_add() - bpy.context.object.active_material = mat - - pre_path = node.image.filepath - node.image = None # remove the image from block - mat.name = "sugar_cane.png.001" - if node.image: - os.remove(tmp_image) - return "failed to setup test, image block still assigned" - bpy.ops.mcprep.replace_missing_textures(animateTextures=False) - if not node.image: - os.remove(tmp_image) - return "Failed to load new image within mat named .png.001" - post_path = node.image.filepath - if not post_path: - os.remove(tmp_image) - return "No image loaded for " + mat.name - elif not os.path.isfile(node.image.filepath): - return "File for loaded image does not exist: " + node.image.filepath - - # Example running with animateTextures too - - # check on image that is packed or not, or packed but no data - os.remove(tmp_image) - - def openfolder(self): - if bpy.app.background is True: - return "" # can't test this in background mode - - folder = bpy.utils.script_path_user() - if not os.path.isdir(folder): - return "Sample folder doesn't exist, couldn't test" - res = bpy.ops.mcprep.openfolder(folder) - if res == {"FINISHED"}: - return "" - else: - return "Failed, returned cancelled" - - def spawn_mob(self): - """Spawn mobs, reload mobs, etc""" - self._clear_scene() - self._add_character() # run the utility as it's own sort of test - - self._clear_scene() - bpy.ops.mcprep.reload_mobs() - - # sample don't specify mob, just load whatever is first - bpy.ops.mcprep.mob_spawner() - - # spawn with linking - # try changing the folder - # try install mob and uninstall - - def spawn_mob_linked(self): - self._clear_scene() - bpy.ops.mcprep.reload_mobs() - bpy.ops.mcprep.mob_spawner(toLink=True) - - def check_blend_eligible(self): - from MCprep.spawner import spawn_util - fake_base = "MyMob - by Person" - - suffix_new = " pre9.0.0" # Force active blender instance as older - suffix_old = " pre1.0.0" # Force active blender instance as newer. - - p_none = fake_base + ".blend" - p_new = fake_base + suffix_new + ".blend" - p_old = fake_base + suffix_old + ".blend" - rando = "rando_name" + suffix_old + ".blend" - - # Check where input file is the "non-versioned" one. - - res = spawn_util.check_blend_eligible(p_none, [p_none, rando]) - if res is not True: - return "Should have been true even if rando has suffix" - - res = spawn_util.check_blend_eligible(p_none, [p_none, p_old]) - if res is not True: - return "Should be true as curr blend eligible and checked latest" - - res = spawn_util.check_blend_eligible(p_none, [p_none, p_new]) - if res is not False: - print(p_none, p_new, res) - return "Should be false as curr blend not eligible and checked latest" - - # Now check if input is a versioned file. - - res = spawn_util.check_blend_eligible(p_new, [p_none, p_new]) - if res is not True: - return "Should have been true since we are below min blender" - - res = spawn_util.check_blend_eligible(p_old, [p_none, p_old]) - if res is not False: - return "Should have been false since we are above this min blender" - - def check_blend_eligible_middle(self): - # Warden-like example, where we have equiv of pre2.80, pre3.0, and - # live blender 3.0+ (presuming we want to test a 2.93-like user) - from MCprep.spawner import spawn_util - fake_base = "WardenExample" - - # Assume the "current" version of blender is like 9.1 - # To make test not be flakey, actual version of blender can be anything - # in range of 2.7.0 upwards to 8.999. - suffix_old = " pre2.7.0" # Force active blender instance as older. - suffix_mid = " pre9.0.0" # Force active blender instance as older. - suffix_new = "" # presume "latest" version - - p_old = fake_base + suffix_old + ".blend" - p_mid = fake_base + suffix_mid + ".blend" - p_new = fake_base + suffix_new + ".blend" - - # Test in order - filelist = [p_old, p_mid, p_new] - - res = spawn_util.check_blend_eligible(p_old, filelist) - if res is True: - return "Older file should not match (in order)" - res = spawn_util.check_blend_eligible(p_mid, filelist) - if res is not True: - return "Mid file SHOULD match (in order)" - res = spawn_util.check_blend_eligible(p_new, filelist) - if res is True: - return "Newer file should not match (in order)" - - # Test out of order - filelist = [p_mid, p_new, p_old] - - res = spawn_util.check_blend_eligible(p_old, filelist) - if res is True: - return "Older file should not match (out of order)" - res = spawn_util.check_blend_eligible(p_mid, filelist) - if res is not True: - return "Mid file SHOULD match (out of order)" - res = spawn_util.check_blend_eligible(p_new, filelist) - if res is True: - return "Newer file should not match (out of order)" - - def check_blend_eligible_real(self): - # This order below matches a user's who was encountering an error - # (the actual in-memory python list order) - riglist = [ - "bee - Boxscape.blend", - "Blaze - Trainguy.blend", - "Cave Spider - Austin Prescott.blend", - "creeper - TheDuckCow.blend", - "drowned - HissingCreeper-thefunnypie2.blend", - "enderman - Trainguy.blend", - "Ghast - Trainguy.blend", - "guardian - Trainguy.blend", - "hostile - boxscape.blend", - "illagers - Boxscape.blend", - "mobs - Rymdnisse.blend", - "nether hostile - Boxscape.blend", - "piglin zombified piglin - Boxscape.blend", - "PolarBear - PixelFrosty.blend", - "ravager - Boxscape.blend", - "Shulker - trainguy.blend", - "Skeleton - Trainguy.blend", - "stray - thefunnypie2.blend", - "Warden - DigDanAnimates pre2.80.0.blend", - "Warden - DigDanAnimates pre3.0.0.blend", - "Warden - DigDanAnimates.blend", - "Zombie - Hissing Creeper.blend", - "Zombie Villager - Hissing Creeper-thefunnypie2.blend" - ] - target_list = [ - "Warden - DigDanAnimates pre2.80.0.blend", - "Warden - DigDanAnimates pre3.0.0.blend", - "Warden - DigDanAnimates.blend", - ] - - from MCprep.spawner import spawn_util - if bpy.app.version < (2, 80): - correct = "Warden - DigDanAnimates pre2.80.0.blend" - elif bpy.app.version < (3, 0): - correct = "Warden - DigDanAnimates pre3.0.0.blend" - else: - correct = "Warden - DigDanAnimates.blend" - - for rig in target_list: - res = spawn_util.check_blend_eligible(rig, riglist) - if rig == correct: - if res is not True: - return "Did not pick {} as correct rig".format(rig) - else: - if res is True: - return "Should have said {} was correct - not {}".format(correct, rig) - - def change_skin(self): - """Test scenarios for changing skin after adding a character.""" - self._clear_scene() - - bpy.ops.mcprep.reload_skins() - skin_ind = bpy.context.scene.mcprep_skins_list_index - skin_item = bpy.context.scene.mcprep_skins_list[skin_ind] - tex_name = skin_item['name'] - skin_path = os.path.join(bpy.context.scene.mcprep_skin_path, tex_name) - - status = 'fail' - try: - _ = bpy.ops.mcprep.applyskin( - filepath=skin_path, - new_material=False) - except RuntimeError as e: - if 'No materials found to update' in str(e): - status = 'success' # expect to fail when nothing selected - if status == 'fail': - return "Should have failed to skin swap with no objects selected" - - # now run on a real test character, with 1 material and 2 objects - self._add_character() - - pre_mats = len(bpy.data.materials) - bpy.ops.mcprep.applyskin( - filepath=skin_path, - new_material=False) - post_mats = len(bpy.data.materials) - if post_mats != pre_mats: # should be unchanged - return ( - "change_skin.mat counts diff despit no new mat request, " - "{} before and {} after".format(pre_mats, post_mats)) - - # do counts of materials before and after to ensure they match - pre_mats = len(bpy.data.materials) - bpy.ops.mcprep.applyskin( - filepath=skin_path, - new_material=True) - post_mats = len(bpy.data.materials) - if post_mats != pre_mats * 2: # should exactly double since in new scene - return ( - "change_skin.mat counts diff mat counts, " - "{} before and {} after".format(pre_mats, post_mats)) - - pre_mats = len(bpy.data.materials) - - # not diff operator name, this is popup browser - bpy.ops.mcprep.skin_swapper( - filepath=skin_path, - new_material=False) - post_mats = len(bpy.data.materials) - if post_mats != pre_mats: # should be unchanged - return ( - "change_skin.mat counts differ even though should be same, " - "{} before and {} after".format(pre_mats, post_mats)) - - # TODO: Add test for when there is a bogus filename, responds with - # Image file not found in err - - # capture info or recent out? - # check that username was there before or not - bpy.ops.mcprep.applyusernameskin( - username='TheDuckCow', - skip_redownload=False, - new_material=True) - - # check that timestamp of last edit of file was longer ago than above cmd - - bpy.ops.mcprep.applyusernameskin( - username='TheDuckCow', - skip_redownload=True, - new_material=True) - - # test deleting username skin and that file is indeed deleted - # and not in list anymore - - # bpy.ops.mcprep.applyusernameskin( - # username='TheDuckCow', - # skip_redownload=True, - # new_material=True) - - # test that the file was added back - - bpy.ops.mcprep.spawn_with_skin() - # test changing skin to file when no existing images/textres - # test changing skin to file when existing material - # test changing skin to file for both above, cycles and internal - # test changing skin file for both above without, then with, - # then without again, normals + spec etc. - return - - def import_world_split(self): - """Test that imported world has multiple objects""" - self._clear_scene() - - pre_objects = len(bpy.data.objects) - self._import_jmc2obj_full() - post_objects = len(bpy.data.objects) - if post_objects + 1 > pre_objects: - print("Success, had {} objs, post import {}".format( - pre_objects, post_objects)) - return - elif post_objects + 1 == pre_objects: - return "Only one new object imported" - else: - return "Nothing imported" - - def import_world_fail(self): - """Ensure loader fails if an invalid path is loaded""" - testdir = os.path.dirname(__file__) - obj_path = os.path.join(testdir, "jmc2obj", "xx_jmc2obj_test_1_14_4.obj") - try: - bpy.ops.mcprep.import_world_split(filepath=obj_path) - except Exception as e: - print("Failed, as intended: " + str(e)) - return - return "World import should have returned an error" - - def import_materials_util(self, mapping_set): - """Reusable function for testing on different obj setups""" - from MCprep.materials.generate import get_mc_canonical_name - from MCprep.materials.generate import find_from_texturepack - from MCprep import util - from MCprep import conf - - util.load_mcprep_json() # force load json cache - mcprep_data = conf.json_data["blocks"][mapping_set] - - # first detect alignment to the raw underlining mappings, nothing to - # do with canonical yet - mapped = [ - mat.name for mat in bpy.data.materials - if mat.name in mcprep_data] # ok! - unmapped = [ - mat.name for mat in bpy.data.materials - if mat.name not in mcprep_data] # not ok - fullset = mapped + unmapped # ie all materials - unleveraged = [ - mat for mat in mcprep_data - if mat not in fullset] # not ideal, means maybe missed check - - print("Mapped: {}, unmapped: {}, unleveraged: {}".format( - len(mapped), len(unmapped), len(unleveraged))) - - if len(unmapped): - err = "Textures not mapped to json file" - print(err) - print(sorted(unmapped)) - print("") - # return err - if len(unleveraged) > 20: - err = "Json file materials not found in obj test file, may need to update world" - print(err) - print(sorted(unleveraged)) - # return err - - if len(mapped) == 0: - return "No materials mapped" - elif mapping_set == "block_mapping_mineways" and len(mapped) > 75: - # Known that the "combined" atlas texture of Mineways imports - # has low coverge, and we're not doing anything about it. - # but will at least capture if coverage gets *worse*. - pass - elif len(mapped) < len(unmapped): # too many esp. for Mineways - # not a very optimistic threshold, but better than none - return "More materials unmapped than mapped" - print("") - - # each element is [cannon_name, form], form is none if not matched - mapped = [get_mc_canonical_name(mat.name) for mat in bpy.data.materials] - - # no matching canon name (warn) - mats_not_canon = [itm[0] for itm in mapped if itm[1] is None] - if mats_not_canon and mapping_set != "block_mapping_mineways": - print("Non-canon material names found: ({})".format(len(mats_not_canon))) - print(mats_not_canon) - if len(mats_not_canon) > 30: # arbitrary threshold - return "Too many materials found without canonical name ({})".format( - len(mats_not_canon)) - else: - print("Confirmed - no non-canon images found") - - # affirm the correct mappings - mats_no_packimage = [ - find_from_texturepack(itm[0]) for itm in mapped - if itm[1] is not None] - mats_no_packimage = [path for path in mats_no_packimage if path] - print("Mapped paths: " + str(len(mats_no_packimage))) - - # could not resolve image from resource pack (warn) even though in mapping - mats_no_packimage = [ - itm[0] for itm in mapped - if itm[1] is not None and not find_from_texturepack(itm[0])] - print("No resource images found for mapped items: ({})".format( - len(mats_no_packimage))) - print("These would appear to have cannon mappings, but then fail on lookup") - - # known number up front, e.g. chests, stone_slab_side, stone_slab_top - if len(mats_no_packimage) > 5: - return "Missing images for blocks specified in mcprep_data.json: {}".format( - ",".join(mats_no_packimage)) - - # also test that there are not raw image names not in mapping list - # but that otherwise could be added to the mapping list as file exists - - def import_jmc2obj(self): - """Checks that material names in output obj match the mapping file""" - self._clear_scene() - self._import_jmc2obj_full() - - res = self.import_materials_util("block_mapping_jmc") - return res - - def import_mineways_separated(self): - """Checks Mineways (single-image) material name mapping to mcprep_data""" - self._clear_scene() - self._import_mineways_separated() - - res = self.import_materials_util("block_mapping_mineways") - return res - - def import_mineways_combined(self): - """Checks Mineways (multi-image) material name mapping to mcprep_data""" - self._clear_scene() - self._import_mineways_combined() - - res = self.import_materials_util("block_mapping_mineways") - return res - - def name_generalize(self): - """Tests the outputs of the generalize function""" - from MCprep.util import nameGeneralize - test_sets = { - "ab": "ab", - "table.001": "table", - "table.100": "table", - "table001": "table001", - "fire_0": "fire_0", - # "fire_0_0001.png":"fire_0", not current behavior, but desired? - "fire_0_0001": "fire_0", - "fire_0_0001.001": "fire_0", - "fire_layer_1": "fire_layer_1", - "cartography_table_side1": "cartography_table_side1" - } - errors = [] - for key in list(test_sets): - res = nameGeneralize(key) - if res != test_sets[key]: - errors.append("{} converts to {} and should be {}".format( - key, res, test_sets[key])) - else: - print("{}:{} passed".format(key, res)) - - if errors: - return "Generalize failed: " + ", ".join(errors) - - def canonical_name_no_none(self): - """Ensure that MC canonical name never returns none""" - from MCprep.materials.generate import get_mc_canonical_name - from MCprep.util import materialsFromObj - self._clear_scene() - self._import_jmc2obj_full() - self._import_mineways_separated() - self._import_mineways_combined() - - bpy.ops.object.select_all(action='SELECT') - mats = materialsFromObj(bpy.context.selected_objects) - canons = [[get_mc_canonical_name(mat.name)][0] for mat in mats] - - if None in canons: # detect None response to canon input - return "Canon returned none value" - if '' in canons: - return "Canon returned empty str value" - - # Ensure it never returns None - in_str, _ = get_mc_canonical_name('') - if in_str != '': - return "Empty str should return empty string, not" + str(in_str) - - did_raise = False - try: - get_mc_canonical_name(None) - except Exception: - did_raise = True - if not did_raise: - return "None input SHOULD raise error" - - # TODO: patch conf.json_data["blocks"] used by addon if possible, - # if this is transformed into a true py unit test. This will help - # check against report (-MNGGQfGGTJRqoizVCer) - - def canonical_test_mappings(self): - """Test some specific mappings to ensure they return correctly.""" - from MCprep.materials.generate import get_mc_canonical_name - - misc = { - ".emit": ".emit", - } - jmc_to_canon = { - "grass": "grass", - "mushroom_red": "red_mushroom", - # "slime": "slime_block", # KNOWN jmc, need to address - } - mineways_to_canon = {} - - for map_type in [misc, jmc_to_canon, mineways_to_canon]: - for key, val in map_type.items(): - res, mapped = get_mc_canonical_name(key) - if res == val: - continue - return "Wrong mapping: {} mapped to {} ({}), not {}".format( - key, res, mapped, val) - - def meshswap_util(self, mat_name): - """Run meshswap on the first object with found mat_name""" - from MCprep.util import select_set - - if mat_name not in bpy.data.materials: - return "Not a material: " + mat_name - print("\nAttempt meshswap of " + mat_name) - mat = bpy.data.materials[mat_name] - - obj = None - for ob in bpy.data.objects: - for slot in ob.material_slots: - if slot and slot.material == mat: - obj = ob - break - if obj: - break - if not obj: - return "Failed to find obj for " + mat_name - print("Found the object - " + obj.name) - - bpy.ops.object.select_all(action='DESELECT') - select_set(obj, True) - res = bpy.ops.mcprep.meshswap() - if res != {'FINISHED'}: - return "Meshswap returned cancelled for " + mat_name - - def meshswap_spawner(self): - """Tests direct meshswap spawning""" - self._clear_scene() - scn_props = bpy.context.scene.mcprep_props - bpy.ops.mcprep.reload_meshswap() - if not scn_props.meshswap_list: - return "No meshswap assets loaded for spawning" - elif len(scn_props.meshswap_list) < 15: - return "Too few meshswap assets available" - - if bpy.app.version >= (2, 80): - # Add with make real = False - bpy.ops.mcprep.meshswap_spawner( - block='banner', method="collection", make_real=False) - - # test doing two of the same one (first won't be cached, second will) - # Add one with make real = True - bpy.ops.mcprep.meshswap_spawner( - block='fire', method="collection", make_real=True) - if 'fire' not in bpy.data.collections: - return "Fire not in collections" - elif not bpy.context.selected_objects: - return "Added made-real meshswap objects not selected" - - # Test that cache is properly used. Also test that the default - # 'method=colleciton' is used, since that's the only mode of - # support for meshswap spawner at the moment. - bpy.ops.mcprep.meshswap_spawner(block='fire', make_real=False) - if 'fire' not in bpy.data.collections: - return "Fire not in collections" - count = sum([1 for itm in bpy.data.collections if 'fire' in itm.name]) - if count != 1: - return "Imported extra fire group, should have cached instead!" - - # test that added item ends up in location location=(1,2,3) - loc = (1, 2, 3) - bpy.ops.mcprep.meshswap_spawner( - block='fire', method="collection", make_real=False, location=loc) - if not bpy.context.object: - return "Added meshswap object not added as active" - elif not bpy.context.selected_objects: - return "Added meshswap object not selected" - if bpy.context.object.location != Vector(loc): - return "Location not properly applied" - count = sum([1 for itm in bpy.data.collections if 'fire' in itm.name]) - if count != 1: - return "Should have 1 fire groups exactly, did not cache" - else: - # Add with make real = False - bpy.ops.mcprep.meshswap_spawner( - block='banner', method="collection", make_real=False) - - # test doing two of the same one (first won't be cached, second will) - # Add one with make real = True - bpy.ops.mcprep.meshswap_spawner( - block='fire', method="collection", make_real=True) - if 'fire' not in bpy.data.groups: - return "Fire not in groups" - elif not bpy.context.selected_objects: - return "Added made-real meshswap objects not selected" - - bpy.ops.mcprep.meshswap_spawner( - block='fire', method="collection", make_real=False) - if 'fire' not in bpy.data.groups: - return "Fire not in groups" - count_torch = sum([1 for itm in bpy.data.groups if 'fire' in itm.name]) - if count_torch != 1: - return "Imported extra fire group, should have cached instead!" - - # test that added item ends up in location location=(1,2,3) - loc = (1, 2, 3) - bpy.ops.mcprep.meshswap_spawner( - block='fire', method="collection", make_real=False, location=loc) - if not bpy.context.object: - return "Added meshswap object not added as active" - elif not bpy.context.selected_objects: - return "Added meshswap object not selected" - if bpy.context.object.location != Vector(loc): - return "Location not properly applied" - - def meshswap_jmc2obj(self): - """Tests jmc2obj meshswapping""" - self._clear_scene() - self._import_jmc2obj_full() - self._set_exporter('jmc2obj') - - # known jmc2obj material names which we expect to be able to meshswap - test_materials = [ - "torch", - "fire", - "lantern", - "cactus_side", - "vines", # plural - "enchant_table_top", - "redstone_torch_on", - "glowstone", - "redstone_lamp_on", - "pumpkin_front_lit", - "sugarcane", - "chest", - "largechest", - "sunflower_bottom", - "sapling_birch", - "white_tulip", - "sapling_oak", - "sapling_acacia", - "sapling_jungle", - "blue_orchid", - "allium", - ] - - errors = [] - for mat_name in test_materials: - try: - res = self.meshswap_util(mat_name) - except Exception as err: - err = str(err) - if len(err) > 15: - res = err[:15].replace("\n", "") - else: - res = err - if res: - errors.append(mat_name + ":" + res) - if errors: - return "Meshswap failed: " + ", ".join(errors) - - def meshswap_mineways_separated(self): - """Tests jmc2obj meshswapping""" - self._clear_scene() - self._import_mineways_separated() - self._set_exporter('Mineways') - - # known Mineways (separated) material names expected for meshswap - test_materials = [ - "grass", - "torch", - "fire_0", - "MWO_chest_top", - "MWO_double_chest_top_left", - # "lantern", not in test object - "cactus_side", - "vine", # singular - "enchanting_table_top", - # "redstone_torch_on", no separate "on" for Mineways separated exports - "glowstone", - "redstone_torch", - "jack_o_lantern", - "sugar_cane", - "jungle_sapling", - "dark_oak_sapling", - "oak_sapling", - "campfire_log", - "white_tulip", - "blue_orchid", - "allium", - ] - - errors = [] - for mat_name in test_materials: - try: - res = self.meshswap_util(mat_name) - except Exception as err: - err = str(err) - if len(err) > 15: - res = err[:15].replace("\n", "") - else: - res = err - if res: - errors.append(mat_name + ":" + res) - if errors: - return "Meshswap failed: " + ", ".join(errors) - - def meshswap_mineways_combined(self): - """Tests jmc2obj meshswapping""" - self._clear_scene() - self._import_mineways_combined() - self._set_exporter('Mineways') - - # known Mineways (separated) material names expected for meshswap - test_materials = [ - "Sunflower", - "Torch", - "Redstone_Torch_(active)", - "Lantern", - "Dark_Oak_Sapling", - "Sapling", # should map to oak sapling - "Birch_Sapling", - "Cactus", - "White_Tulip", - "Vines", - "Ladder", - "Enchanting_Table", - "Campfire", - "Jungle_Sapling", - "Red_Tulip", - "Blue_Orchid", - "Allium", - ] - - errors = [] - for mat_name in test_materials: - try: - res = self.meshswap_util(mat_name) - except Exception as err: - err = str(err) - if len(err) > 15: - res = err[:15].replace("\n", "") - else: - res = err - if res: - errors.append(mat_name + ":" + res) - if errors: - return "Meshswap combined failed: " + ", ".join(errors) - - def detect_desaturated_images(self): - """Checks the desaturate images function works""" - from MCprep.materials.generate import is_image_grayscale - - base = self.get_mcprep_path() - print("Raw base", base) - base = os.path.join( - base, "MCprep_resources", "resourcepacks", "mcprep_default", - "assets", "minecraft", "textures", "block") - print("Remapped base: ", base) - - # known images that ARE desaturated: - desaturated = [ - "grass_block_top.png" - ] - saturated = [ - "grass_block_side.png", - "glowstone.png" - ] - - for tex in saturated: - img = bpy.data.images.load(os.path.join(base, tex)) - if not img: - raise Exception('Failed to load img ' + str(tex)) - if is_image_grayscale(img) is True: - raise Exception( - 'Image {} detected as grayscale, should be saturated'.format(tex)) - for tex in desaturated: - img = bpy.data.images.load(os.path.join(base, tex)) - if not img: - raise Exception('Failed to load img ' + str(tex)) - if is_image_grayscale(img) is False: - raise Exception( - 'Image {} detected as saturated - should be grayscale'.format(tex)) - - # test that it is caching as expected.. by setting a false - # value for cache flag and seeing it's returning the property value - - def detect_extra_passes(self): - """Ensure only the correct pbr file matches are found for input file""" - from MCprep.materials.generate import find_additional_passes - - tmp_dir = tempfile.gettempdir() - - # physically generate these empty files, then delete - tmp_files = [ - "oak_log_top.png", - "oak_log_top-s.png", - "oak_log_top_n.png", - "oak_log.jpg", - "oak_log_s.jpg", - "oak_log_n.jpeg", - "oak_log_disp.jpeg", - "stonecutter_saw.tiff", - "stonecutter_saw n.tiff" - ] - - for tmp in tmp_files: - fname = os.path.join(tmp_dir, tmp) - with open(fname, 'a'): - os.utime(fname) - - def cleanup(): - """Failsafe delete files before raising error within test method""" - for tmp in tmp_files: - try: - os.remove(os.path.join(tmp_dir, tmp)) - except Exception: - pass - - # assert setup was successful - for tmp in tmp_files: - if os.path.isfile(os.path.join(tmp_dir, tmp)): - continue - cleanup() - raise Exception("Failed to generate test empty files") - - # the test cases; input is diffuse, output is the whole dict - cases = [ - { - "diffuse": os.path.join(tmp_dir, "oak_log_top.png"), - "specular": os.path.join(tmp_dir, "oak_log_top-s.png"), - "normal": os.path.join(tmp_dir, "oak_log_top_n.png"), - }, { - "diffuse": os.path.join(tmp_dir, "oak_log.jpg"), - "specular": os.path.join(tmp_dir, "oak_log_s.jpg"), - "normal": os.path.join(tmp_dir, "oak_log_n.jpeg"), - "displace": os.path.join(tmp_dir, "oak_log_disp.jpeg"), - }, { - "diffuse": os.path.join(tmp_dir, "stonecutter_saw.tiff"), - "normal": os.path.join(tmp_dir, "stonecutter_saw n.tiff"), - } - ] - - for test in cases: - res = find_additional_passes(test["diffuse"]) - if res != test: - cleanup() - # for debug readability, basepath everything - for itm in res: - res[itm] = os.path.basename(res[itm]) - for itm in test: - test[itm] = os.path.basename(test[itm]) - raise Exception("Mismatch for set {}: got {} but expected {}".format( - test["diffuse"], res, test)) - - # test other cases intended to fail - res = find_additional_passes(os.path.join(tmp_dir, "not_a_file.png")) - if res != {}: - cleanup() - raise Exception("Fake file should not have any return") - - def _qa_helper(self, basepath, allow_packed): - """File used to help QC an open blend file.""" - - # bpy.ops.file.make_paths_relative() instead of this, do manually. - different_base = [] - not_relative = [] - missing = [] - for img in bpy.data.images: - if not img.filepath: - continue - abspath = os.path.abspath(bpy.path.abspath(img.filepath)) - if not abspath.startswith(basepath): - if allow_packed is True and img.packed_file: - pass - else: - different_base.append(os.path.basename(img.filepath)) - if img.filepath != bpy.path.relpath(img.filepath): - not_relative.append(os.path.basename(img.filepath)) - if not os.path.isfile(abspath): - if allow_packed is True and img.packed_file: - pass - else: - missing.append(img.name) - - if len(different_base) > 50: - return "Wrong basepath for image filepath comparison!" - if different_base: - return "Found {} images with different basepath from file: {}".format( - len(different_base), ", ".join(different_base)) - if not_relative: - return "Found {} non relative img files: {}".format( - len(not_relative), ", ".join(not_relative)) - if missing: - return "Found {} img with missing source files: {}".format( - len(missing), ", ".join(missing)) - - def qa_meshswap_file(self): - """Open the meshswap file, assert there are no relative paths""" - basepath = os.path.join("MCprep_addon", "MCprep_resources") - basepath = os.path.abspath(basepath) # relative to the dev git folder - blendfile = os.path.join(basepath, "mcprep_meshSwap.blend") - if not os.path.isfile(blendfile): - return blendfile + ": missing tests dir local meshswap file" - bpy.ops.wm.open_mainfile(filepath=blendfile) - # do NOT save this file! - - resp = self._qa_helper(basepath, allow_packed=False) - if resp: - return resp - - # detect any non canonical material names?? how to exclude? - - # Affirm that no materials have a principled node, should be basic only - - def item_spawner(self): - """Test item spawning and reloading""" - self._clear_scene() - scn_props = bpy.context.scene.mcprep_props - - pre_items = len(scn_props.item_list) - bpy.ops.mcprep.reload_items() - post_items = len(scn_props.item_list) - - if pre_items != 0: - return "Should have opened new file with unloaded assets?" - elif post_items == 0: - return "No items loaded" - elif post_items < 50: - return "Too few items loaded, missing texturepack?" - - # spawn with whatever default index - pre_objs = len(bpy.data.objects) - bpy.ops.mcprep.spawn_item() - post_objs = len(bpy.data.objects) - - if post_objs == pre_objs: - return "No items spawned" - elif post_objs > pre_objs + 1: - return "More than one item spawned" - - # test core useage on a couple of out of the box textures - - # test once with custom block - # bpy.ops.mcprep.spawn_item_file(filepath=) - - # test with different thicknesses - # test after changing resource pack - # test that with an image of more than 1k pixels, it's truncated as expected - # test with different - - def item_spawner_resize(self): - """Test spawning an item that requires resizing.""" - self._clear_scene() - bpy.ops.mcprep.reload_items() - - # Create a tmp file - tmp_img = bpy.data.images.new("tmp_item_spawn", 32, 32, alpha=True) - tmp_img.filepath = os.path.join(bpy.app.tempdir, "tmp_item.png") - tmp_img.save() - - # spawn with whatever default index - pre_objs = len(bpy.data.objects) - bpy.ops.mcprep.spawn_item_file( - max_pixels=16, - filepath=tmp_img.filepath - ) - post_objs = len(bpy.data.objects) - - if post_objs == pre_objs: - return "No items spawned" - elif post_objs > pre_objs + 1: - return "More than one item spawned" - - # Now check that this item spawned has the expected face count. - obj = bpy.context.object - polys = len(obj.data.polygons) - print("Poly's count: ", polys) - if polys > 16: - return "Didn't scale enough to fewer pixels, facecount: " + str(polys) - elif polys < 16: - return "Over-scaled down facecount: " + str(polys) - - def entity_spawner(self): - """Test entity spawning and reloading""" - self._clear_scene() - scn_props = bpy.context.scene.mcprep_props - - pre_count = len(scn_props.entity_list) - bpy.ops.mcprep.reload_entities() - post_count = len(scn_props.entity_list) - - if pre_count != 0: - return "Should have opened new file with unloaded assets?" - elif post_count == 0: - return "No entities loaded" - elif post_count < 5: - return "Too few entities loaded ({}), missing texturepack?".format( - post_count - pre_count) - - # spawn with whatever default index - pre_objs = len(bpy.data.objects) - bpy.ops.mcprep.entity_spawner() - post_objs = len(bpy.data.objects) - - if post_objs == pre_objs: - return "No entity spawned" - - # Test collection/group added - # Test loading from file. - - def model_spawner(self): - """Test model spawning and reloading""" - self._clear_scene() - scn_props = bpy.context.scene.mcprep_props - - pre_count = len(scn_props.model_list) - bpy.ops.mcprep.reload_models() - post_count = len(scn_props.model_list) - - if pre_count != 0: - return "Should have opened new file with unloaded assets?" - elif post_count == 0: - return "No models loaded" - elif post_count < 50: - return "Too few models loaded, missing texturepack?" - - # spawn with whatever default index - pre_objs = list(bpy.data.objects) - bpy.ops.mcprep.spawn_model( - filepath=scn_props.model_list[scn_props.model_list_index].filepath) - post_objs = bpy.data.objects - - if len(post_objs) == len(pre_objs): - return "No models spawned" - elif len(post_objs) > len(pre_objs) + 1: - return "More than one model spawned" - - # Test that materials were properly added. - new_objs = list(set(post_objs) - set(pre_objs)) - model = new_objs[0] - if not model.active_material: - return "No material on model" - - # TODO: fetch/check there being a texture. - - # Test collection/group added - # Test loading from file. - - def geonode_effect_spawner(self): - """Test the geo node variant of effect spawning works.""" - if bpy.app.version < (3, 0): - return # Not supported before 3.0 anyways. - self._clear_scene() - scn_props = bpy.context.scene.mcprep_props - etype = "geo_area" - - pre_count = len([ - x for x in scn_props.effects_list if x.effect_type == etype]) - bpy.ops.mcprep.reload_effects() - post_count = len([ - x for x in scn_props.effects_list if x.effect_type == etype]) - - if pre_count != 0: - return "Should start with no effects loaded" - if post_count == 0: - return "Should have more effects loaded after reload" - - effect = [x for x in scn_props.effects_list if x.effect_type == etype][0] - res = bpy.ops.mcprep.spawn_global_effect(effect_id=str(effect.index)) - if res != {'FINISHED'}: - return "Did not end with finished result" - - # TODO: Further checks it actually loaded the effect. - # Check that the geonode inputs are updated. - obj = bpy.context.object - if not obj: - return "Geo node added object not selected" - - geo_nodes = [mod for mod in obj.modifiers if mod.type == "NODES"] - if not geo_nodes: - return "No geonode modifier found" - - # Now validate that one of the settings was updated. - # TODO: example where we assert the active effect `subpath` is non empty - - def particle_area_effect_spawner(self): - """Test the particle area variant of effect spawning works.""" - self._clear_scene() - scn_props = bpy.context.scene.mcprep_props - etype = "particle_area" - - pre_count = len([ - x for x in scn_props.effects_list if x.effect_type == etype]) - bpy.ops.mcprep.reload_effects() - post_count = len([ - x for x in scn_props.effects_list if x.effect_type == etype]) - - if pre_count != 0: - return "Should start with no effects loaded" - if post_count == 0: - return "Should have more effects loaded after reload" - - effect = [x for x in scn_props.effects_list if x.effect_type == etype][0] - res = bpy.ops.mcprep.spawn_global_effect(effect_id=str(effect.index)) - if res != {'FINISHED'}: - return "Did not end with finished result" - - # TODO: Further checks it actually loaded the effect. - - def collection_effect_spawner(self): - """Test the collection variant of effect spawning works.""" - self._clear_scene() - scn_props = bpy.context.scene.mcprep_props - etype = "collection" - - pre_count = len([ - x for x in scn_props.effects_list if x.effect_type == etype]) - bpy.ops.mcprep.reload_effects() - post_count = len([ - x for x in scn_props.effects_list if x.effect_type == etype]) - - if pre_count != 0: - return "Should start with no effects loaded" - if post_count == 0: - return "Should have more effects loaded after reload" - - init_objs = list(bpy.data.objects) - - effect = [x for x in scn_props.effects_list if x.effect_type == etype][0] - res = bpy.ops.mcprep.spawn_instant_effect(effect_id=str(effect.index), frame=2) - if res != {'FINISHED'}: - return "Did not end with finished result" - - final_objs = list(bpy.data.objects) - new_objs = list(set(final_objs) - set(init_objs)) - if len(new_objs) == 0: - return "didn't crate new objects" - if bpy.context.object not in new_objs: - return "Selected obj is not a new object" - - is_empty = bpy.context.object.type == 'EMPTY' - is_coll_inst = bpy.context.object.instance_type == 'COLLECTION' - if not is_empty or not is_coll_inst: - return "Didn't end up with selected collection instance" - - # TODO: Further checks it actually loaded the effect. - - def img_sequence_effect_spawner(self): - """Test the image sequence variant of effect spawning works.""" - if bpy.app.version < (2, 81): - return "Disabled due to consistent crashing" - self._clear_scene() - scn_props = bpy.context.scene.mcprep_props - etype = "img_seq" - - pre_count = len([ - x for x in scn_props.effects_list if x.effect_type == etype]) - bpy.ops.mcprep.reload_effects() - post_count = len([ - x for x in scn_props.effects_list if x.effect_type == etype]) - - if pre_count != 0: - return "Should start with no effects loaded" - if post_count == 0: - return "Should have more effects loaded after reload" - - # Find the one with at least 10 frames. - effect_name = "Big smoke" - effect = None - for this_effect in scn_props.effects_list: - if this_effect.effect_type != etype: - continue - if this_effect.name == effect_name: - effect = this_effect - - if not effect: - return "Failed to fetch {} target effect".format(effect_name) - - t0 = time.time() - res = bpy.ops.mcprep.spawn_instant_effect(effect_id=str(effect.index)) - if res != {'FINISHED'}: - return "Did not end with finished result" - - t1 = time.time() - if t1 - t0 > 1.0: - return "Spawning took over 1 second for sequence effect" - - # TODO: Further checks it actually loaded the effect. - - def particle_plane_effect_spawner(self): - """Test the particle plane variant of effect spawning works.""" - self._clear_scene() - - filepath = os.path.join( - bpy.context.scene.mcprep_texturepack_path, - "assets", "minecraft", "textures", "block", "dirt.png") - res = bpy.ops.mcprep.spawn_particle_planes(filepath=filepath) - - if res != {'FINISHED'}: - return "Did not end with finished result" - - # TODO: Further checks it actually loaded the effect. - - def world_tools(self): - """Test adding skies, prepping the world, etc""" - from MCprep.world_tools import get_time_object - - # test with both engines (cycles+eevee, or cycles+internal) - self._clear_scene() - bpy.ops.mcprep.world() - - pre_objs = len(bpy.data.objects) - bpy.ops.mcprep.add_mc_sky( - world_type='world_shader', - # initial_time='8', - add_clouds=True, - remove_existing_suns=True) - post_objs = len(bpy.data.objects) - if pre_objs >= post_objs: - return "Nothing added" - # find the sun, ensure it's pointed partially to the side - obj = get_time_object() - if not obj: - return "No detected MCprepHour controller (a)" - - self._clear_scene() - pre_objs = len(bpy.data.objects) - bpy.ops.mcprep.add_mc_sky( - world_type='world_mesh', - # initial_time='12', - add_clouds=False, - remove_existing_suns=True) - post_objs = len(bpy.data.objects) - if pre_objs >= post_objs: - return "Nothing added" - # find the sun, ensure it's pointed straight down - obj = get_time_object() - if not obj: - return "No detected MCprepHour controller (b)" - - self._clear_scene() - pre_objs = len(bpy.data.objects) - bpy.ops.mcprep.add_mc_sky( - world_type='world_only', - # initial_time='18', - add_clouds=False, - remove_existing_suns=True) - post_objs = len(bpy.data.objects) - if pre_objs >= post_objs: - return "Nothing added" - # find the sun, ensure it's pointed straight down - obj = get_time_object() - if not obj: - return "No detected MCprepHour controller (c)" - - self._clear_scene() - pre_objs = len(bpy.data.objects) - bpy.ops.mcprep.add_mc_sky( - world_type='world_static_mesh', - # initial_time='0', - add_clouds=False, - remove_existing_suns=True) - post_objs = len(bpy.data.objects) - if pre_objs >= post_objs: - return "Nothing added" - # find the sun, ensure it's pointed straight down - - self._clear_scene() - obj = get_time_object() - if obj: - return "Found MCprepHour controller, shouldn't be one (d)" - pre_objs = len(bpy.data.objects) - bpy.ops.mcprep.add_mc_sky( - world_type='world_static_only', - # initial_time='6', - add_clouds=False, - remove_existing_suns=True) - post_objs = len(bpy.data.objects) - if pre_objs >= post_objs: - return "Nothing added" - # test that it removes existing suns by first placing one, and then - # affirming it's gone - - def sync_materials(self): - """Test syncing materials works""" - self._clear_scene() - - # test empty case - res = bpy.ops.mcprep.sync_materials( - link=False, - replace_materials=False, - skipUsage=True) # track here false to avoid error - if res != {'CANCELLED'}: - return "Should return cancel in empty scene" - - # test that the base test material is included as shipped - bpy.ops.mesh.primitive_plane_add() - obj = bpy.context.object - if bpy.app.version >= (2, 80): - obj.select_set(True) - else: - obj.select = True - - new_mat = bpy.data.materials.new("mcprep_test") - obj.active_material = new_mat - - init_mats = bpy.data.materials[:] - init_len = len(bpy.data.materials) - res = bpy.ops.mcprep.sync_materials( - link=False, - replace_materials=False) - if res != {'FINISHED'}: - return "Should return finished with test file" - - # check there is another material now - imported = set(bpy.data.materials[:]) - set(init_mats) - post_len = len(bpy.data.materials) - if not list(imported): - return "No new materials found" - elif len(list(imported)) > 1: - return "More than one material generated" - elif list(imported)[0].library: - return "Material linked should not be a library" - elif post_len - 1 != init_len: - return "Should have imported specifically one material" - - new_mat.name = "mcprep_test" - init_len = len(bpy.data.materials) - res = bpy.ops.mcprep.sync_materials( - link=False, - replace_materials=True) - if res != {'FINISHED'}: - return "Should return finished with test file (replace)" - if len(bpy.data.materials) != init_len: - return "Number of materials should not have changed with replace" - - # Now test it works with name generalization, stripping .### - new_mat.name = "mcprep_test.005" - init_mats = bpy.data.materials[:] - init_len = len(bpy.data.materials) - res = bpy.ops.mcprep.sync_materials( - link=False, - replace_materials=False) - if res != {'FINISHED'}: - return "Should return finished with test file" - - # check there is another material now - imported = set(bpy.data.materials[:]) - set(init_mats) - post_len = len(bpy.data.materials) - if not list(imported): - return "No new materials found" - elif len(list(imported)) > 1: - return "More than one material generated" - elif list(imported)[0].library: - return "Material linked should not be a library" - elif post_len - 1 != init_len: - return "Should have imported specifically one material" - - def sync_materials_link(self): - """Test syncing materials works""" - self._clear_scene() - - # test that the base test material is included as shipped - bpy.ops.mesh.primitive_plane_add() - obj = bpy.context.object - if bpy.app.version >= (2, 80): - obj.select_set(True) - else: - obj.select = True - - new_mat = bpy.data.materials.new("mcprep_test") - obj.active_material = new_mat - - new_mat.name = "mcprep_test" - init_mats = bpy.data.materials[:] - res = bpy.ops.mcprep.sync_materials( - link=True, - replace_materials=False) - if res != {'FINISHED'}: - return "Should return finished with test file (link)" - imported = set(bpy.data.materials[:]) - set(init_mats) - imported = list(imported) - if not imported: - return "No new material found after linking" - if not list(imported)[0].library: - return "Material linked is not a library" - - def load_material(self): - """Test the load material operators and related resets""" - self._clear_scene() - bpy.ops.mcprep.reload_materials() - - # add object - bpy.ops.mesh.primitive_cube_add() - - scn_props = bpy.context.scene.mcprep_props - itm = scn_props.material_list[scn_props.material_list_index] - path = itm.path - bpy.ops.mcprep.load_material(filepath=path) - - # validate that the loaded material has a name matching current list - mat = bpy.context.object.active_material - scn_props = bpy.context.scene.mcprep_props - mat_item = scn_props.material_list[scn_props.material_list_index] - if mat_item.name not in mat.name: - return "Material name not loaded " + mat.name - - def uv_transform_detection(self): - """Ensure proper detection and transforms for Mineways all-in-one images""" - from MCprep.materials.uv_tools import get_uv_bounds_per_material - self._clear_scene() - - bpy.ops.mesh.primitive_cube_add() - bpy.ops.object.editmode_toggle() - bpy.ops.mesh.select_all(action='SELECT') - bpy.ops.uv.reset() - bpy.ops.object.editmode_toggle() - new_mat = bpy.data.materials.new(name="tmp") - bpy.context.object.active_material = new_mat - - if not bpy.context.object or not bpy.context.object.active_material: - return "Failed set up for uv_transform_detection" - - uv_bounds = get_uv_bounds_per_material(bpy.context.object) - mname = bpy.context.object.active_material.name - if uv_bounds != {mname: [0, 1, 0, 1]}: - return "UV transform for default cube should have max bounds" - - bpy.ops.object.editmode_toggle() - bpy.ops.mesh.select_all(action='SELECT') - bpy.ops.uv.sphere_project() # will ensure more irregular UV map, not bounded - bpy.ops.object.editmode_toggle() - uv_bounds = get_uv_bounds_per_material(bpy.context.object) - if uv_bounds == {mname: [0, 1, 0, 1]}: - return "UV mapping is irregular, should have different min/max" - - def uv_transform_no_alert(self): - """Ensure that uv transform alert does not go off unexpectedly""" - from MCprep.materials.uv_tools import detect_invalid_uvs_from_objs - self._clear_scene() - - self._import_mineways_separated() - invalid, invalid_objs = detect_invalid_uvs_from_objs( - bpy.context.selected_objects) - prt = ",".join([obj.name for obj in invalid_objs]) # obj.name.split("_")[-1] - if invalid is True: - return "Mineways separated tiles export should not alert: " + prt - - self._clear_scene() - self._import_jmc2obj_full() - invalid, invalid_objs = detect_invalid_uvs_from_objs( - bpy.context.selected_objects) - prt = ",".join([obj.name.split("_")[-1] for obj in invalid_objs]) - if invalid is True: - return "jmc2obj export should not alert: " + prt - - def uv_transform_combined_alert(self): - """Ensure that uv transform alert goes off for Mineways all-in-one""" - from MCprep.materials.uv_tools import detect_invalid_uvs_from_objs - self._clear_scene() - - self._import_mineways_combined() - invalid, invalid_objs = detect_invalid_uvs_from_objs( - bpy.context.selected_objects) - if invalid is False: - return "Combined image export should alert" - if not invalid_objs: - return "Correctly alerted combined image, but no obj's returned" - - # Do specific checks for water and lava, since they might be combined - # and cover more than one uv position (and falsely pass the test) - # in combined, water is called "Stationary_Wat" and "Stationary_Lav" - # (yes, appears cutoff; and yes includes the flowing too) - # NOTE! in 2.7x, will be named "Stationary_Water", but in 2.9 it is - # "Test_MCprep_1.16.4__-145_4_1271_to_-118_255_1311_Stationary_Wat" - water_obj = [ - obj for obj in bpy.data.objects if "Stationary_Wat" in obj.name][0] - lava_obj = [ - obj for obj in bpy.data.objects if "Stationary_Lav" in obj.name][0] - - invalid, invalid_objs = detect_invalid_uvs_from_objs([lava_obj, water_obj]) - if invalid is False: - return "Combined lava/water should still alert" - - def test_generate_material_sequence(self): - """Ensure that generating an image sequence works as expected.""" - from MCprep.materials.sequences import generate_material_sequence - - # ALT: call animate_single_material - # animate_single_material( - # mat, engine, export_location='original', clear_cache=False) - - tiled_img = os.path.join( - os.path.dirname(__file__), - "test_resource_pack", "textures", "campfire_fire.png") - fake_orig_img = tiled_img - result_dir = tiled_img[:-4] # Same name, sans extension. - - try: - shutil.rmtree(result_dir) - except Exception as err: - print("Error removing prior directory of animated campfire") - print(err) - - # Ensure that the folder does not initially exist - if os.path.isdir(result_dir): - return "Folder pre-exists, should be removed before test" - - res, err = generate_material_sequence( - source_path=fake_orig_img, - image_path=tiled_img, - form=None, - export_location="original", - clear_cache=True) - - if err: - return "Generate materials had an error: " + str(err) - if not res: - return "Failed to get success resposne from generate img sequence" - - if not os.path.isdir(result_dir): - return "Output directory does not exist" - - gen_files = [img for img in os.listdir(result_dir) if img.endswith(".png")] - if not gen_files: - return "No images generated" - - # Now do cleanup. - shutil.rmtree(result_dir) - - def test_enable_obj_importer(self): - """Ensure module name is correct, since error won't be reported.""" - bpy.ops.preferences.addon_enable(module="io_scene_obj") - - def qa_effects(self): - """Ensures that effects files meet all QA needs""" - - basepath = os.path.join("MCprep_addon", "MCprep_resources", "effects") - basepath = os.path.abspath(basepath) # relative to the dev git folder - - bfiles = [] - for child in os.listdir(basepath): - fullp = os.path.join(basepath, child) - if os.path.isfile(fullp) and child.lower().endswith(".blend"): - bfiles.append(fullp) - elif not os.path.isdir(fullp): - continue - subblends = [ - os.path.join(basepath, child, blend) - for blend in os.listdir(os.path.join(basepath, child)) - if os.path.isfile(os.path.join(basepath, child, blend)) - and blend.lower().endswith(".blend") - ] - bfiles.extend(subblends) - - print("Checking blend files") - if not bfiles: - return "No files loaded for bfiles" - - issues = [] - checked = 0 - for blend in bfiles: - print("QC'ing:", blend) - if not os.path.isfile(blend): - return "Did not exist: " + str(blend) - bpy.ops.wm.open_mainfile(filepath=blend) - - resp = self._qa_helper(basepath, allow_packed=True) - if resp: - issues.append([blend, resp]) - checked += 1 - - if issues: - return issues - - def qa_rigs(self): - """Ensures that all rig files meet all QA needs. - - NOTE: This test is actually surpisingly fast given the number of files - it needs to check against, but it's possible it can be unstable. Exit - early to override if needed. - """ - - basepath = os.path.join("MCprep_addon", "MCprep_resources", "rigs") - basepath = os.path.abspath(basepath) # relative to the dev git folder - - bfiles = [] - for child in os.listdir(basepath): - fullp = os.path.join(basepath, child) - if os.path.isfile(fullp) and child.lower().endswith(".blend"): - bfiles.append(fullp) - elif not os.path.isdir(fullp): - continue - subblends = [ - os.path.join(basepath, child, blend) - for blend in os.listdir(os.path.join(basepath, child)) - if os.path.isfile(os.path.join(basepath, child, blend)) - and blend.lower().endswith(".blend") - ] - bfiles.extend(subblends) - - print("Checking blend files") - if not bfiles: - return "No files loaded for bfiles" - - issues = [] - checked = 0 - for blend in bfiles: - print("QC'ing:", blend) - if not os.path.isfile(blend): - return "Did not exist: " + str(blend) - bpy.ops.wm.open_mainfile(filepath=blend) - - resp = self._qa_helper(basepath, allow_packed=True) - if resp: - issues.append([blend, resp]) - checked += 1 - - if issues: - return "Checked {} rigs, issues: {}".format( - checked, issues) - - def convert_mtl_simple(self): - """Ensures that conversion of the mtl with other color space works.""" - from MCprep import world_tools - - src = "mtl_simple_original.mtl" - end = "mtl_simple_modified.mtl" - test_dir = os.path.dirname(__file__) - simple_mtl = os.path.join(test_dir, src) - modified_mtl = os.path.join(test_dir, end) - - # now save the texturefile somewhere - tmp_dir = tempfile.gettempdir() - tmp_mtl = os.path.join(tmp_dir, src) - shutil.copyfile(simple_mtl, tmp_mtl) # leave original intact - - if not os.path.isfile(tmp_mtl): - return "Failed to create tmp tml at " + tmp_mtl - - # Need to mock: - # bpy.context.scene.view_settings.view_transform - # to be an invalid kind of attribute, to simulate an ACES or AgX space. - # But we can't do that since we're not (yet) using the real unittest - # framework, hence we'll just clear the world_tool's vars. - save_init = list(world_tools.BUILTIN_SPACES) - world_tools.BUILTIN_SPACES = ["NotRealSpace"] - print("TEST: pre", world_tools.BUILTIN_SPACES) - - # Resultant file - res = world_tools.convert_mtl(tmp_mtl) - - # Restore the property we unset. - world_tools.BUILTIN_SPACES = save_init - print("TEST: post", world_tools.BUILTIN_SPACES) - - if res is None: - return "Failed to mock color space and thus could not test convert_mtl" - - if res is False: - return "Convert mtl failed with false response" - - # Now check that the data is the same. - res = filecmp.cmp(tmp_mtl, modified_mtl, shallow=False) - if res is not True: - # Not removing file, since we likely want to inspect it. - return "Generated MTL is different: {} vs {}".format( - tmp_mtl, modified_mtl) - else: - os.remove(tmp_mtl) - - def convert_mtl_skip(self): - """Ensures that we properly skip if a built in space active.""" - from MCprep import world_tools - - src = "mtl_simple_original.mtl" - test_dir = os.path.dirname(__file__) - simple_mtl = os.path.join(test_dir, src) - - # now save the texturefile somewhere - tmp_dir = tempfile.gettempdir() - tmp_mtl = os.path.join(tmp_dir, src) - shutil.copyfile(simple_mtl, tmp_mtl) # leave original intact - - if not os.path.isfile(tmp_mtl): - return "Failed to create tmp tml at " + tmp_mtl - - # Need to mock: - # bpy.context.scene.view_settings.view_transform - # to be an invalid kind of attribute, to simulate an ACES or AgX space. - # But we can't do that since we're not (yet) using the real unittest - # framework, hence we'll just clear the world_tool's vars. - actual_space = str(bpy.context.scene.view_settings.view_transform) - save_init = list(world_tools.BUILTIN_SPACES) - world_tools.BUILTIN_SPACES = [actual_space] - print("TEST: pre", world_tools.BUILTIN_SPACES) - - # Resultant file - res = world_tools.convert_mtl(tmp_mtl) - - # Restore the property we unset. - world_tools.BUILTIN_SPACES = save_init - print("TEST: post", world_tools.BUILTIN_SPACES) - - if res is not None: - os.remove(tmp_mtl) - return "Should not have converter MTL for valid space" - - -class OCOL: - """override class for colors, for terminals not supporting color-out""" - HEADER = '' - OKBLUE = '' - OKGREEN = '' - WARNING = '[WARN]' - FAIL = '[ERR]' - ENDC = '' - BOLD = '' - UNDERLINE = '' - - -class COL: - """native_colors to use, if terminal supports color out""" - HEADER = '\033[95m' - OKBLUE = '\033[94m' - OKGREEN = '\033[92m' - WARNING = '\033[93m' - FAIL = '\033[91m' - ENDC = '\033[0m' - BOLD = '\033[1m' - UNDERLINE = '\033[4m' - - -def suffix_chars(string, max_char): - """Returns passed string or the last max_char characters if longer""" - string = string.replace('\n', '\\n ') - string = string.replace(',', ' ') - if len(string) > max_char: - return string[-max_char:] - return string - -# ----------------------------------------------------------------------------- -# Testing file, to semi-auto check addon functionality is working -# Run this as a script, which creates a temp MCprep - test panel -# and cycle through all tests. -# ----------------------------------------------------------------------------- - - -class MCPTEST_OT_test_run(bpy.types.Operator): - bl_label = "MCprep run test" - bl_idname = "mcpreptest.run_test" - bl_description = "Run specified test index" - - index = bpy.props.IntProperty(default=0) - - def execute(self, context): - # ind = context.window_manager.mcprep_test_index - test_class.mcrprep_run_test(self.index) - return {'FINISHED'} - - -class MCPTEST_OT_test_selfdestruct(bpy.types.Operator): - bl_label = "MCprep test self-destruct (dereg)" - bl_idname = "mcpreptest.self_destruct" - bl_description = "Deregister the MCprep test script, panel, and operators" - - def execute(self, context): - print("De-registering MCprep test") - unregister() - return {'FINISHED'} - - -class MCPTEST_PT_test_panel(bpy.types.Panel): - """MCprep test panel""" - bl_label = "MCprep Test Panel" - bl_space_type = 'VIEW_3D' - bl_region_type = 'TOOLS' if bpy.app.version < (2, 80) else 'UI' - bl_category = "MCprep" - - def draw_header(self, context): - col = self.layout.column() - col.label("", icon="ERROR") - - def draw(self, context): - layout = self.layout - col = layout.column() - col.label(text="Select test case:") - col.prop(context.window_manager, "mcprep_test_index") - ops = col.operator("mcpreptest.run_test") - ops.index = context.window_manager.mcprep_test_index - col.prop(context.window_manager, "mcprep_test_autorun") - col.label(text="") - - r = col.row() - subc = r.column() - subc.scale_y = 0.8 - # draw test results thus far: - for i, itm in enumerate(test_class.test_cases): - row = subc.row(align=True) - - if test_class.test_cases[i][1]["check"] == 1: - icn = "COLOR_GREEN" - elif test_class.test_cases[i][1]["check"] == -1: - icn = "COLOR_GREEN" - elif test_class.test_cases[i][1]["check"] == -2: - icn = "QUESTION" - else: - icn = "MESH_CIRCLE" - row.operator("mcpreptest.run_test", icon=icn, text="").index = i - row.label("{}-{} | {}".format( - test_class.test_cases[i][1]["type"], - test_class.test_cases[i][0], - test_class.test_cases[i][1]["res"] - )) - col.label(text="") - col.operator("mcpreptest.self_destruct") - - -def mcprep_test_index_update(self, context): - if context.window_manager.mcprep_test_autorun: - print("Auto-run MCprep test") - bpy.ops.mcpreptest.run_test(index=self.mcprep_test_index) - - -# ----------------------------------------------------------------------------- -# Registration -# ----------------------------------------------------------------------------- - - -classes = ( - MCPTEST_OT_test_run, - MCPTEST_OT_test_selfdestruct, - MCPTEST_PT_test_panel -) - - -def register(): - print("REGISTER MCPREP TEST") - maxlen = len(test_class.test_cases) - - bpy.types.WindowManager.mcprep_test_index = bpy.props.IntProperty( - name="MCprep test index", - default=-1, - min=-1, - max=maxlen, - update=mcprep_test_index_update) - bpy.types.WindowManager.mcprep_test_autorun = bpy.props.BoolProperty( - name="Autorun test", - default=True) - - # context.window_manager.mcprep_test_index = -1 put into handler to reset? - for cls in classes: - # util.make_annotations(cls) - bpy.utils.register_class(cls) - - -def unregister(): - print("DEREGISTER MCPREP TEST") - for cls in reversed(classes): - bpy.utils.unregister_class(cls) - del bpy.types.WindowManager.mcprep_test_index - del bpy.types.WindowManager.mcprep_test_autorun - - -if __name__ == "__main__": - global test_class - test_class = mcprep_testing() - - register() - - # setup paths to the target - test_class.setup_env_paths() - - # check for additional args, e.g. if running from console beyond blender - if "--" in sys.argv: - argind = sys.argv.index("--") - args = sys.argv[argind + 1:] - else: - args = [] - - if "-v" in args: - test_class.suppress = False - else: - test_class.suppress = True - - if "-run" in args: - ind = args.index("-run") - if len(args) > ind: - test_class.run_only = args[ind + 1] - - if "--auto_run" in args: - test_class.run_all_tests() diff --git a/test_files/asset_qa_test.py b/test_files/asset_qa_test.py new file mode 100644 index 00000000..c13379f8 --- /dev/null +++ b/test_files/asset_qa_test.py @@ -0,0 +1,294 @@ +# ##### 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 unittest + +import bpy + +from MCprep_addon.spawner import spawn_util + + +class AssetQaTest(unittest.TestCase): + """Test runner to validate current.""" + + @classmethod + def setUpClass(cls): + bpy.ops.preferences.addon_enable(module="MCprep_addon") + + def _qa_helper(self, basepath: str, allow_packed: bool) -> str: + """File used to help QC an open blend file.""" + + # bpy.ops.file.make_paths_relative() instead of this, do manually. + different_base = [] + not_relative = [] + missing = [] + for img in bpy.data.images: + if not img.filepath: + continue + abspath = os.path.abspath(bpy.path.abspath(img.filepath)) + if not abspath.startswith(basepath): + if allow_packed is True and img.packed_file: + pass + else: + different_base.append(os.path.basename(img.filepath)) + if img.filepath != bpy.path.relpath(img.filepath): + not_relative.append(os.path.basename(img.filepath)) + if not os.path.isfile(abspath): + if allow_packed is True and img.packed_file: + pass + else: + missing.append(img.name) + + if len(different_base) > 50: + return "Wrong basepath for image filepath comparison!" + if different_base: + return "Found {} images with different basepath from file: {}".format( + len(different_base), "; ".join(different_base)) + if not_relative: + return "Found {} non relative img files: {}".format( + len(not_relative), "; ".join(not_relative)) + if missing: + return "Found {} img with missing source files: {}".format( + len(missing), "; ".join(missing)) + + def test_qa_meshswap_file(self): + """Open the meshswap file, assert there are no relative paths""" + full_path = os.path.join("MCprep_addon", "MCprep_resources") + basepath = os.path.abspath(full_path) # relative to git folder + blendfile = os.path.join(basepath, "mcprep_meshSwap.blend") + self.assertTrue( + os.path.isfile(blendfile), + f"missing tests dir local meshswap file: {blendfile}") + + print("QC'ing:", blendfile) + bpy.ops.wm.open_mainfile(filepath=blendfile) + # do NOT save this file! + + resp = self._qa_helper(basepath, allow_packed=False) + self.assertFalse(bool(resp), f"Failed: {resp}") + + # detect any non canonical material names?? how to exclude? + + # Affirm that no materials have a principled node, should be basic only + + def test_qa_effects(self): + """Ensures that effects files meet all QA needs""" + + basepath = os.path.join("MCprep_addon", "MCprep_resources", "effects") + basepath = os.path.abspath(basepath) # relative to the dev git folder + + bfiles = [] + for child in os.listdir(basepath): + if bpy.app.version < (3, 0) and child == "geonodes": + print("Skipping geo nodes validation pre bpy 3.0") + continue + fullp = os.path.join(basepath, child) + if os.path.isfile(fullp) and child.lower().endswith(".blend"): + bfiles.append(fullp) + elif not os.path.isdir(fullp): + continue + subblends = [ + os.path.join(basepath, child, blend) + for blend in os.listdir(os.path.join(basepath, child)) + if os.path.isfile(os.path.join(basepath, child, blend)) + and blend.lower().endswith(".blend") + ] + bfiles.extend(subblends) + + print("Checking blend files") + self.assertGreater(len(bfiles), 0, "No files loaded for bfiles") + + for blend in bfiles: + tname = blend.replace(".blend", "") + tname = os.path.basename(tname) + with self.subTest(tname): + print("QC'ing:", blend) + self.assertTrue(os.path.isfile(blend), f"Did not exist: {blend}") + bpy.ops.wm.open_mainfile(filepath=blend) + + resp = self._qa_helper(basepath, allow_packed=True) + self.assertFalse(resp, f"{resp} {blend}") + + def test_qa_rigs(self): + """Ensures that all rig files meet all QA needs. + + NOTE: This test is actually surpisingly fast given the number of files + it needs to check against, but it's possible it can be unstable. Exit + early to override if needed. + """ + basepath = os.path.join("MCprep_addon", "MCprep_resources", "rigs") + basepath = os.path.abspath(basepath) # relative to the dev git folder + + bfiles = [] + for child in os.listdir(basepath): + fullp = os.path.join(basepath, child) + if os.path.isfile(fullp) and child.lower().endswith(".blend"): + bfiles.append(fullp) + elif not os.path.isdir(fullp): + continue + subblends = [ + os.path.join(basepath, child, blend) + for blend in os.listdir(os.path.join(basepath, child)) + if os.path.isfile(os.path.join(basepath, child, blend)) + and blend.lower().endswith(".blend") + ] + bfiles.extend(subblends) + + self.assertGreater(len(bfiles), 0, "No files loaded for bfiles") + + for blend in bfiles: + tname = blend.replace(".blend", "") + tname = os.path.basename(tname) + with self.subTest(tname): + res = spawn_util.check_blend_eligible(blend, bfiles) + if res is False: + print("QC'ing SKIP:", blend) + continue + print("QC'ing:", blend) + self.assertTrue(os.path.isfile(blend), f"Did not exist: {blend}") + bpy.ops.wm.open_mainfile(filepath=blend) + + resp = self._qa_helper(basepath, allow_packed=True) + print(resp) + self.assertFalse(resp, resp) + + def test_check_blend_eligible(self): + fake_base = "MyMob - by Person" + + suffix_new = " pre9.0.0" # Force active blender instance as older + suffix_old = " pre1.0.0" # Force active blender instance as newer. + + p_none = fake_base + ".blend" + p_new = fake_base + suffix_new + ".blend" + p_old = fake_base + suffix_old + ".blend" + rando = "rando_name" + suffix_old + ".blend" + + # Check where input file is the "non-versioned" one. + + res = spawn_util.check_blend_eligible(p_none, [p_none, rando]) + self.assertTrue(res, "Should have been true even if rando has suffix") + + res = spawn_util.check_blend_eligible(p_none, [p_none, p_old]) + self.assertTrue( + res, "Should be true as curr blend eligible and checked latest") + + res = spawn_util.check_blend_eligible(p_none, [p_none, p_new]) + self.assertFalse( + res, + "Should be false as curr blend not eligible and checked latest") + + # Now check if input is a versioned file. + + res = spawn_util.check_blend_eligible(p_new, [p_none, p_new]) + self.assertTrue( + res, "Should have been true since we are below min blender") + + res = spawn_util.check_blend_eligible(p_old, [p_none, p_old]) + self.assertFalse( + res, "Should have been false since we are above this min blender") + + def test_check_blend_eligible_middle(self): + # Warden-like example, where we have equiv of pre2.80, pre3.0, and + # live blender 3.0+ (presuming we want to test a 2.93-like user) + fake_base = "WardenExample" + + # Assume the "current" version of blender is like 9.1 + # To make test not be flakey, actual version of blender can be anything + # in range of 2.7.0 upwards to 8.999. + suffix_old = " pre2.7.0" # Force active blender instance as older. + suffix_mid = " pre9.0.0" # Force active blender instance as older. + suffix_new = "" # presume "latest" version + + p_old = fake_base + suffix_old + ".blend" + p_mid = fake_base + suffix_mid + ".blend" + p_new = fake_base + suffix_new + ".blend" + + # Test in order + filelist = [p_old, p_mid, p_new] + + res = spawn_util.check_blend_eligible(p_old, filelist) + self.assertFalse(res, "Older file should not match (in order)") + res = spawn_util.check_blend_eligible(p_mid, filelist) + self.assertTrue(res, "Mid file SHOULD match (in order)") + res = spawn_util.check_blend_eligible(p_new, filelist) + self.assertFalse(res, "Newer file should not match (in order)") + + # Test out of order + filelist = [p_mid, p_new, p_old] + + res = spawn_util.check_blend_eligible(p_old, filelist) + self.assertFalse(res, "Older file should not match (out of order)") + res = spawn_util.check_blend_eligible(p_mid, filelist) + self.assertTrue(res, "Mid file SHOULD match (out of order)") + res = spawn_util.check_blend_eligible(p_new, filelist) + self.assertFalse(res, "Newer file should not match (out of order)") + + def test_check_blend_eligible_real(self): + # This order below matches a user's who was encountering an error + # (the actual in-memory python list order) + riglist = [ + "bee - Boxscape.blend", + "Blaze - Trainguy.blend", + "Cave Spider - Austin Prescott.blend", + "creeper - TheDuckCow.blend", + "drowned - HissingCreeper-thefunnypie2.blend", + "enderman - Trainguy.blend", + "Ghast - Trainguy.blend", + "guardian - Trainguy.blend", + "hostile - boxscape.blend", + "illagers - Boxscape.blend", + "mobs - Rymdnisse.blend", + "nether hostile - Boxscape.blend", + "piglin zombified piglin - Boxscape.blend", + "PolarBear - PixelFrosty.blend", + "ravager - Boxscape.blend", + "Shulker - trainguy.blend", + "Skeleton - Trainguy.blend", + "stray - thefunnypie2.blend", + "Warden - DigDanAnimates pre2.80.0.blend", + "Warden - DigDanAnimates pre3.0.0.blend", + "Warden - DigDanAnimates.blend", + "Zombie - Hissing Creeper.blend", + "Zombie Villager - Hissing Creeper-thefunnypie2.blend" + ] + target_list = [ + "Warden - DigDanAnimates pre2.80.0.blend", + "Warden - DigDanAnimates pre3.0.0.blend", + "Warden - DigDanAnimates.blend", + ] + + if bpy.app.version < (2, 80): + correct = "Warden - DigDanAnimates pre2.80.0.blend" + elif bpy.app.version < (3, 0): + correct = "Warden - DigDanAnimates pre3.0.0.blend" + else: + correct = "Warden - DigDanAnimates.blend" + + for rig in target_list: + res = spawn_util.check_blend_eligible(rig, riglist) + if rig == correct: + self.assertTrue(res, f"Did not pick {rig} as correct rig") + else: + self.assertFalse( + res, f"Should have said {correct} was correct - not {rig}") + + +if __name__ == '__main__': + unittest.main(exit=False) diff --git a/test_files/materials_test.py b/test_files/materials_test.py new file mode 100644 index 00000000..42586ac6 --- /dev/null +++ b/test_files/materials_test.py @@ -0,0 +1,847 @@ +# ##### 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 ##### + +from typing import Tuple +import datetime +import os +import shutil +import tempfile +import unittest + +import bpy +from bpy.types import Material + +from MCprep_addon.materials import generate +from MCprep_addon.materials import sequences +from MCprep_addon.materials.generate import find_additional_passes +from MCprep_addon.materials.generate import get_mc_canonical_name +from MCprep_addon.materials.uv_tools import get_uv_bounds_per_material + + +class MaterialsTest(unittest.TestCase): + """Materials-related tests.""" + + @classmethod + def setUpClass(cls): + bpy.ops.preferences.addon_enable(module="MCprep_addon") + + def setUp(self): + """Clears scene and data between each test""" + bpy.ops.wm.read_homefile(app_template="", use_empty=True) + + def _debug_save_file_state(self): + """Saves the current scene state to a test file for inspection.""" + testdir = os.path.dirname(__file__) + path = os.path.join(testdir, "debug_save_state.blend") + bpy.ops.wm.save_as_mainfile(filepath=path) + print(f"Saved to debug file: {path}") + + def _get_mcprep_path(self): + """Returns the addon basepath installed in this blender instance""" + for base in bpy.utils.script_paths(): + init = os.path.join( + base, "addons", "MCprep_addon") # __init__.py folder + if os.path.isdir(init): + return init + self.fail("Failed to get MCprep path") + + def _get_canon_texture_image(self, name: str, test_pack=False): + if test_pack: + testdir = os.path.dirname(__file__) + filepath = os.path.join( + testdir, "test_resource_pack", "textures", name + ".png") + return filepath + else: + base = self._get_mcprep_path() + filepath = os.path.join( + base, "MCprep_resources", + "resourcepacks", "mcprep_default", "assets", "minecraft", + "textures", "block", name + ".png") + return filepath + + def _create_canon_mat(self, canon: str = None, test_pack=False): + # ) -> Tuple(bpy.types.Material, bpy.types.ShaderNode): + """Creates a material that should be recognized.""" + name = canon if canon else "dirt" + mat = bpy.data.materials.new(name) + mat.use_nodes = True + img_node = mat.node_tree.nodes.new(type="ShaderNodeTexImage") + if canon: + filepath = self._get_canon_texture_image(canon, test_pack) + img = bpy.data.images.load(filepath) + else: + img = bpy.data.images.new(name, 16, 16) + img_node.image = img + return mat, img_node + + def _set_test_mcprep_texturepack_path(self, reset: bool = False): + """Assigns or resets the local texturepack path.""" + testdir = os.path.dirname(__file__) + path = os.path.join(testdir, "test_resource_pack") + if not os.path.isdir(path): + raise Exception("Failed to set test texturepack path") + bpy.context.scene.mcprep_texturepack_path = path + + def test_prep_materials_no_selection(self): + """Ensures prepping with no selection fails.""" + with self.assertRaises(RuntimeError) as rte: + res = bpy.ops.mcprep.prep_materials( + animateTextures=False, + packFormat="simple", + autoFindMissingTextures=False, + improveUiSettings=False) + self.assertEqual(res, {'CANCELLED'}) + self.assertIn( + "Error: No objects selected", + str(rte.exception), + "Wrong error raised") + + def test_prep_materials_sel_no_mat(self): + """Ensures prepping an obj without materials fails.""" + bpy.ops.mesh.primitive_plane_add() + self.assertIsNotNone(bpy.context.object, "Object should be selected") + with self.assertRaises(RuntimeError) as rte: + res = bpy.ops.mcprep.prep_materials( + animateTextures=False, + packFormat="simple", + autoFindMissingTextures=False, + improveUiSettings=False) + self.assertEqual(res, {'CANCELLED'}) + self.assertIn( + "No materials found", + str(rte.exception), + "Wrong error raised") + + # TODO: Add test where material is added but without an image/nodes + + def _prep_material_test_constructor(self, pack_format: str) -> Material: + """Ensures prepping with the expected parameters succeeds.""" + bpy.ops.mesh.primitive_plane_add() + obj = bpy.context.object + + # add object with canonical material name. Assume cycles + new_mat, _ = self._create_canon_mat() + obj.active_material = new_mat + self.assertIsNotNone(obj.active_material, "Material should be applied") + + res = bpy.ops.mcprep.prep_materials( + animateTextures=False, + packFormat=pack_format, + autoFindMissingTextures=False, + improveUiSettings=False) + + self.assertEqual(res, {'FINISHED'}, "Did not return finished") + return new_mat + + def _get_imgnode_stats(self, mat: Material) -> Tuple[int, int]: + count_images = 0 + missing_images = 0 + for node in mat.node_tree.nodes: + if node.type != "TEX_IMAGE": + continue + elif node.image: + count_images += 1 + else: + missing_images += 1 + return (count_images, missing_images) + + def test_prep_simple(self): + new_mat = self._prep_material_test_constructor("simple") + count_images, missing_images = self._get_imgnode_stats(new_mat) + self.assertEqual( + count_images, 1, "Should have only 1 pass after simple load") + self.assertEqual( + missing_images, 0, "Should have 0 unloaded passes") + + def test_prep_pbr_specular(self): + new_mat = self._prep_material_test_constructor("specular") + count_images, missing_images = self._get_imgnode_stats(new_mat) + self.assertGreaterEqual( + count_images, 1, "Should have 1+ pass after pbr load") + self.assertEqual( + missing_images, 2, "Other passes should remain empty") + + def test_prep_pbr_seus(self): + new_mat = self._prep_material_test_constructor("seus") + count_images, missing_images = self._get_imgnode_stats(new_mat) + self.assertGreaterEqual( + count_images, 1, "Should have 1+ pass after seus load") + self.assertEqual( + missing_images, 2, "Other passes should remain empty") + + def test_prep_missing_pbr_passes(self): + """Ensures norm/spec passes loaded after simple prepping.""" + self._set_test_mcprep_texturepack_path() + new_mat, _ = self._create_canon_mat("diamond_ore", test_pack=True) + + bpy.ops.mesh.primitive_plane_add() + obj = bpy.context.object + obj.active_material = new_mat + self.assertIsNotNone(obj.active_material, "Material should be applied") + + # Start with simple material loaded, non pbr/no extra passes + res = bpy.ops.mcprep.prep_materials( + animateTextures=False, + autoFindMissingTextures=False, + packFormat="simple", + useExtraMaps=False, # Set to false + syncMaterials=False, + improveUiSettings=False) + self.assertEqual(res, {'FINISHED'}) + count_images, missing_images = self._get_imgnode_stats(new_mat) + self.assertEqual( + count_images, 1, "Should have only 1 pass after simple load") + self.assertEqual( + missing_images, 0, "Should have 0 unloaded passes") + + # Now try pbr load, should auto-load in adjacent _n and _s passes. + self._set_test_mcprep_texturepack_path() + res = bpy.ops.mcprep.prep_materials( + animateTextures=False, + autoFindMissingTextures=False, + packFormat="specular", + useExtraMaps=True, # Set to True + syncMaterials=False, + improveUiSettings=False) + self.assertEqual(res, {'FINISHED'}) + self.assertSequenceEqual( + bpy.data.materials, [new_mat], + "We should have only modified the one pre-existing material") + + count_images, missing_images = self._get_imgnode_stats(new_mat) + # self._debug_save_file_state() + self.assertEqual( + count_images, 3, "Should have 3 pass after pbr load") + self.assertEqual( + missing_images, 0, "Should have 0 unloaded passes") + + def test_load_material(self): + """Test the load material operators and related resets""" + bpy.ops.mcprep.reload_materials() + + # add object + bpy.ops.mesh.primitive_cube_add() + + scn_props = bpy.context.scene.mcprep_props + itm = scn_props.material_list[scn_props.material_list_index] + path = itm.path + bpy.ops.mcprep.load_material(filepath=path) + + # validate that the loaded material has a name matching current list + mat = bpy.context.object.active_material + scn_props = bpy.context.scene.mcprep_props + mat_item = scn_props.material_list[scn_props.material_list_index] + self.assertTrue(mat_item.name in mat.name, + f"Material name not loaded {mat.name}") + + def test_generate_material_sequence(self): + """Validates generating an image sequence works ok.""" + self._material_sequnece_subtest(operator=False) + + def test_prep_material_animated(self): + """Validates loading an animated material works ok.""" + self._material_sequnece_subtest(operator=True) + + def _has_animated_tex_node(self, mat) -> bool: + any_animated = False + for nd in mat.node_tree.nodes: + if nd.type != "TEX_IMAGE": + continue + if nd.image.source == "SEQUENCE": + any_animated = True + break + return any_animated + + def _material_sequnece_subtest(self, operator: bool): + """Validates generating an image sequence works ok.""" + + tiled_img = os.path.join( + os.path.dirname(__file__), + "test_resource_pack", "textures", "campfire_fire.png") + fake_orig_img = tiled_img + result_dir = os.path.splitext(tiled_img)[0] + + try: + shutil.rmtree(result_dir) + except Exception: + pass # Ok, generally should be cleaned up anyways. + + # Ensure that the folder does not initially exist + self.assertFalse(os.path.isdir(result_dir), + "Folder pre-exists, should be removed before test") + + res, err = None, None + if operator is True: + # Add a mesh to operate on + bpy.ops.mesh.primitive_plane_add() + + res = bpy.ops.mcprep.load_material( + filepath=tiled_img, animateTextures=False) + self.assertEqual(res, {'FINISHED'}) + + any_animated = self._has_animated_tex_node( + bpy.context.object.active_material) + self.assertFalse(any_animated, "Should not be initially animated") + + res = bpy.ops.mcprep.load_material( + filepath=tiled_img, animateTextures=True) + self.assertEqual(res, {'FINISHED'}) + any_animated = self._has_animated_tex_node( + bpy.context.object.active_material) + self._debug_save_file_state() + self.assertTrue(any_animated, "Affirm animated material applied") + + else: + res, err = sequences.generate_material_sequence( + source_path=fake_orig_img, + image_path=tiled_img, + form=None, + export_location="original", + clear_cache=True) + + gen_files = [img for img in os.listdir(result_dir) + if img.endswith(".png")] + self.assertTrue(os.path.isdir(result_dir), + "Output directory does not exist") + shutil.rmtree(result_dir) + self.assertTrue(gen_files, "No images generated") + self.assertIsNone(err, "Generate materials had an error") + self.assertTrue( + res, + "Failed to get success resposne from generate img sequence") + + def test_detect_desaturated_images(self): + """Checks the desaturate images are recognized as such.""" + should_saturate = { + # Sample of canonically grayscale textures. + "grass_block_top": True, + "acacia_leaves": True, + "redstone_dust_line0": True, + "water_flow": True, + + # Sample of textures already saturated + "grass_block_side": False, + "glowstone": False + } + + for tex in list(should_saturate): + do_sat = should_saturate[tex] + with self.subTest(f"Assert {tex} saturate is {do_sat}"): + img_file = self._get_canon_texture_image(tex, test_pack=False) + self.assertTrue( + os.path.isfile(img_file), + f"Failed to get test file {img_file}") + img = bpy.data.images.load(img_file) + self.assertTrue(img, 'Failed to load img') + res = generate.is_image_grayscale(img) + if do_sat: + self.assertTrue( + res, f"Should detect {tex} as grayscale") + else: + self.assertFalse( + res, f"Should not detect {tex} as grayscale") + + # TODO: test that it is caching as expected.. by setting a false + # value for cache flag and seeing it's returning the property value + + def test_matprep_cycles(self): + """Tests the generation function used within an operator.""" + canon = "grass_block_top" + mat, img_node = self._create_canon_mat(canon, test_pack=False) + passes = {"diffuse": img_node.image} + options = generate.PrepOptions( + passes=passes, + use_reflections=False, + use_principled=True, + only_solid=False, + pack_format="simple", + use_emission_nodes=False, + use_emission=False + ) + + generate.matprep_cycles(mat, options) + self.assertEqual(1, len(bpy.data.materials)) + self.assertEqual(1, len(bpy.data.images), list(bpy.data.images)) + + def test_skin_swap_local(self): + bpy.ops.mcprep.reload_skins() + skin_ind = bpy.context.scene.mcprep_skins_list_index + skin_item = bpy.context.scene.mcprep_skins_list[skin_ind] + tex_name = skin_item["name"] + skin_path = os.path.join(bpy.context.scene.mcprep_skin_path, tex_name) + + self.assertIsNone(bpy.context.object, "Should have no initial object") + + with self.assertRaises(RuntimeError) as raised: + res = bpy.ops.mcprep.applyskin( + filepath=skin_path, + new_material=False) + self.assertEqual(res, {'CANCELLED'}) + + self.assertEqual('Error: No materials found to update\n', + str(raised.exception), + "Should fail when no existing materials exist") + + # now run on a real test character, with 1 material and 2 objects + # self._add_character() + # Using a simple material now, to make things fast. + mat, _ = self._create_canon_mat() + bpy.ops.mesh.primitive_plane_add() + self.assertIsNotNone(bpy.context.object, "Object should be selected") + bpy.context.object.active_material = mat + + # Perform the main action + pre_mats = len(bpy.data.materials) + res = bpy.ops.mcprep.applyskin(filepath=skin_path, + new_material=False) + post_mats = len(bpy.data.materials) + self.assertEqual(pre_mats, post_mats, "Should not create a new mat") + + # do counts of materials before and after to ensure they match + pre_mats = len(bpy.data.materials) + bpy.ops.mcprep.applyskin( + filepath=skin_path, + new_material=True) + post_mats = len(bpy.data.materials) + self.assertEqual(pre_mats * 2, post_mats, + "Should have 2x as many mats after new mats created") + + def test_skin_swap_username(self): + bpy.ops.mcprep.reload_skins() + + mat, _ = self._create_canon_mat() + bpy.ops.mesh.primitive_plane_add() + self.assertIsNotNone(bpy.context.object, "Object should be selected") + bpy.context.object.active_material = mat + + username = "theduckcow" + + # Be sure to delete the skin first, otherwise just testing locality. + target_index = None + for ind, itm in enumerate(bpy.context.scene.mcprep_skins_list): + if itm["name"].lower() == username.lower() + ".png": + target_index = ind + break + + if target_index is not None: + bpy.context.scene.mcprep_skins_list_index = target_index + res = bpy.ops.mcprep.remove_skin() + self.assertEqual(res, {'FINISHED'}) + + # Triple verify the file is now gone. + skin_path = os.path.join(bpy.context.scene.mcprep_skin_path, + username + ".png") + self.assertFalse(os.path.isfile(skin_path), + "Skin should initially be removed") + + res = bpy.ops.mcprep.applyusernameskin( + username='TheDuckCow', + skip_redownload=False, + new_material=True) + + self.assertEqual(res, {'FINISHED'}) + self.assertTrue(os.path.isfile(skin_path), + "Skin should have downloaded") + + initial_mod_stat = os.path.getmtime(skin_path) + initial_mod = datetime.datetime.fromtimestamp(initial_mod_stat) + + # import time + # time.sleep(1) + res = bpy.ops.mcprep.applyusernameskin( + username='TheDuckCow', + skip_redownload=True, + new_material=True) + self.assertEqual(res, {'FINISHED'}) + + new_mod_stat = os.path.getmtime(skin_path) + new_mod = datetime.datetime.fromtimestamp(new_mod_stat) + + # TODO: Verify if this is truly cross platform for last time modified. + self.assertEqual(initial_mod, new_mod, "Should not have redownloaded") + + def test_download_username_list(self): + usernames = ["theduckcow", "thekindkitten"] + skin_path = bpy.context.scene.mcprep_skin_path + + for _user in usernames: + _user_file = os.path.join(skin_path, f"{_user}.png") + if os.path.isfile(_user_file): + os.remove(_user_file) + + res = bpy.ops.mcprep.download_username_list( + username_list=','.join(usernames), + skip_redownload=True, + convert_layout=True) + self.assertEqual(res, {'FINISHED'}) + for _user in usernames: + with self.subTest(_user): + _user_file = os.path.join(skin_path, f"{_user}.png") + self.assertTrue(os.path.isfile(_user_file), + f"{_user} file should have downloaded") + + def test_spawn_with_skin(self): + bpy.ops.mcprep.reload_mobs() + bpy.ops.mcprep.reload_skins() + + res = bpy.ops.mcprep.spawn_with_skin() + self.assertEqual(res, {'FINISHED'}) + + # test changing skin to file when no existing images/textres + # test changing skin to file when existing material + # test changing skin to file for both above, cycles and internal + # test changing skin file for both above without, then with, + # then without again, normals + spec etc. + + def test_sync_materials(self): + """Test syncing materials works""" + + # test empty case + res = bpy.ops.mcprep.sync_materials( + link=False, + replace_materials=False, + skipUsage=True) # track here false to avoid error + self.assertEqual( + res, {'CANCELLED'}, "Should return cancel in empty scene") + + # test that the base test material is included as shipped + bpy.ops.mesh.primitive_plane_add() + obj = bpy.context.object + obj.select_set(True) + + new_mat = bpy.data.materials.new("mcprep_test") + obj.active_material = new_mat + + init_mats = bpy.data.materials[:] + init_len = len(bpy.data.materials) + res = bpy.ops.mcprep.sync_materials( + link=False, + replace_materials=False) + self.assertEqual( + res, {'FINISHED'}, "Should return finished with test file") + + # check there is another material now + imported = set(bpy.data.materials[:]) - set(init_mats) + post_len = len(bpy.data.materials) + self.assertFalse( + list(imported)[0].library, + "Material linked should not be a library") + self.assertEqual( + post_len - 1, + init_len, + "Should have imported specifically one material") + + new_mat.name = "mcprep_test" + init_len = len(bpy.data.materials) + res = bpy.ops.mcprep.sync_materials( + link=False, + replace_materials=True) + self.assertEqual(res, {'FINISHED'}, + "Should return finished with test file (replace)") + self.assertEqual( + len(bpy.data.materials), init_len, + "Number of materials should not have changed with replace") + + # Now test it works with name generalization, stripping .### + new_mat.name = "mcprep_test.005" + init_mats = bpy.data.materials[:] + init_len = len(bpy.data.materials) + res = bpy.ops.mcprep.sync_materials( + link=False, + replace_materials=False) + self.assertEqual(res, {'FINISHED'}, + "Should return finished with test file") + + # check there is another material now + imported = set(bpy.data.materials[:]) - set(init_mats) + post_len = len(bpy.data.materials) + self.assertTrue(list(imported), "No new materials found") + self.assertFalse( + list(imported)[0].library, + "Material linked should NOT be a library") + self.assertEqual( + post_len - 1, + init_len, + "Should have imported specifically one material") + + def test_sync_materials_link(self): + """Test syncing materials works""" + + # test that the base test material is included as shipped + bpy.ops.mesh.primitive_plane_add() + obj = bpy.context.object + obj.select_set(True) + + new_mat = bpy.data.materials.new("mcprep_test") + obj.active_material = new_mat + + new_mat.name = "mcprep_test" + init_mats = bpy.data.materials[:] + res = bpy.ops.mcprep.sync_materials( + link=True, + replace_materials=False) + self.assertEqual(res, {'FINISHED'}, + "Should return finished with test file (link)") + imported = set(bpy.data.materials[:]) - set(init_mats) + imported = list(imported) + self.assertTrue(imported, "No new material found after linking") + self.assertTrue( + list(imported)[0].library, "Material linked should be a library") + + def test_uv_transform_detection(self): + """Ensure proper detection and transforms for Mineways all-in-one images""" + bpy.ops.mesh.primitive_cube_add() + bpy.ops.object.editmode_toggle() + bpy.ops.mesh.select_all(action='SELECT') + bpy.ops.uv.reset() + bpy.ops.object.editmode_toggle() + new_mat = bpy.data.materials.new(name="tmp") + bpy.context.object.active_material = new_mat + + if not bpy.context.object or not bpy.context.object.active_material: + self.fail("Failed set up for uv_transform_detection") + + uv_bounds = get_uv_bounds_per_material(bpy.context.object) + mname = bpy.context.object.active_material.name + self.assertEqual( + uv_bounds, {mname: [0, 1, 0, 1]}, + "UV transform for default cube should have max bounds") + + bpy.ops.object.editmode_toggle() + bpy.ops.mesh.select_all(action='SELECT') + bpy.ops.uv.sphere_project() # ensures irregular UV map, not bounded + bpy.ops.object.editmode_toggle() + uv_bounds = get_uv_bounds_per_material(bpy.context.object) + self.assertNotEqual( + uv_bounds, {mname: [0, 1, 0, 1]}, + "UV mapping is irregular, should have different min/max") + + def test_canonical_test_mappings(self): + """Test some specific mappings to ensure they return correctly.""" + + misc = { + ".emit": ".emit", + } + jmc_to_canon = { + "grass": "grass", + "mushroom_red": "red_mushroom", + # "slime": "slime_block", # KNOWN jmc, need to address + } + mineways_to_canon = {} + + for map_type in [misc, jmc_to_canon, mineways_to_canon]: + for key, val in map_type.items(): + res, mapped = get_mc_canonical_name(key) + self.assertEqual( + res, val, + f"{key} should map to {res} ({mapped}, not {val}") + + def detect_extra_passes(self): + """Ensure only the correct pbr file matches are found for input file""" + + tmp_dir = tempfile.gettempdir() + + # physically generate these empty files, then delete + tmp_files = [ + "oak_log_top.png", + "oak_log_top-s.png", + "oak_log_top_n.png", + "oak_log.jpg", + "oak_log_s.jpg", + "oak_log_n.jpeg", + "oak_log_disp.jpeg", + "stonecutter_saw.tiff", + "stonecutter_saw n.tiff" + ] + + for tmp in tmp_files: + fname = os.path.join(tmp_dir, tmp) + with open(fname, 'a'): + os.utime(fname) + + def cleanup(): + """Failsafe delete files before raising error within test method""" + for tmp in tmp_files: + try: + os.remove(os.path.join(tmp_dir, tmp)) + except Exception: + pass + + # assert setup was successful + for tmp in tmp_files: + if os.path.isfile(os.path.join(tmp_dir, tmp)): + continue + cleanup() + self.fail("Failed to generate test empty files") + + # the test cases; input is diffuse, output is the whole dict + cases = [ + { + "diffuse": os.path.join(tmp_dir, "oak_log_top.png"), + "specular": os.path.join(tmp_dir, "oak_log_top-s.png"), + "normal": os.path.join(tmp_dir, "oak_log_top_n.png"), + }, { + "diffuse": os.path.join(tmp_dir, "oak_log.jpg"), + "specular": os.path.join(tmp_dir, "oak_log_s.jpg"), + "normal": os.path.join(tmp_dir, "oak_log_n.jpeg"), + "displace": os.path.join(tmp_dir, "oak_log_disp.jpeg"), + }, { + "diffuse": os.path.join(tmp_dir, "stonecutter_saw.tiff"), + "normal": os.path.join(tmp_dir, "stonecutter_saw n.tiff"), + } + ] + + for test in cases: + res = find_additional_passes(test["diffuse"]) + if res != test: + cleanup() + # for debug readability, basepath everything + for itm in res: + res[itm] = os.path.basename(res[itm]) + for itm in test: + test[itm] = os.path.basename(test[itm]) + dfse = test["diffuse"] + self.fail( + f"Mismatch for set {dfse}: got {res} but expected {test}") + + # test other cases intended to fail + res = find_additional_passes(os.path.join(tmp_dir, "not_a_file.png")) + cleanup() + self.assertEqual(res, {}, "Fake file should not have any return") + + def test_replace_missing_images_fixed(self): + """Find missing images from selected materials, cycles. + + Scenarios in which we find new textures + One: material is empty with no image block assigned at all, though has + image node and material is a canonical name + Two: material has image block but the filepath is missing, find it + """ + + mat, node = self._create_canon_mat("sugar_cane") + bpy.ops.mesh.primitive_plane_add() + bpy.context.object.active_material = mat + + pre_path = node.image.filepath + bpy.ops.mcprep.replace_missing_textures(animateTextures=False) + post_path = node.image.filepath + self.assertEqual(pre_path, post_path, "Pre/post path should match") + + # now save the texturefile somewhere + tmp_dir = tempfile.gettempdir() + tmp_image = os.path.join(tmp_dir, "sugar_cane.png") + shutil.copyfile(node.image.filepath, tmp_image) # leave orig intact + + # Test that path is unchanged even when with a non canonical path + with self.subTest("non_missing_left_alone"): + node.image.filepath = tmp_image + if node.image.filepath != tmp_image: + os.remove(tmp_image) + self.fail("failed to setup test, node path not = " + tmp_image) + pre_path = node.image.filepath + bpy.ops.mcprep.replace_missing_textures(animateTextures=False) + post_path = node.image.filepath + if pre_path != post_path: + os.remove(tmp_image) + self.assertEqual(pre_path, post_path, "Path should not change") + + with self.subTest("missing_resolved"): + # Ensure empty node within a canonically named material is fixed + pre_path = node.image.filepath + node.image = None # remove the image from block + + if node.image: + os.remove(tmp_image) + self.fail("failed to setup test, image block still assigned") + bpy.ops.mcprep.replace_missing_textures(animateTextures=False) + post_path = node.image.filepath + if not post_path: + os.remove(tmp_image) + self.fail("No post path found, should have loaded file") + elif post_path == pre_path: + os.remove(tmp_image) + self.fail("Should have loaded image as new datablock") + elif not os.path.isfile(post_path): + os.remove(tmp_image) + self.fail("New path file does not exist") + + def test_replace_missing_images_moved_blend(self): + """Scenario where we save, close, then move the blend file.""" + tmp_dir = tempfile.gettempdir() + mat, node = self._create_canon_mat("sugar_cane") + bpy.ops.mesh.primitive_plane_add() + bpy.context.object.active_material = mat + + # Then, create the textures locally + bpy.ops.file.pack_all() + bpy.ops.file.unpack_all(method='USE_LOCAL') + unpacked_path = bpy.path.abspath(node.image.filepath) + + # close and open, moving the file in the meantime + save_tmp_file = os.path.join(tmp_dir, "tmp_test.blend") + os.rename(unpacked_path, unpacked_path + "x") + bpy.ops.wm.save_mainfile(filepath=save_tmp_file) + bpy.ops.wm.open_mainfile(filepath=save_tmp_file) + + # now run the operator + img = bpy.data.images['sugar_cane.png'] + pre_path = img.filepath + if os.path.isfile(pre_path): + os.remove(unpacked_path + "x") + self.fail("Failed to setup test for save/reopn move") + + bpy.ops.mcprep.replace_missing_textures(animateTextures=False) + post_path = img.filepath + file_exists = os.path.isfile(post_path) + os.remove(unpacked_path + "x") + self.assertNotEqual(post_path, pre_path, "Did not change path") + self.assertTrue( + file_exists, + f"File for blend reloaded image does not exist: {post_path}") + + def test_replace_missing_images_name_incremented(self): + """Ensure example of sugar_cane.png.001 is accounted for.""" + mat, node = self._create_canon_mat("sugar_cane") + bpy.ops.mesh.primitive_plane_add() + bpy.context.object.active_material = mat + + tmp_dir = tempfile.gettempdir() + tmp_image = os.path.join(tmp_dir, "sugar_cane.png") + shutil.copyfile(node.image.filepath, tmp_image) # leave orig intact + + node.image = None # remove the image from block + mat.name = "sugar_cane.png.001" + if node.image: + os.remove(tmp_image) + self.fail("failed to setup test, image block still assigned") + bpy.ops.mcprep.replace_missing_textures(animateTextures=False) + post_path = node.image.filepath + is_file = os.path.isfile(node.image.filepath) + os.remove(tmp_image) + + self.assertTrue( + node.image, "Failed to load new image within mat named .png.001") + self.assertTrue(post_path, "No image loaded for " + mat.name) + self.assertTrue( + is_file, f"File for loaded image does not exist: {post_path}") + + # TODO: Example running with animateTextures too + + +if __name__ == '__main__': + unittest.main(exit=False) diff --git a/test_files/mcmodel_test.py b/test_files/mcmodel_qa.py similarity index 100% rename from test_files/mcmodel_test.py rename to test_files/mcmodel_qa.py diff --git a/test_files/spawner_test.py b/test_files/spawner_test.py new file mode 100644 index 00000000..5e190fe0 --- /dev/null +++ b/test_files/spawner_test.py @@ -0,0 +1,549 @@ +# ##### 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 unittest + +import bpy +from mathutils import Vector + +from MCprep_addon import util + + +class BaseSpawnerTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + bpy.ops.preferences.addon_enable(module="MCprep_addon") + + def setUp(self): + """Clears scene and data between each test""" + bpy.ops.wm.read_homefile(app_template="", use_empty=True) + + +class MobSpawnerTest(BaseSpawnerTest): + """Mob spawning related tests.""" + + fast_mcmob_type: str = 'hostile/mobs - Rymdnisse.blend:/:endermite' + + def test_mob_spawner_append(self): + """Spawn mobs, reload mobs, etc""" + res = bpy.ops.mcprep.reload_mobs() + self.assertEqual(res, {'FINISHED'}) + + # sample don't specify mob, just load whatever is first + res = bpy.ops.mcprep.mob_spawner( + mcmob_type=self.fast_mcmob_type, + toLink=False, # By design for this subtest + clearPose=False, + prep_materials=False + ) + self.assertEqual(res, {'FINISHED'}) + + # spawn with linking + # try changing the folder + # try install mob and uninstall + + def test_mob_spawner_linked(self): + res = bpy.ops.mcprep.reload_mobs() + self.assertEqual(res, {'FINISHED'}) + res = bpy.ops.mcprep.mob_spawner( + mcmob_type=self.fast_mcmob_type, + toLink=True, # By design for this subtest + clearPose=False, + prep_materials=False) + self.assertEqual(res, {'FINISHED'}) + + def test_mob_spawner_relocate(self): + res = bpy.ops.mcprep.reload_mobs() + # Set cursor location. + for method in ["Clear", "Offset"]: # Cursor tested above + res = bpy.ops.mcprep.mob_spawner( + mcmob_type=self.fast_mcmob_type, + relocation=method, + toLink=False, + clearPose=True, + prep_materials=False) + self.assertEqual(res, {'FINISHED'}) + + def test_bogus_mob_spawn(self): + """Spawn mobs, reload mobs, etc""" + res = bpy.ops.mcprep.reload_mobs() + self.assertEqual(res, {'FINISHED'}) + + # sample don't specify mob, just load whatever is first + with self.assertRaises(TypeError): + res = bpy.ops.mcprep.mob_spawner( + mcmob_type="bogus" + ) + + # TODO: changing the folder, install / uninstall mob + + +class ItemSpawnerTest(BaseSpawnerTest): + """Item spawning related tests.""" + + def test_item_spawner(self): + """Test item spawning and reloading""" + scn_props = bpy.context.scene.mcprep_props + + pre_items = len(scn_props.item_list) + bpy.ops.mcprep.reload_items() + post_items = len(scn_props.item_list) + + self.assertEqual(pre_items, 0) + self.assertGreater(post_items, 0, "No items loaded") + self.assertGreater(post_items, 50, + "Too few items loaded, missing texturepack?") + + # spawn with whatever default index + pre_objs = len(bpy.data.objects) + bpy.ops.mcprep.spawn_item() + post_objs = len(bpy.data.objects) + + self.assertGreater(post_objs, pre_objs, "No items spawned") + self.assertEqual(post_objs, pre_objs + 1, "More than one item spawned") + + # test core useage on a couple of out of the box textures + + # test once with custom block + # bpy.ops.mcprep.spawn_item_file(filepath=) + + # test with different thicknesses + # test after changing resource pack + # test that with an image of more than 1k pixels, it's truncated as expected + # test with different + + def test_item_spawner_resize(self): + """Test spawning an item that requires resizing.""" + bpy.ops.mcprep.reload_items() + + # Create a tmp file + tmp_img = bpy.data.images.new("tmp_item_spawn", 32, 32, alpha=True) + tmp_img.filepath = os.path.join(bpy.app.tempdir, "tmp_item.png") + tmp_img.save() + + # spawn with whatever default index + pre_objs = len(bpy.data.objects) + bpy.ops.mcprep.spawn_item_file( + max_pixels=16, + filepath=tmp_img.filepath + ) + post_objs = len(bpy.data.objects) + + self.assertGreater(post_objs, pre_objs, "No items spawned") + self.assertEqual(post_objs, pre_objs + 1, "More than one item spawned") + + # Now check that this item spawned has the expected face count. + obj = bpy.context.object + polys = len(obj.data.polygons) + self.assertEqual(16, polys, "Wrong pixel scaling applied") + + +class EffectsSpawnerTest(BaseSpawnerTest): + """EffectsSpawning-related tests.""" + + def test_particle_plane_effect_spawner(self): + """Test the particle plane variant of effect spawning works.""" + filepath = os.path.join( + bpy.context.scene.mcprep_texturepack_path, + "assets", "minecraft", "textures", "block", "dirt.png") + res = bpy.ops.mcprep.spawn_particle_planes(filepath=filepath) + + self.assertEqual(res, {'FINISHED'}) + + def test_img_sequence_effect_spawner(self): + """Ensures the image sequence variant of effect spawning works.""" + if bpy.app.version < (2, 81): + self.skipTest("Disabled due to consistent crashing") + + scn_props = bpy.context.scene.mcprep_props + etype = "img_seq" + + pre_count = len([ + x for x in scn_props.effects_list if x.effect_type == etype]) + bpy.ops.mcprep.reload_effects() + post_count = len([ + x for x in scn_props.effects_list if x.effect_type == etype]) + + self.assertEqual(pre_count, 0, "Should start with no effects loaded") + self.assertGreater(post_count, 0, + "Should have more effects loaded after reload") + + # Find the one with at least 10 frames. + effect_name = "Big smoke" + effect = None + for this_effect in scn_props.effects_list: + if this_effect.effect_type != etype: + continue + if this_effect.name == effect_name: + effect = this_effect + + self.assertTrue( + effect, "Failed to fetch {} target effect".format(effect_name)) + + res = bpy.ops.mcprep.spawn_instant_effect(effect_id=str(effect.index)) + self.assertEqual(res, {'FINISHED'}) + + # TODO: Further checks it actually loaded the effect. + + @unittest.skipIf(bpy.app.version < (3, 0), + "Geonodes not supported pre 3.0") + def test_geonode_effect_spawner(self): + scn_props = bpy.context.scene.mcprep_props + etype = "geo_area" + + pre_count = len([ + x for x in scn_props.effects_list if x.effect_type == etype]) + bpy.ops.mcprep.reload_effects() + post_count = len([ + x for x in scn_props.effects_list if x.effect_type == etype]) + + self.assertEqual(pre_count, 0, "Should start with no effects loaded") + self.assertGreater( + post_count, 0, "Should have more effects loaded after reload") + + effect = [x for x in scn_props.effects_list if x.effect_type == etype][0] + res = bpy.ops.mcprep.spawn_global_effect(effect_id=str(effect.index)) + self.assertEqual(res, {'FINISHED'}, "Did not end with finished result") + + # TODO: Further checks it actually loaded the effect. + # Check that the geonode inputs are updated. + obj = bpy.context.object + self.assertTrue(obj, "Geo node added object not selected") + + geo_nodes = [mod for mod in obj.modifiers if mod.type == "NODES"] + self.assertTrue(geo_nodes, "No geonode modifier found") + + # Now validate that one of the settings was updated. + # TODO: example where we assert the active effect `subpath` is non empty + + def test_particle_area_effect_spawner(self): + """Test the particle area variant of effect spawning works.""" + scn_props = bpy.context.scene.mcprep_props + etype = "particle_area" + + pre_count = len([ + x for x in scn_props.effects_list if x.effect_type == etype]) + bpy.ops.mcprep.reload_effects() + post_count = len([ + x for x in scn_props.effects_list if x.effect_type == etype]) + + self.assertEqual(pre_count, 0, "Should start with no effects loaded") + self.assertGreater( + post_count, 0, "Should have more effects loaded after reload") + + effect = [x for x in scn_props.effects_list if x.effect_type == etype][0] + res = bpy.ops.mcprep.spawn_global_effect(effect_id=str(effect.index)) + self.assertEqual(res, {'FINISHED'}, "Did not end with finished result") + + # TODO: Further checks it actually loaded the effect. + + def test_collection_effect_spawner(self): + """Test the collection variant of effect spawning works.""" + scn_props = bpy.context.scene.mcprep_props + etype = "collection" + + pre_count = len([ + x for x in scn_props.effects_list if x.effect_type == etype]) + bpy.ops.mcprep.reload_effects() + post_count = len([ + x for x in scn_props.effects_list if x.effect_type == etype]) + + self.assertEqual(pre_count, 0, "Should start with no effects loaded") + self.assertGreater( + post_count, 0, "Should have more effects loaded after reload") + + init_objs = list(bpy.data.objects) + + effect = [x for x in scn_props.effects_list if x.effect_type == etype][0] + res = bpy.ops.mcprep.spawn_instant_effect( + effect_id=str(effect.index), frame=2) + self.assertEqual(res, {'FINISHED'}, "Did not end with finished result") + + final_objs = list(bpy.data.objects) + new_objs = list(set(final_objs) - set(init_objs)) + self.assertGreater(len(new_objs), 0, "didn't crate new objects") + self.assertTrue( + bpy.context.object in new_objs, "Selected obj is not a new object") + + is_empty = bpy.context.object.type == 'EMPTY' + is_coll_inst = bpy.context.object.instance_type == 'COLLECTION' + self.assertFalse( + not is_empty or not is_coll_inst, + "Didn't end up with selected collection instance") + # TODO: Further checks it actually loaded the effect. + + +class ModelSpawnerTest(BaseSpawnerTest): + """ModelSpawning-related tests.""" + + def test_model_spawner(self): + """Test model spawning and reloading""" + scn_props = bpy.context.scene.mcprep_props + + pre_count = len(scn_props.model_list) + res = bpy.ops.mcprep.reload_models() + self.assertEqual(res, {'FINISHED'}) + post_count = len(scn_props.model_list) + + self.assertEqual(pre_count, 0, + "Should have opened new file with unloaded assets") + self.assertGreater(post_count, 0, "No models loaded") + self.assertGreater(post_count, 50, + "Too few models loaded, missing texturepack?") + + # spawn with whatever default index + pre_objs = list(bpy.data.objects) + res = bpy.ops.mcprep.spawn_model( + filepath=scn_props.model_list[scn_props.model_list_index].filepath) + self.assertEqual(res, {'FINISHED'}) + post_objs = list(bpy.data.objects) + + self.assertGreater(len(post_objs), len(pre_objs), "No models spawned") + self.assertEqual(len(post_objs), len(pre_objs) + 1, + "More than one model spawned") + + # Test that materials were properly added. + new_objs = list(set(post_objs) - set(pre_objs)) + model = new_objs[0] + self.assertTrue(model.active_material, "No material on model") + + +class EntitySpawnerTest(BaseSpawnerTest): + """EntitySpawning-related tests.""" + + def test_entity_spawner(self): + scn_props = bpy.context.scene.mcprep_props + + pre_count = len(scn_props.entity_list) + self.assertEqual(pre_count, 0, + "Should have opened new file with unloaded assets") + bpy.ops.mcprep.reload_entities() + post_count = len(scn_props.entity_list) + + self.assertGreater(post_count, 5, "Too few entities loaded") + + # spawn with whatever default index + pre_objs = len(bpy.data.objects) + bpy.ops.mcprep.entity_spawner() + post_objs = len(bpy.data.objects) + + self.assertGreater(post_objs, pre_objs, "No entity spawned") + # Test collection/group added + # Test loading from file. + + +class MeshswapTest(BaseSpawnerTest): + """Meshswap-related tests.""" + + def _import_world_with_settings(self, file: str): + testdir = os.path.dirname(__file__) + obj_path = os.path.join(testdir, file) + + self.assertTrue(os.path.isfile(obj_path), + f"Obj file missing: {obj_path}, {file}") + res = bpy.ops.mcprep.import_world_split(filepath=obj_path) + self.assertEqual(res, {'FINISHED'}) + self.assertGreater(len(bpy.data.objects), 50, "Should have many objs") + self.assertGreater( + len(bpy.data.materials), 50, "Should have many mats") + + def _meshswap_util(self, mat_name: str) -> str: + """Run meshswap on the first object with found mat_name""" + if mat_name not in bpy.data.materials: + return "Not a material: " + mat_name + print("\nAttempt meshswap of " + mat_name) + mat = bpy.data.materials[mat_name] + + obj = None + for ob in bpy.data.objects: + for slot in ob.material_slots: + if slot and slot.material == mat: + obj = ob + break + if obj: + break + if not obj: + return "Failed to find obj for " + mat_name + print("Found the object - " + obj.name) + + # Select only the object in question + for ob in bpy.context.scene.objects: + try: + ob.select_set(False) + except Exception: + pass + obj.select_set(True) + res = bpy.ops.mcprep.meshswap() + if res != {'FINISHED'}: + return "Meshswap returned cancelled for " + mat_name + return "" + + def test_meshswap_world_jmc(self): + test_subpath = os.path.join("test_data", "jmc2obj_test_1_15_2.obj") + self._import_world_with_settings(file=test_subpath) + self.addon_prefs = util.get_user_preferences(bpy.context) + self.assertEqual(self.addon_prefs.MCprep_exporter_type, "jmc2obj") + + # known jmc2obj material names which we expect to be able to meshswap + test_materials = [ + "torch", + "fire", + "lantern", + "cactus_side", + "vines", # plural + "enchant_table_top", + "redstone_torch_on", + "glowstone", + "redstone_lamp_on", + "pumpkin_front_lit", + "sugarcane", + "chest", + "largechest", + "sunflower_bottom", + "sapling_birch", + "white_tulip", + "sapling_oak", + "sapling_acacia", + "sapling_jungle", + "blue_orchid", + "allium", + ] + + for mat_name in test_materials: + with self.subTest(mat_name): + res = self._meshswap_util(mat_name) + self.assertEqual("", res) + + def test_meshswap_world_mineways_separated(self): + test_subpath = os.path.join( + "test_data", "mineways_test_separated_1_15_2.obj") + self._import_world_with_settings(file=test_subpath) + self.addon_prefs = util.get_user_preferences(bpy.context) + self.assertEqual(self.addon_prefs.MCprep_exporter_type, "Mineways") + + # known mineways material names which we expect to be able to meshswap + test_materials = [ + "grass", + "torch", + "fire_0", + "MWO_chest_top", + "MWO_double_chest_top_left", + # "lantern", not in test object + "cactus_side", + "vine", # singular + "enchanting_table_top", + # "redstone_torch_on", no separate "on" for Mineways separated exports + "glowstone", + "redstone_torch", + "jack_o_lantern", + "sugar_cane", + "jungle_sapling", + "dark_oak_sapling", + "oak_sapling", + "campfire_log", + "white_tulip", + "blue_orchid", + "allium", + ] + + for mat_name in test_materials: + with self.subTest(mat_name): + res = self._meshswap_util(mat_name) + self.assertEqual("", res) + + def test_meshswap_world_mineways_combined(self): + test_subpath = os.path.join( + "test_data", "mineways_test_combined_1_15_2.obj") + self._import_world_with_settings(file=test_subpath) + self.addon_prefs = util.get_user_preferences(bpy.context) + self.assertEqual(self.addon_prefs.MCprep_exporter_type, "Mineways") + + # known mineways material names which we expect to be able to meshswap + test_materials = [ + "Sunflower", + "Torch", + "Redstone_Torch_(active)", + "Lantern", + "Dark_Oak_Sapling", + "Sapling", # should map to oak sapling + "Birch_Sapling", + "Cactus", + "White_Tulip", + "Vines", + "Ladder", + "Enchanting_Table", + "Campfire", + "Jungle_Sapling", + "Red_Tulip", + "Blue_Orchid", + "Allium", + ] + + for mat_name in test_materials: + with self.subTest(mat_name): + res = self._meshswap_util(mat_name) + self.assertEqual("", res) + + def test_meshswap_spawner(self): + scn_props = bpy.context.scene.mcprep_props + bpy.ops.mcprep.reload_meshswap() + self.assertGreater(len(scn_props.meshswap_list), 15, + "Too few meshswap assets available") + + # Add with make real = False + res = bpy.ops.mcprep.meshswap_spawner( + block='banner', method="collection", make_real=False) + self.assertEqual(res, {'FINISHED'}) + + # test doing two of the same one (first won't be cached, second will) + # Add one with make real = True + res = bpy.ops.mcprep.meshswap_spawner( + block='fire', method="collection", make_real=True) + self.assertEqual(res, {'FINISHED'}) + self.assertTrue('fire' in bpy.data.collections, + "Fire not in collections") + self.assertTrue(len(bpy.context.selected_objects) > 0, + "Added made-real meshswap objects not selected") + + # Test that cache is properly used. Also test that the default + # 'method=colleciton' is used. + res = bpy.ops.mcprep.meshswap_spawner(block='fire', make_real=False) + count = sum([1 for itm in bpy.data.collections if 'fire' in itm.name]) + self.assertEqual( + count, 1, "Imported extra fire group, should have cached instead!") + + # test that added item ends up in location location=(1,2,3) + loc = (1, 2, 3) + bpy.ops.mcprep.meshswap_spawner( + block='fire', method="collection", make_real=False, location=loc) + self.assertTrue( + bpy.context.object, "Added meshswap object not added as active") + self.assertTrue( + bpy.context.selected_objects, "Added meshswap object not selected") + self.assertEqual(bpy.context.object.location, + Vector(loc), + "Location not properly applied") + count = sum([1 for itm in bpy.data.collections if 'fire' in itm.name]) + self.assertEqual( + count, 1, "Should have 1 fire groups exactly, did not cache") + + +if __name__ == '__main__': + unittest.main(exit=False) diff --git a/test_files/jmc2obj/jmc2obj_test_1_15_2.mtl b/test_files/test_data/jmc2obj_test_1_15_2.mtl similarity index 100% rename from test_files/jmc2obj/jmc2obj_test_1_15_2.mtl rename to test_files/test_data/jmc2obj_test_1_15_2.mtl diff --git a/test_files/jmc2obj/jmc2obj_test_1_15_2.obj b/test_files/test_data/jmc2obj_test_1_15_2.obj similarity index 100% rename from test_files/jmc2obj/jmc2obj_test_1_15_2.obj rename to test_files/test_data/jmc2obj_test_1_15_2.obj diff --git a/test_files/mineways/combined_textures/mineways_test_combined_1_15_2.mtl b/test_files/test_data/mineways_test_combined_1_15_2.mtl similarity index 100% rename from test_files/mineways/combined_textures/mineways_test_combined_1_15_2.mtl rename to test_files/test_data/mineways_test_combined_1_15_2.mtl diff --git a/test_files/mineways/combined_textures/mineways_test_combined_1_15_2.obj b/test_files/test_data/mineways_test_combined_1_15_2.obj similarity index 100% rename from test_files/mineways/combined_textures/mineways_test_combined_1_15_2.obj rename to test_files/test_data/mineways_test_combined_1_15_2.obj diff --git a/test_files/mineways/separated_textures/mineways_test_separated_1_15_2.mtl b/test_files/test_data/mineways_test_separated_1_15_2.mtl similarity index 100% rename from test_files/mineways/separated_textures/mineways_test_separated_1_15_2.mtl rename to test_files/test_data/mineways_test_separated_1_15_2.mtl diff --git a/test_files/mineways/separated_textures/mineways_test_separated_1_15_2.obj b/test_files/test_data/mineways_test_separated_1_15_2.obj similarity index 100% rename from test_files/mineways/separated_textures/mineways_test_separated_1_15_2.obj rename to test_files/test_data/mineways_test_separated_1_15_2.obj diff --git a/test_files/mtl_atlas_modified.mtl b/test_files/test_data/mtl_atlas_modified.mtl similarity index 100% rename from test_files/mtl_atlas_modified.mtl rename to test_files/test_data/mtl_atlas_modified.mtl diff --git a/test_files/mtl_atlas_original.mtl b/test_files/test_data/mtl_atlas_original.mtl similarity index 100% rename from test_files/mtl_atlas_original.mtl rename to test_files/test_data/mtl_atlas_original.mtl diff --git a/test_files/mtl_simple_modified.mtl b/test_files/test_data/mtl_simple_modified.mtl similarity index 100% rename from test_files/mtl_simple_modified.mtl rename to test_files/test_data/mtl_simple_modified.mtl diff --git a/test_files/mtl_simple_original.mtl b/test_files/test_data/mtl_simple_original.mtl similarity index 100% rename from test_files/mtl_simple_original.mtl rename to test_files/test_data/mtl_simple_original.mtl diff --git a/test_files/test_runner.py b/test_files/test_runner.py new file mode 100644 index 00000000..d70a1bdc --- /dev/null +++ b/test_files/test_runner.py @@ -0,0 +1,143 @@ +# ##### 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 argparse +import os +import sys +import unittest + +import bpy + +# Commandline script passed to Blender, directory should be //test/ +MODULE_DIR = os.path.dirname(__file__) +sys.path.append(MODULE_DIR) + +SPACER = "-" * 79 + + +def get_args(): + """Sets up and returns argument parser for module functions.""" + # Override the args available to arg parser, to ignore blender ones. + if "--" in sys.argv: + test_args = sys.argv[sys.argv.index("--") + 1:] + else: + test_args = [] + sys.argv = [os.path.basename(__file__)] + test_args + + parser = argparse.ArgumentParser(description="Run MCprep tests.") + parser.add_argument( + "-t", "--test_specific", + help="Run only a specific test function or class matching this name") + parser.add_argument( + "-v", "--version", + help="Specify the blender version(s) to test, in #.# or #.##,#.#") + return parser.parse_args() + + +def setup_env_paths(self): + """Adds the MCprep installed addon path to sys for easier importing.""" + to_add = None + + for base in bpy.utils.script_paths(): + init = os.path.join(base, "addons", "MCprep_addon", "__init__.py") + if os.path.isfile(init): + to_add = init + break + if not to_add: + raise Exception("Could not add MCprep addon path for importing") + + sys.path.insert(0, to_add) + + +def main(): + args = get_args() + suite = unittest.TestSuite() + + sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + + if args.version is not None: + # A concatenated list like 3.5,2.80 + bvers = args.version.split(",") + # Get the tuple version to compare to current version. + valid_bver = False + for this_bv in bvers: + this_bv_tuple = tuple(int(i) for i in this_bv.split(".")) + + # Get the current version of blender, truncating to the same number + # of digits (so input: #.# -> current bv 2.80.1 gets cut to 2.80) + open_blender = bpy.app.version[:len(this_bv_tuple)] + if open_blender == this_bv_tuple: + valid_bver = True + break + + if not valid_bver: + print(f"Skipping: Blender {bpy.app.version} does not match {args.version}") + sys.exit() + + print("") + print(SPACER) + print(f"Running tests for {bpy.app.version}...") + + # Run tests in the addon tests directory matching rule. + suite.addTest(unittest.TestLoader().discover(MODULE_DIR, "*_test.py")) + + # Check if there's another input for a single test to run, + # rebuild the test suite with only that flagged test. + if args.test_specific: + new_suite = unittest.TestSuite() + for test_file in suite._tests: + for test_cls in test_file._tests: + for test_function_suite in test_cls: + for this_test in test_function_suite._tests: + # str in format of: + # file_name.ClassName.test_case_name + tst_name_id = this_test.id() + tst_names = tst_name_id.split(".") + if args.test_specific in tst_names: + new_suite.addTest(this_test) + print("Run only: ", this_test._testMethodName) + suite = new_suite + + results = unittest.TextTestRunner(verbosity=2).run(suite) + print(SPACER) + print(results) + + # Append outputs to csv file, so that we can easily see multiple + # runs across different versions of blender to verify successes. + errs = [res[0].id().split(".")[-1] for res in results.errors] + fails = [res[0].id().split(".")[-1] for res in results.failures] + skipped = [res[0].id().split(".")[-1] for res in results.skipped] + + with open("test_results.csv", 'a') as csv: + errors = ";".join(errs + fails).replace(",", " ") + if errors == "": + errors = "No errors" + csv.write("{},{},{},{},{},{}\r\n".format( + str(bpy.app.version).replace(",", "."), + "all_tests" if not args.test_specific else args.test_specific, + results.testsRun - len(skipped), + len(skipped), + len(results.errors) + len(results.failures), + errors, + )) + print("Wrote out test results.") + sys.exit() + + +if __name__ == '__main__': + main() diff --git a/test_files/util_test.py b/test_files/util_test.py new file mode 100644 index 00000000..d5470bec --- /dev/null +++ b/test_files/util_test.py @@ -0,0 +1,95 @@ +# ##### 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 unittest + +import bpy +import os + +from MCprep_addon.util import nameGeneralize + +# TODO: restructure tests to be inside MCprep_addon to support rel imports. +# from . import test_runner +RUN_SLOW_TESTS = False + +# Tests which cause some UI changes, such as opening webpages or folder opens. +RUN_UI_TESTS = False + + +class UtilOperatorsTest(unittest.TestCase): + """Create tests for the util_operators.py file.""" + + @classmethod + def setUpClass(cls): + bpy.ops.preferences.addon_enable(module="MCprep_addon") + + def test_improve_ui(self): + res = bpy.ops.mcprep.improve_ui() + self.assertEqual(res, {"CANCELLED"}) # Always returns this in bg mode + + # Cannot have the right context for this to run. + # def test_open_preferences(self): + # res = bpy.ops.mcprep.open_preferences() + # self.assertEqual(res, {"FINISHED"}) + + @unittest.skipUnless(RUN_UI_TESTS, "Skip UI: test_open_folder") + def test_open_folder(self): + folder = bpy.utils.script_path_user() + self.assertTrue(os.path.isdir(folder), "Invalid target folder") + res = bpy.ops.mcprep.openfolder(folder=folder) + self.assertEqual(res, {"FINISHED"}) + + with self.assertRaises(RuntimeError): + res = bpy.ops.mcprep.openfolder(folder="/fake/folder") + self.assertEqual(res, {"CANCELLED"}) + + @unittest.skipUnless(RUN_UI_TESTS, "Skip UI: test_open_folder") + def test_open_help(self): + # Mocking not working this way when used on operators. + # with mock.patch("bpy.ops.wm.url_open") as mock_open: + res = bpy.ops.mcprep.open_help(url="https://theduckcow.com") + self.assertEqual(res, {"FINISHED"}) + + def test_name_generalize(self): + """Tests the outputs of the generalize function""" + + test_sets = { + "ab": "ab", + "table.001": "table", + "table.100": "table", + "table001": "table001", + "fire_0": "fire_0", + # "fire_0_0001.png":"fire_0", not current behavior, but desired? + "fire_0_0001": "fire_0", + "fire_0_0001.001": "fire_0", + "fire_layer_1": "fire_layer_1", + "cartography_table_side1": "cartography_table_side1" + } + for key in list(test_sets): + with self.subTest(key): + res = nameGeneralize(key) + self.assertEqual( + res, test_sets[key], + f"{key} converts to {res} and should be {test_sets[key]}") + + +if __name__ == '__main__': + # TODO: restructure tests to be inside MCprep_addon to support rel imports. + # args = test_runner.get_args() + # RUN_SLOW_TESTS = args.speed == "slow" + unittest.main(exit=False) diff --git a/test_files/world_tools_test.py b/test_files/world_tools_test.py new file mode 100644 index 00000000..ef71b316 --- /dev/null +++ b/test_files/world_tools_test.py @@ -0,0 +1,367 @@ +# ##### 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 filecmp +import os +import shutil +import tempfile +import unittest + +import bpy + +from MCprep_addon import util +from MCprep_addon import world_tools +from MCprep_addon.materials.generate import find_from_texturepack +from MCprep_addon.materials.generate import get_mc_canonical_name +from MCprep_addon.materials.uv_tools import detect_invalid_uvs_from_objs +from MCprep_addon.util import materialsFromObj + + +class WorldToolsTest(unittest.TestCase): + """World tools related tests.""" + + @classmethod + def setUpClass(cls): + bpy.ops.preferences.addon_enable(module="MCprep_addon") + + def setUp(self): + """Clears scene and data between each test""" + bpy.ops.wm.read_homefile(app_template="", use_empty=True) + self.addon_prefs = util.get_user_preferences(bpy.context) + + # ------------------------------------------------------------------------- + # Sub-test utilities + # ------------------------------------------------------------------------- + + def _import_world_with_settings(self, file: str): + testdir = os.path.dirname(__file__) + obj_path = os.path.join(testdir, file) + + self.assertEqual(len(bpy.data.objects), 0, "Should start with no objs") + self.assertTrue(os.path.isfile(obj_path), + f"Obj file missing: {obj_path}, {file}") + res = bpy.ops.mcprep.import_world_split(filepath=obj_path) + self.assertEqual(res, {'FINISHED'}) + self.assertGreater(len(bpy.data.objects), 50, "Should have many objs") + self.assertGreater( + len(bpy.data.materials), 50, "Should have many mats") + + def _canonical_name_no_none(self): + """Ensure that MC canonical name never returns none""" + + mats = materialsFromObj(bpy.context.scene.objects) + canons = [[get_mc_canonical_name(mat.name)][0] for mat in mats] + self.assertGreater(len(canons), 10, "Materials not selected") + self.assertNotIn(None, canons, "Canon returned none value") + self.assertNotIn("", canons, "Canon returned empty str value") + + # Ensure it never returns None + in_str, _ = get_mc_canonical_name('') + self.assertEqual(in_str, '', "Empty str should return empty string") + + with self.assertRaises(TypeError): + get_mc_canonical_name(None) # "None input SHOULD raise error" + + def _import_materials_util(self, mapping_set): + """Reusable function for testing on different obj setups""" + + util.load_mcprep_json() # force load json cache + # Must use the reference of env associated with util, + # can't import conf separately. + mcprep_data = util.env.json_data["blocks"][mapping_set] + + # first detect alignment to the raw underlining mappings, nothing to + # do with canonical yet + mapped = [ + mat.name for mat in bpy.data.materials + if mat.name in mcprep_data] # ok! + unmapped = [ + mat.name for mat in bpy.data.materials + if mat.name not in mcprep_data] # not ok + fullset = mapped + unmapped # ie all materials + unleveraged = [ + mat for mat in mcprep_data + if mat not in fullset] # not ideal, means maybe missed check + + # print("Mapped: {}, unmapped: {}, unleveraged: {}".format( + # len(mapped), len(unmapped), len(unleveraged))) + + # if len(unmapped): + # err = "Textures not mapped to json file" + # print(err) + # print(sorted(unmapped)) + # print("") + # # return err + # if len(unleveraged) > 20: + # err = "Json file materials not found, need to update world?" + # print(err) + # print(sorted(unleveraged)) + # # return err + + self.assertGreater(len(mapped), 0, "No materials mapped") + if mapping_set == "block_mapping_mineways" and len(mapped) > 75: + # Known that the "combined" atlas texture of Mineways imports + # has low coverge, and we're not doing anything about it. + # but will at least capture if coverage gets *worse*. + pass + elif len(mapped) < len(unmapped): # too many esp. for Mineways + # not a very optimistic threshold, but better than none + self.fail("More materials unmapped than mapped") + + # each element is [cannon_name, form], form is none if not matched + mapped = [get_mc_canonical_name(mat.name) + for mat in bpy.data.materials] + + # no matching canon name (warn) + mats_not_canon = [itm[0] for itm in mapped if itm[1] is None] + if mats_not_canon and mapping_set != "block_mapping_mineways": + # print("Non-canon material names found: ({})".format(len(mats_not_canon))) + # print(mats_not_canon) + if len(mats_not_canon) > 30: # arbitrary threshold + self.fail("Too many materials found without canonical name") + + # affirm the correct mappings + mats_no_packimage = [ + find_from_texturepack(itm[0]) for itm in mapped + if itm[1] is not None] + mats_no_packimage = [path for path in mats_no_packimage if path] + + # could not resolve image from resource pack (warn) even though in mapping + mats_no_packimage = [ + itm[0] for itm in mapped + if itm[1] is not None and not find_from_texturepack(itm[0])] + + # known number up front, e.g. chests, stone_slab_side, stone_slab_top + if len(mats_no_packimage) > 6: + miss_blocks = " | ".join(mats_no_packimage) + self.fail("Missing images for mcprep_data.json blocks: {}".format( + miss_blocks)) + + # also test that there are not raw image names not in mapping list + # but that otherwise could be added to the mapping list as file exists + + # ------------------------------------------------------------------------- + # Top-level tests + # ------------------------------------------------------------------------- + + def test_enable_obj_importer(self): + """Ensure module name is correct, since error won't be reported.""" + bpy.ops.preferences.addon_enable(module="io_scene_obj") + + def test_world_import_jmc_full(self): + test_subpath = os.path.join( + "test_data", "jmc2obj_test_1_15_2.obj") + self._import_world_with_settings(file=test_subpath) + self.assertEqual(self.addon_prefs.MCprep_exporter_type, "jmc2obj") + + # UV tool test. Would be in its own test, but then we would be doing + # multiple unnecessary imports of the same world. So make it a subtest. + with self.subTest("test_uv_transform_no_alert_jmc2obj"): + invalid, invalid_objs = detect_invalid_uvs_from_objs( + bpy.context.selected_objects) + prt = ",".join([obj.name.split("_")[-1] for obj in invalid_objs]) + self.assertFalse( + invalid, f"jmc2obj export should not alert: {prt}") + + with self.subTest("canon_name_validation"): + self._canonical_name_no_none() + + with self.subTest("test_mappings"): + self._import_materials_util("block_mapping_jmc") + + def test_world_import_mineways_separated(self): + test_subpath = os.path.join( + "test_data", "mineways_test_separated_1_15_2.obj") + self._import_world_with_settings(file=test_subpath) + self.assertEqual(self.addon_prefs.MCprep_exporter_type, "Mineways") + + # UV tool test. Would be in its own test, but then we would be doing + # multiple unnecessary imports of the same world. So make it a subtest. + with self.subTest("test_uv_transform_no_alert_mineways"): + invalid, invalid_objs = detect_invalid_uvs_from_objs( + bpy.context.selected_objects) + prt = ",".join([obj.name for obj in invalid_objs]) + self.assertFalse( + invalid, + f"Mineways separated tiles export should not alert: {prt}") + + with self.subTest("canon_name_validation"): + self._canonical_name_no_none() + + with self.subTest("test_mappings"): + self._import_materials_util("block_mapping_mineways") + + def test_world_import_mineways_combined(self): + test_subpath = os.path.join( + "test_data", "mineways_test_combined_1_15_2.obj") + self._import_world_with_settings(file=test_subpath) + self.assertEqual(self.addon_prefs.MCprep_exporter_type, "Mineways") + + with self.subTest("test_uv_transform_combined_alert"): + invalid, invalid_objs = detect_invalid_uvs_from_objs( + bpy.context.selected_objects) + self.assertTrue(invalid, "Combined image export should alert") + if not invalid_objs: + self.fail( + "Correctly alerted combined image, but no obj's returned") + + # Do specific checks for water and lava, could be combined and + # cover more than one uv position (and falsely pass the test) in + # combined, water is called "Stationary_Wat" and "Stationary_Lav" + # (yes, appears cutoff; and yes includes the flowing too) + # NOTE! in 2.7x, will be named "Stationary_Water", but in 2.9 it is + # "Test_MCprep_1.16.4__-145_4_1271_to_-118_255_1311_Stationary_Wat" + water_obj = [obj for obj in bpy.data.objects + if "Stationary_Wat" in obj.name][0] + lava_obj = [obj for obj in bpy.data.objects + if "Stationary_Lav" in obj.name][0] + + invalid, invalid_objs = detect_invalid_uvs_from_objs( + [lava_obj, water_obj]) + self.assertTrue(invalid, "Combined lava/water should still alert") + + with self.subTest("canon_name_validation"): + self._canonical_name_no_none() + + with self.subTest("test_mappings"): + self._import_materials_util("block_mapping_mineways") + + def test_world_import_fails_expected(self): + testdir = os.path.dirname(__file__) + obj_path = os.path.join(testdir, "fake_world.obj") + with self.assertRaises(RuntimeError): + res = bpy.ops.mcprep.import_world_split(filepath=obj_path) + self.assertEqual(res, {'CANCELLED'}) + + def test_add_mc_sky(self): + subtests = [ + # name / enum option, add_clouds, remove_existing_suns + ["world_shader", True, True], + ["world_mesh", True, False], + ["world_only", False, True], + ["world_static_mesh", False, False], + ["world_static_only", True, True] + ] + + for sub in subtests: + with self.subTest(sub[0]): + # Reset the scene + bpy.ops.wm.read_homefile(app_template="", use_empty=True) + + pre_objs = len(bpy.data.objects) + res = bpy.ops.mcprep.add_mc_sky( + world_type=sub[0], + add_clouds=sub[1], + remove_existing_suns=sub[2] + ) + post_objs = len(bpy.data.objects) + self.assertEqual(res, {'FINISHED'}) + self.assertGreater( + post_objs, pre_objs, "No timeobject imported") + obj = world_tools.get_time_object() + if "static" in sub[0]: + self.assertFalse(obj, "Static scn should have no timeobj") + else: + self.assertTrue(obj, "Dynamic scn should have timeobj") + + def test_convert_mtl_simple(self): + """Ensures that conversion of the mtl with other color space works.""" + + src = "mtl_simple_original.mtl" + end = "mtl_simple_modified.mtl" + test_dir = os.path.dirname(__file__) + simple_mtl = os.path.join(test_dir, "test_data", src) + modified_mtl = os.path.join(test_dir, "test_data", end) + + # now save the texturefile somewhere + tmp_dir = tempfile.gettempdir() + tmp_mtl = os.path.join(tmp_dir, src) + shutil.copyfile(simple_mtl, tmp_mtl) # leave original intact + + self.assertTrue( + os.path.isfile(tmp_mtl), + f"Failed to create tmp tml at {tmp_mtl}") + + # Need to mock: + # bpy.context.scene.view_settings.view_transform + # to be an invalid kind of attribute, to simulate an ACES or AgX space. + # But we can't do that since we're not (yet) using the real unittest + # framework, hence we'll just clear the world_tool's vars. + save_init = list(world_tools.BUILTIN_SPACES) + world_tools.BUILTIN_SPACES = ["NotRealSpace"] + print("TEST: pre", world_tools.BUILTIN_SPACES) + + # Resultant file + res = world_tools.convert_mtl(tmp_mtl) + + # Restore the property we unset. + world_tools.BUILTIN_SPACES = save_init + print("TEST: post", world_tools.BUILTIN_SPACES) + + self.assertIsNotNone( + res, + "Failed to mock color space and thus could not test convert_mtl") + + self.assertTrue(res, "Convert mtl failed with false response") + + # Now check that the data is the same. + res = filecmp.cmp(tmp_mtl, modified_mtl, shallow=False) + self.assertTrue( + res, f"Generated MTL is different: {tmp_mtl} vs {modified_mtl}") + # Not removing file, since we likely want to inspect it. + os.remove(tmp_mtl) + + def test_convert_mtl_skip(self): + """Ensures that we properly skip if a built in space active.""" + + src = "mtl_simple_original.mtl" + test_dir = os.path.dirname(__file__) + simple_mtl = os.path.join(test_dir, "test_data", src) + + # now save the texturefile somewhere + tmp_dir = tempfile.gettempdir() + tmp_mtl = os.path.join(tmp_dir, src) + shutil.copyfile(simple_mtl, tmp_mtl) # leave original intact + + self.assertTrue( + os.path.isfile(tmp_mtl), f"Failed to create tmp tml at {tmp_mtl}") + + # Need to mock: + # bpy.context.scene.view_settings.view_transform + # to be an invalid kind of attribute, to simulate an ACES or AgX space. + # But we can't do that since we're not (yet) using the real unittest + # framework, hence we'll just clear the world_tool's vars. + actual_space = str(bpy.context.scene.view_settings.view_transform) + save_init = list(world_tools.BUILTIN_SPACES) + world_tools.BUILTIN_SPACES = [actual_space] + # print("TEST: pre", world_tools.BUILTIN_SPACES) + + # Resultant file + res = world_tools.convert_mtl(tmp_mtl) + + # Restore the property we unset. + world_tools.BUILTIN_SPACES = save_init + # print("TEST: post", world_tools.BUILTIN_SPACES) + + if res is not None: + os.remove(tmp_mtl) + self.assertIsNone(res, "Should not have converter MTL for valid space") + + +if __name__ == '__main__': + unittest.main(exit=False)