diff --git a/.gitattributes b/.gitattributes index 63ea2676..1d8939fd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,4 @@ *.mtl filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text *.blend filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text diff --git a/LICENSE-3RD-PARTY.txt b/LICENSE-3RD-PARTY.txt new file mode 100644 index 00000000..8234520d --- /dev/null +++ b/LICENSE-3RD-PARTY.txt @@ -0,0 +1,32 @@ +----------------------------------------------------------------------------- +The BSD 3-Clause License + +Applies to: + commonmcobj_parser.py Copyright (c) 2024, Mahid Sheikh + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +----------------------------------------------------------------------------- diff --git a/MCprep_addon/commonmcobj_parser.py b/MCprep_addon/commonmcobj_parser.py new file mode 100644 index 00000000..43346e24 --- /dev/null +++ b/MCprep_addon/commonmcobj_parser.py @@ -0,0 +1,252 @@ +# BSD 3-Clause License +# +# Copyright (c) 2024, Mahid Sheikh +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# The parser is under a more permissive BSD 3-Clause license to make it easier +# for developers to use in non-GPL code. Normally, I wouldn't do dual licensing, +# but in this case, it makes sense as it would allow developers to reuse this +# parser for their own uses under more permissive terms. This doesn't change anything +# related to MCprep, which is GPL, as BSD 3-Clause is compatible with GPL. The +# only part that might conflict is Clause 3, but it could be argued that one +# can't do that under GPL anyway, or any license for that matter, and that +# Clause 3 is just a reminder to developers. +# +# - Mahid Sheikh + +from dataclasses import dataclass +from enum import Enum +from typing import List, Optional, Tuple, TextIO + +MAX_SUPPORTED_VERSION = 1 + +class CommonMCOBJTextureType(Enum): + ATLAS = "ATLAS" + INDIVIDUAL_TILES = "INDIVIDUAL_TILES" + +@dataclass +class CommonMCOBJ: + """ + Python representation of the CommonMCOBJ header + """ + # Version of the CommonMCOBJ spec + version: int + + # Exporter name in all lowercase + exporter: str + + # Name of source world + world_name: str + + # Path of source world* + world_path: str + + # Min values of the selection bounding box + export_bounds_min: Tuple[int, int, int] + + # Max values of the selection bounding box + export_bounds_max: Tuple[int, int, int] + + # Offset from (0, 0, 0) + export_offset: Tuple[float, float, float] + + # Scale of each block in meters; by default, this should be 1 meter + block_scale: float + + # Coordinate offset for blocks + block_origin_offset: Tuple[float, float, float] + + # Is the Z axis of the OBJ considered up? + z_up: bool + + # Are the textures using large texture atlases or + # individual textures? + texture_type: CommonMCOBJTextureType + + # Are blocks split by type? + has_split_blocks: bool + + # Original header + original_header: Optional[str] + +def parse_common_header(header_lines: list[str]) -> CommonMCOBJ: + """ + Parses the CommonMCOBJ header information from a list of strings. + + header_lines list[str]: + list of strings representing each line of the header. + + returns: + CommonMCOBJ object + """ + + # Split at the colon and clean up formatting + def clean_and_extract(line: str) -> Tuple[str, str]: + split = line.split(':', 1) + pos = 0 + for i,x in enumerate(split[0]): + if x.isalpha(): + pos = i + break + return (split[0][pos:], split[1].strip()) + + # Basic values + header = CommonMCOBJ( + version=0, + exporter="NULL", + world_name="NULL", + world_path="NULL", + export_bounds_min=(0, 0, 0), + export_bounds_max=(0, 0, 0), + export_offset=(0, 0, 0), + block_scale=0, + block_origin_offset=(0, 0, 0), + z_up=False, + texture_type=CommonMCOBJTextureType.ATLAS, + has_split_blocks=False, + original_header=None + ) + + # Keys whose values do not need extra processing + NO_VALUE_PARSE = [ + "exporter", + "world_name", + "world_path", + ] + + # Keys whose values are tuples + TUPLE_PARSE_INT = [ + "export_bounds_min", + "export_bounds_max", + ] + + TUPLE_PARSE_FLOAT = [ + "export_offset", + "block_origin_offset" + ] + + # Keys whose values are booleans + BOOLEAN_PARSE = [ + "z_up", + "has_split_blocks" + ] + + # Although CommonMCOBJ states that + # order does matter in the header, + # future versions may change the order + # of some values, so it's best to + # use a non-order specific parser + for line in header_lines: + if ":" not in line: + continue + key, value = clean_and_extract(line) + + if key == "version": + try: + header.version = int(value) + if header.version > MAX_SUPPORTED_VERSION: + header.original_header = "\n".join(header_lines) + except Exception: + pass + + elif key == "block_scale": + try: + header.block_scale = float(value) + except Exception: + pass + + elif key == "texture_type": + try: + header.texture_type = CommonMCOBJTextureType[value] + except Exception: + pass + + # All of these are parsed the same, with + # no parsing need to value + # + # No keys here will be classed as failed + elif key in NO_VALUE_PARSE: + setattr(header, key, value) + + # All of these are parsed the same, with + # parsing the value to a tuple + elif key in TUPLE_PARSE_INT: + try: + setattr(header, key, tuple(map(int, value[1:-1].split(', ')))) + except Exception: + pass + + elif key in TUPLE_PARSE_FLOAT: + try: + setattr(header, key, tuple(map(float, value[1:-1].split(', ')))) + except Exception: + pass + + elif key in BOOLEAN_PARSE: + try: + setattr(header, key, value == "true") + except Exception: + pass + + return header + + +def parse_header(f: TextIO) -> Optional[CommonMCOBJ]: + """ + Parses a file and returns a CommonMCOBJ object if the header exists. + + f: TextIO + File object + + Returns: + - CommonMCOBJ object if header exists + - None otherwise + """ + + header: List[str] = [] + found_header = False + + # Read in the header + lines_read = 0 + for _l in f: + tl = " ".join(_l.rstrip().split()) + lines_read += 1 + if lines_read > 100 and tl and not tl.startswith("#"): + break # no need to parse further than the true header area + elif tl == "# COMMON_MC_OBJ_START": + header.append(tl) + found_header = True + continue + elif tl == "# COMMON_MC_OBJ_END": + header.append(tl) + break + if not found_header or tl == "#": + continue + header.append(tl) + if not len(header): + return None + return parse_common_header(header) diff --git a/MCprep_addon/conf.py b/MCprep_addon/conf.py index f09e83d4..2038d430 100644 --- a/MCprep_addon/conf.py +++ b/MCprep_addon/conf.py @@ -135,7 +135,7 @@ def __init__(self): # 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 = [] + self.material_sync_cache: List = [] # Whether we use PO files directly or use the converted form self.use_direct_i18n = False @@ -305,10 +305,16 @@ class MCprepError(object): Path of file the exception object was created in. The preferred way to get this is __file__ + + msg: Optional[str] + Optional message to display for an + exception. Use this if the exception + type may not be so clear cut """ err_type: BaseException line: int file: str + msg: Optional[str] = None env = MCprepEnv() diff --git a/MCprep_addon/materials/default_materials.py b/MCprep_addon/materials/default_materials.py deleted file mode 100644 index 7b2a6909..00000000 --- a/MCprep_addon/materials/default_materials.py +++ /dev/null @@ -1,219 +0,0 @@ -# ##### BEGIN GPL LICENSE BLOCK ##### -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# ##### END GPL LICENSE BLOCK ##### - - -import os -from typing import Union, Optional - -import bpy -from bpy.types import Context, Material - -from .. import tracking -from .. import util -from . import sync - -from ..conf import env, Engine - - -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 env.material_sync_cache is None: - sync.reload_material_sync_library(context) - if util.nameGeneralize(default_material) in env.material_sync_cache: - return True - elif default_material in env.material_sync_cache: - return True - return False - - -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 env.material_sync_cache: - import_name = default_material - 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. - sync_file = sync.get_sync_blend(context) - - init_mats = list(bpy.data.materials) - path = os.path.join(sync_file, "Material") - util.bAppendLink(path, import_name, False) # No linking. - - imported = set(list(bpy.data.materials)) - set(init_mats) - if not imported: - return f"Could not import {material.name}" - new_default_material = list(imported)[0] - - # Checking if there's a node with the label Texture. - new_material_nodes = new_default_material.node_tree.nodes - if not new_material_nodes.get("MCPREP_diffuse"): - return "Material has no MCPREP_diffuse node" - - if not material.node_tree.nodes: - return "Material has no nodes" - - # Change the texture. - new_default_material_nodes = new_default_material.node_tree.nodes - material_nodes = material.node_tree.nodes - - if not material_nodes.get("Image Texture"): - return "Material has no Image Texture node" - - default_texture_node = new_default_material_nodes.get("MCPREP_diffuse") - image_texture = material_nodes.get("Image Texture").image.name - texture_file = bpy.data.images.get(image_texture) - default_texture_node.image = texture_file - - if engine == "CYCLES" or engine == "BLENDER_EEVEE": - default_texture_node.interpolation = 'Closest' - - material.user_remap(new_default_material) - - # remove the old material since we're changing the default and we don't - # want to overwhelm users - bpy.data.materials.remove(material) - new_default_material.name = material.name - return None - - -class MCPREP_OT_default_material(bpy.types.Operator): - bl_idname = "mcprep.sync_default_materials" - bl_label = "Sync Default Materials" - bl_options = {'REGISTER', 'UNDO'} - - use_pbr: bpy.props.BoolProperty( - name="Use PBR", - description="Use PBR or not", - default=False) - - engine: bpy.props.StringProperty( - name="engine To Use", - description="Defines the engine to use", - default="CYCLES") - - SIMPLE = "simple" - PBR = "pbr" - - track_function = "sync_default_materials" - track_param = None - @tracking.report_error - def execute(self, context): - # Sync file stuff. - sync_file = sync.get_sync_blend(context) - if not os.path.isfile(sync_file): - self.report({'ERROR'}, f"Sync file not found: {sync_file}") - return {'CANCELLED'} - - if sync_file == bpy.data.filepath: - return {'CANCELLED'} - - # Find the default material. - workflow = self.SIMPLE if not self.use_pbr else self.PBR - 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'} - - # Sync materials. - mat_list = list(bpy.data.materials) - for mat in mat_list: - try: - err = sync_default_material(context, mat, material_name, self.engine.upper()) # no linking - if err: - env.log(err) - except Exception as e: - print(e) - - return {'FINISHED'} - - -class MCPREP_OT_create_default_material(bpy.types.Operator): - bl_idname = "mcprep.create_default_material" - bl_label = "Create Default Material" - bl_options = {'REGISTER', 'UNDO'} - - SIMPLE = "simple" - PBR = "pbr" - - def execute(self, context): - engine = context.scene.render.engine - 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 - type: the type of texture that's being dealt with - """ - if not len(bpy.context.selected_objects): - # If there's no selected objects. - self.report({'ERROR'}, "Select an object to create the material") - return - - 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 - links = default_material.node_tree.links - nodes.clear() - - default_texture_node = nodes.new(type="ShaderNodeTexImage") - principled = nodes.new("ShaderNodeBsdfPrincipled") - nodeOut = nodes.new("ShaderNodeOutputMaterial") - - default_texture_node.name = "MCPREP_diffuse" - default_texture_node.label = "Diffuse Texture" - default_texture_node.location = (120, 0) - - principled.inputs["Specular"].default_value = 0 - principled.location = (600, 0) - - nodeOut.location = (820, 0) - - links.new(default_texture_node.outputs[0], principled.inputs[0]) - links.new(principled.outputs["BSDF"], nodeOut.inputs[0]) - - if engine == "EEVEE": - if hasattr(default_material, "blend_method"): - default_material.blend_method = 'HASHED' - if hasattr(default_material, "shadow_method"): - default_material.shadow_method = 'HASHED' - - -classes = ( - MCPREP_OT_default_material, - MCPREP_OT_create_default_material, -) - - -def register(): - for cls in classes: - bpy.utils.register_class(cls) - bpy.app.handlers.load_post.append(sync.clear_sync_cache) - - -def unregister(): - for cls in reversed(classes): - bpy.utils.unregister_class(cls) - try: - bpy.app.handlers.load_post.remove(sync.clear_sync_cache) - except: - pass diff --git a/MCprep_addon/materials/generate.py b/MCprep_addon/materials/generate.py index ee69b908..4b3a2972 100644 --- a/MCprep_addon/materials/generate.py +++ b/MCprep_addon/materials/generate.py @@ -17,7 +17,7 @@ # ##### END GPL LICENSE BLOCK ##### import os -from typing import Dict, Optional, List, Any, Tuple, Union +from typing import Dict, Optional, List, Any, Tuple, Union, cast from pathlib import Path from dataclasses import dataclass from enum import Enum @@ -26,7 +26,7 @@ from bpy.types import Context, Material, Image, Texture, Nodes, NodeLinks, Node from .. import util -from ..conf import env, Form +from ..conf import MCprepError, env, Form AnimatedTex = Dict[str, int] @@ -125,43 +125,51 @@ def get_mc_canonical_name(name: str) -> Tuple[str, Optional[Form]]: return canon, form -def find_from_texturepack(blockname: str, resource_folder: Optional[Path]=None) -> Path: +def find_from_texturepack(blockname: str, resource_folder: Optional[Path]=None) -> Union[Path, MCprepError]: """Given a blockname (and resource folder), find image filepath. Finds textures following any pack which should have this structure, and the input folder or default resource folder could target at any of the following sublevels above the level. //pack_name/assets/minecraft/textures// + + Returns: + - Path if successful + - MCprepError if error occurs (may return with a message) """ - if not resource_folder: + if resource_folder is None: # default to internal pack - resource_folder = bpy.path.abspath(bpy.context.scene.mcprep_texturepack_path) + resource_folder = Path(cast( + str, + bpy.path.abspath(bpy.context.scene.mcprep_texturepack_path) + )) - if not os.path.isdir(resource_folder): + if not resource_folder.exists() or not resource_folder.is_dir(): env.log("Error, resource folder does not exist") - return + line, file = env.current_line_and_file() + return MCprepError(FileNotFoundError(), line, file, f"Resource pack folder at {resource_folder} does not exist!") # Check multiple paths, picking the first match (order is important), # goal of picking out the /textures folder. check_dirs = [ - os.path.join(resource_folder, "textures"), - os.path.join(resource_folder, "minecraft", "textures"), - os.path.join(resource_folder, "assets", "minecraft", "textures")] + Path(resource_folder, "textures"), + Path(resource_folder, "minecraft", "textures"), + Path(resource_folder, "assets", "minecraft", "textures")] for path in check_dirs: - if os.path.isdir(path): + if path.exists(): resource_folder = path break search_paths = [ resource_folder, # Both singular and plural shown below as it has varied historically. - os.path.join(resource_folder, "blocks"), - os.path.join(resource_folder, "block"), - os.path.join(resource_folder, "items"), - os.path.join(resource_folder, "item"), - os.path.join(resource_folder, "entity"), - os.path.join(resource_folder, "models"), - os.path.join(resource_folder, "model"), + Path(resource_folder, "blocks"), + Path(resource_folder, "block"), + Path(resource_folder, "items"), + Path(resource_folder, "item"), + Path(resource_folder, "entity"), + Path(resource_folder, "models"), + Path(resource_folder, "model"), ] res = None @@ -170,32 +178,36 @@ def find_from_texturepack(blockname: str, resource_folder: Optional[Path]=None) if "/" in blockname: newpath = blockname.replace("/", os.path.sep) for ext in extensions: - if os.path.isfile(os.path.join(resource_folder, newpath + ext)): - res = os.path.join(resource_folder, newpath + ext) + if Path(resource_folder, newpath + ext).exists(): + res = Path(resource_folder, newpath + ext) return res newpath = os.path.basename(blockname) # case where goes into other subpaths for ext in extensions: - if os.path.isfile(os.path.join(resource_folder, newpath + ext)): - res = os.path.join(resource_folder, newpath + ext) + if Path(resource_folder, newpath + ext).exists(): + res = Path(resource_folder, newpath + ext) return res # fallback (more common case), wide-search for for path in search_paths: - if not os.path.isdir(path): + if not path.is_dir(): continue for ext in extensions: - check_path = os.path.join(path, blockname + ext) - if os.path.isfile(check_path): - res = os.path.join(path, blockname + ext) + check_path = Path(path, blockname + ext) + if check_path.exists() and check_path.is_file(): + res = Path(path, blockname + ext) return res + # Mineways fallback for suffix in ["-Alpha", "-RGB", "-RGBA"]: if blockname.endswith(suffix): - res = os.path.join( + res = Path( resource_folder, "mineways_assets", f"mineways{suffix}.png") - if os.path.isfile(res): + if res.exists() and res.is_file(): return res + if res is None: + line, file = env.current_line_and_file() + return MCprepError(FileNotFoundError(), line, file) return res @@ -204,6 +216,13 @@ def detect_form(materials: List[Material]) -> Optional[Form]: Useful for pre-determining elibibility of a function and also for tracking reporting to give sense of how common which exporter is used. + + materials: List[Material]: + List of materials to check from + + Returns: + - Form if detected + - None if not detected """ jmc2obj = 0 mc = 0 @@ -344,10 +363,12 @@ def set_texture_pack( """ mc_name, _ = get_mc_canonical_name(material.name) image = find_from_texturepack(mc_name, folder) - if image is None: + if isinstance(image, MCprepError): + if image.msg: + env.log(image.msg) return 0 - image_data = util.loadTexture(image) + image_data = util.loadTexture(str(image)) _ = set_cycles_texture( image_data, material, extra_passes=use_extra_passes) return 1 @@ -392,7 +413,7 @@ def set_cycles_texture( # check if there is more data to see pass types img_sets = {} if extra_passes: - img_sets = find_additional_passes(image.filepath) + img_sets = find_additional_passes(Path(image.filepath)) changed = False is_grayscale = False @@ -562,7 +583,8 @@ def get_textures(material: Material) -> Dict[str, Image]: 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) + print("What is this?", image_file) + abs_img_file = bpy.path.abspath(str(image_file)) # needs to be blend file relative env.log(f"\tFind additional passes for: {image_file}", vv_only=True) if not os.path.isfile(abs_img_file): return {} @@ -637,9 +659,11 @@ def replace_missing_texture(image: Image) -> bool: canon, _ = get_mc_canonical_name(name) # TODO: detect for pass structure like normal and still look for right pass image_path = find_from_texturepack(canon) - if not image_path: + if isinstance(image_path, MCprepError): + if image_path.msg: + env.log(image_path.msg) return False - image.filepath = image_path + image.filepath = str(image_path) # image.reload() # not needed? # pack? diff --git a/MCprep_addon/materials/material_manager.py b/MCprep_addon/materials/material_manager.py index e81e5db8..0b96ee69 100644 --- a/MCprep_addon/materials/material_manager.py +++ b/MCprep_addon/materials/material_manager.py @@ -26,7 +26,7 @@ from .. import tracking from .. import util -from ..conf import env +from ..conf import MCprepError, env # ----------------------------------------------------------------------------- @@ -431,6 +431,8 @@ def execute(self, context): self.report({'INFO'}, f"Updated {count} materials") self.track_param = context.scene.render.engine + + # NOTE: This is temporary addon_prefs = util.get_user_preferences(context) self.track_exporter = addon_prefs.MCprep_exporter_type return {'FINISHED'} @@ -440,14 +442,17 @@ def load_from_texturepack(self, mat): 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): - env.log(f"Find missing images: No source file found for {mat.name}") + if isinstance(image_path, MCprepError): + if image_path.msg: + env.log(image_path.msg) + else: + 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 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) + image = bpy.data.images.load(str(image_path), check_existing=True) engine = bpy.context.scene.render.engine if engine == 'CYCLES' or engine == 'BLENDER_EEVEE' or engine == 'BLENDER_EEVEE_NEXT': diff --git a/MCprep_addon/materials/prep.py b/MCprep_addon/materials/prep.py index 1a4549c2..7fb48522 100644 --- a/MCprep_addon/materials/prep.py +++ b/MCprep_addon/materials/prep.py @@ -18,6 +18,7 @@ import os +from pathlib import Path import bpy from bpy_extras.io_utils import ImportHelper @@ -28,6 +29,7 @@ from . import uv_tools from .. import tracking from .. import util +from .. import world_tools from ..conf import env # ----------------------------------------------------------------------------- @@ -313,8 +315,10 @@ def execute(self, context): "Nothing modified, be sure you selected objects with existing materials!" ) - addon_prefs = util.get_user_preferences(context) self.track_param = context.scene.render.engine + + # NOTE: This is temporary + addon_prefs = util.get_user_preferences(context) self.track_exporter = addon_prefs.MCprep_exporter_type return {'FINISHED'} @@ -419,6 +423,9 @@ class MCPREP_OT_swap_texture_pack( @classmethod def poll(cls, context): + if world_tools.get_exporter(context) != None: + return util.is_atlas_export(context) + # Fallback to legacy addon_prefs = util.get_user_preferences(context) if addon_prefs.MCprep_exporter_type != "(choose)": return util.is_atlas_export(context) @@ -479,6 +486,8 @@ def execute(self, context): _ = generate.detect_form(mat_list) invalid_uv, affected_objs = uv_tools.detect_invalid_uvs_from_objs(obj_list) + # NOTE: This is temporary + addon_prefs = util.get_user_preferences(context) self.track_exporter = addon_prefs.MCprep_exporter_type # set the scene's folder for the texturepack being swapped @@ -488,7 +497,7 @@ def execute(self, context): res = 0 for mat in mat_list: self.preprocess_material(mat) - res += generate.set_texture_pack(mat, folder, self.useExtraMaps) + res += generate.set_texture_pack(mat, Path(folder), self.useExtraMaps) if self.animateTextures: sequences.animate_single_material( mat, diff --git a/MCprep_addon/materials/sequences.py b/MCprep_addon/materials/sequences.py index f076c2c8..4a0f9203 100644 --- a/MCprep_addon/materials/sequences.py +++ b/MCprep_addon/materials/sequences.py @@ -32,7 +32,7 @@ from . import uv_tools from .. import tracking from .. import util -from ..conf import env, Engine, Form +from ..conf import MCprepError, env, Engine, Form class ExportLocation(enum.Enum): @@ -72,8 +72,12 @@ 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: - env.log(f"Canon path not found for {mat_gen}:{canon}, form {form}, path: {image_path_canon}", vv_only=True) + + if isinstance(image_path_canon, MCprepError): + if image_path_canon.msg: + env.log(image_path_canon.msg) + else: + env.log(f"Error occured during texturepack search for {mat_gen}:{canon}, form {form}") return affectable, False, None if not os.path.isfile(f"{image_path_canon}.mcmeta"): @@ -215,7 +219,7 @@ def generate_material_sequence(source_path: Path, image_path: Path, form: Option "try running blender as admin") for img_pass in img_pass_dict: - passfile = img_pass_dict[img_pass] + passfile = str(img_pass_dict[img_pass]) # Convert from Path env.log("Running on file:") env.log(bpy.path.abspath(passfile)) diff --git a/MCprep_addon/mcprep_ui.py b/MCprep_addon/mcprep_ui.py index 7639768d..0789ee5e 100644 --- a/MCprep_addon/mcprep_ui.py +++ b/MCprep_addon/mcprep_ui.py @@ -753,13 +753,14 @@ def draw(self, context): row = col.row(align=True) row.prop(addon_prefs, "MCprep_exporter_type", expand=True) row = col.row(align=True) - if addon_prefs.MCprep_exporter_type == "(choose)": + exporter = world_tools.get_exporter(context) + if exporter is None: row.operator( "mcprep.open_jmc2obj", text=env._("Select exporter!"), icon='ERROR') row.enabled = False - elif addon_prefs.MCprep_exporter_type == "Mineways": + elif exporter is world_tools.WorldExporter.Mineways or exporter is world_tools.WorldExporter.ClassicMW: row.operator("mcprep.open_mineways") - else: + elif exporter is world_tools.WorldExporter.Jmc2OBJ or exporter is world_tools.WorldExporter.ClassicJmc: row.operator("mcprep.open_jmc2obj") wpath = addon_prefs.world_obj_path @@ -782,7 +783,8 @@ def draw(self, context): p.filepath = context.scene.mcprep_texturepack_path if context.mode == "OBJECT": col.operator("mcprep.meshswap", text=env._("Mesh Swap")) - if addon_prefs.MCprep_exporter_type == "(choose)": + exporter = world_tools.get_exporter(context) + if exporter is None or exporter is world_tools.WorldExporter.Unknown: col.label(text=env._("Select exporter!"), icon='ERROR') if context.mode == 'EDIT_MESH': col.operator("mcprep.scale_uv") @@ -1063,7 +1065,6 @@ def draw(self, context): return row = layout.row() - # row.operator("mcprep.create_default_material") split = layout.split() col = split.column(align=True) @@ -1499,9 +1500,10 @@ def model_spawner(self, context: Context) -> None: 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": + exporter = world_tools.get_exporter(context) + if exporter is world_tools.WorldExporter.Mineways or exporter is world_tools.WorldExporter.ClassicMW: ops.snapping = "offset" - elif addon_prefs.MCprep_exporter_type == "jmc2obj": + elif exporter is world_tools.WorldExporter.Jmc2OBJ or exporter is world_tools.WorldExporter.ClassicJmc: ops.snapping = "center" else: box = col.box() @@ -1521,9 +1523,10 @@ def model_spawner(self, context: Context) -> None: ops = col.operator("mcprep.import_model_file") ops.location = util.get_cursor_location(context) - if addon_prefs.MCprep_exporter_type == "Mineways": + exporter = world_tools.get_exporter(context) + if exporter is world_tools.WorldExporter.Mineways or exporter is world_tools.WorldExporter.ClassicMW: ops.snapping = "center" - elif addon_prefs.MCprep_exporter_type == "jmc2obj": + elif exporter is world_tools.WorldExporter.Jmc2OBJ or exporter is world_tools.WorldExporter.ClassicJmc: ops.snapping = "offset" split = layout.split() diff --git a/MCprep_addon/spawner/meshswap.py b/MCprep_addon/spawner/meshswap.py index ea865051..8a245fb5 100644 --- a/MCprep_addon/spawner/meshswap.py +++ b/MCprep_addon/spawner/meshswap.py @@ -20,6 +20,7 @@ from dataclasses import dataclass from typing import Dict, List, Union, Tuple import math +from MCprep_addon import world_tools import mathutils import os import random @@ -465,8 +466,7 @@ class MCPREP_OT_meshswap(bpy.types.Operator): @classmethod def poll(cls, context): - addon_prefs = util.get_user_preferences(context) - return addon_prefs.MCprep_exporter_type != "(choose)" and context.mode == 'OBJECT' + return world_tools.get_exporter(context) != world_tools.WorldExporter.Unknown and context.mode == 'OBJECT' def invoke(self, context, event): return context.window_manager.invoke_props_dialog( @@ -517,6 +517,8 @@ def draw(self, context): @tracking.report_error def execute(self, context): tprep = time.time() + + # NOTE: This is temporary addon_prefs = util.get_user_preferences(context) self.track_exporter = addon_prefs.MCprep_exporter_type diff --git a/MCprep_addon/spawner/spawn_util.py b/MCprep_addon/spawner/spawn_util.py index a8031347..ae1ce8d0 100644 --- a/MCprep_addon/spawner/spawn_util.py +++ b/MCprep_addon/spawner/spawn_util.py @@ -24,7 +24,7 @@ import bpy from bpy.types import Context, Collection, BlendDataLibraries -from ..conf import env +from ..conf import MCprepError, env from .. import util from .. import tracking from . import mobs @@ -372,6 +372,7 @@ def load_linked(self, context: Context, path: str, name: str) -> None: path = bpy.path.abspath(path) act = None + res = None if hasattr(bpy.data, "groups"): res = util.bAppendLink(f"{path}/Group", name, True) act = context.object # assumption of object after linking, 2.7 only @@ -382,10 +383,10 @@ def load_linked(self, context: Context, path: str, name: str) -> None: else: print("Error: Should have had at least one object selected.") - if res is False: + if isinstance(res, MCprepError): # Most likely scenario, path was wrong and raised "not a library". # end and automatically reload assets. - self.report({'WARNING'}, "Failed to load asset file") + self.report({'WARNING'}, res.msg) bpy.ops.mcprep.prompt_reset_spawners('INVOKE_DEFAULT') return diff --git a/MCprep_addon/util.py b/MCprep_addon/util.py index 85921ce5..207bbf00 100644 --- a/MCprep_addon/util.py +++ b/MCprep_addon/util.py @@ -26,6 +26,7 @@ import random import re import subprocess +from MCprep_addon.commonmcobj_parser import CommonMCOBJTextureType import bpy from bpy.types import ( @@ -48,6 +49,18 @@ # GENERAL SUPPORTING FUNCTIONS (no registration required) # ----------------------------------------------------------------------------- +def update_matrices(obj): + """Update mattrices of object so that we can accurately parent, + because for some stupid reason, Blender doesn't do this by default""" + if obj.parent is None: + obj.matrix_world = obj.matrix_basis + + else: + obj.matrix_world = obj.parent.matrix_world * \ + obj.matrix_parent_inverse * \ + obj.matrix_basis + + def apply_noncolor_data(node: Node) -> Optional[MCprepError]: """ Apply the Non-Color/Generic Data option to the passed @@ -134,19 +147,27 @@ def materialsFromObj(obj_list: List[bpy.types.Object]) -> List[Material]: return mat_list -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 +def bAppendLink(directory: str, name: str, toLink: bool, active_layer: bool=True) -> Optional[MCprepError]: + """ + This function calls the append and link methods in an + easy and safe manner. Note that for 2.8 compatibility, the directory passed in should already be correctly identified (eg Group or Collection) Arguments: - directory: xyz.blend/Type, where Type is: Collection, Group, Material... - name: asset name + directory: str + xyz.blend/Type, where Type is: Collection, Group, Material... + name: str + Asset name toLink: bool + If true, link instead of append + active_layer: bool=True + Deprecated in MCprep 3.6 as it relates to pre-2.8 layers - Returns: true if successful, false if not. + Returns: + - None if successful + - MCprepError with message if the asset could not be appended or linked """ env.log(f"Appending {directory} : {name}", vv_only=True) @@ -155,17 +176,7 @@ def bAppendLink(directory: str, name: str, toLink: bool, active_layer: bool=True if directory[-1] != "/" and directory[-1] != os.path.sep: directory += os.path.sep - if "link_append" in dir(bpy.ops.wm): - # OLD method of importing, e.g. in blender 2.70 - 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 - except RuntimeError as e: - print("bAppendLink", e) - return False - elif "link" in dir(bpy.ops.wm) and "append" in dir(bpy.ops.wm): - env.log("Using post-2.72 method of append/link", vv_only=True) + if "link" in dir(bpy.ops.wm) and "append" in dir(bpy.ops.wm): if toLink: bpy.ops.wm.link(directory=directory, filename=name) else: @@ -173,10 +184,11 @@ def bAppendLink(directory: str, name: str, toLink: bool, active_layer: bool=True bpy.ops.wm.append( directory=directory, filename=name) - return True + return None except RuntimeError as e: print("bAppendLink", e) - return False + line, file = env.current_line_and_file() + return MCprepError(e, line, file, f"Could not append {name}!") def obj_copy( @@ -227,12 +239,6 @@ def min_bv(version: Tuple, *, inclusive: bool = True) -> bool: return bpy.app.version >= version -def bv28() -> bool: - """Check if blender 2.8, for layouts, UI, and properties. """ - env.deprecation_warning() - return min_bv((2, 80)) - - def bv30() -> bool: """Check if we're dealing with Blender 3.0""" return min_bv((3, 00)) @@ -262,6 +268,12 @@ def is_atlas_export(context: Context) -> bool: file_types["ATLAS"] += 1 else: file_types["INDIVIDUAL"] += 1 + elif "COMMONMCOBJ_HEADER" in obj and obj["PARENTED_EMPTY"] is not None: + tex = CommonMCOBJTextureType[obj["PARENTED_EMPTY"]["texture_type"]] + if tex is CommonMCOBJTextureType.ATLAS: + file_types["ATLAS"] += 1 + elif tex is CommonMCOBJTextureType.INDIVIDUAL_TILES: + file_types["INDIVIDUAL"] += 1 else: continue @@ -339,19 +351,33 @@ def link_selected_objects_to_scene() -> None: if ob not in list(bpy.context.scene.objects): obj_link_scene(ob) +def open_program(executable: str) -> Optional[MCprepError]: + """ + Runs an executable such as Mineways or jmc2OBJ, taking into account the + user's operating system (using Wine if Mineways is to be launched on + a non-Windows OS such as macOS or Linux) and automatically checks if + the program exists or has the right permissions to be executed. -def open_program(executable: str) -> Union[int, str]: + Returns: + - None if the program is found and ran successfully + - MCprepError in all error cases (may have error message) + """ # Open an external program from filepath/executbale executable = bpy.path.abspath(executable) env.log(f"Open program request: {executable}") + # Doesn't matter where the exact error occurs + # in this function, since they're all going to + # be crazy hard to decipher + line, file = env.current_line_and_file() + # input could be .app file, which appears as if a folder if not os.path.isfile(executable): env.log("File not executable") if not os.path.isdir(executable): - return -1 + return MCprepError(FileNotFoundError(), line, file) elif not executable.lower().endswith(".app"): - return -1 + return MCprepError(FileNotFoundError(), line, file) # try to open with wine, if available osx_or_linux = platform.system() == "Darwin" @@ -373,13 +399,13 @@ def open_program(executable: str) -> Union[int, str]: # for line in iter(p.stdout.readline, ''): # # will print lines as they come, instead of just at end # print(stdout) - return 0 + return None try: # attempt to use blender's built-in method res = bpy.ops.wm.path_open(filepath=executable) if res == {"FINISHED"}: env.log("Opened using built in path opener") - return 0 + return None else: env.log("Did not get finished response: ", str(res)) except: @@ -393,8 +419,8 @@ def open_program(executable: str) -> Union[int, str]: p = Popen(['open', executable], stdin=PIPE, stdout=PIPE, stderr=PIPE) stdout, err = p.communicate(b"") if err != b"": - return f"Error occured while trying to open executable: {err}" - return "Failed to open executable" + return MCprepError(RuntimeError(), line, file, f"Error occured while trying to open executable: {err!r}") + return MCprepError(RuntimeError(), line, file, "Failed to open executable") def open_folder_crossplatform(folder: str) -> bool: diff --git a/MCprep_addon/world_tools.py b/MCprep_addon/world_tools.py index 1add9a5a..83c871f3 100644 --- a/MCprep_addon/world_tools.py +++ b/MCprep_addon/world_tools.py @@ -16,17 +16,21 @@ # # ##### END GPL LICENSE BLOCK ##### +import enum +from dataclasses import fields +from enum import Enum, auto import os import math from pathlib import Path -from typing import List, Optional +from typing import List, Optional, Union import shutil +from MCprep_addon.commonmcobj_parser import CommonMCOBJ, CommonMCOBJTextureType, parse_header import bpy from bpy.types import Context, Camera from bpy_extras.io_utils import ExportHelper, ImportHelper -from .conf import env, VectorType +from .conf import MCprepError, env, VectorType from . import util from . import tracking from .materials import generate @@ -36,18 +40,10 @@ # supporting functions # ----------------------------------------------------------------------------- -BUILTIN_SPACES = ( - "Standard", - "Filmic", - "Filmic Log", - "Raw", - "False Color" -) - +BUILTIN_SPACES = ('Standard', 'Khronos PBR Neutral', 'AgX', 'Filmic', 'Filmic Log', 'False Color', 'Raw') time_obj_cache = None - def get_time_object() -> None: """Returns the time object if present in the file""" global time_obj_cache # to avoid re parsing every time @@ -79,9 +75,12 @@ class ObjHeaderOptions: """Wrapper functions to avoid typos causing issues.""" def __init__(self): - self._exporter: Optional[str] = None - self._file_type: Optional[str] = None - + # This assumes all OBJs that aren't from Mineways + # and don't have a CommonMCOBJ header are from + # jmc2obj, and use individual tiles for textures + self._exporter: Optional[str] = "jmc2obj" + self._file_type: Optional[str] = "INDIVIDUAL_TILES" + """ Wrapper functions to avoid typos causing issues """ @@ -110,16 +109,104 @@ def texture_type(self): return self._file_type if self._file_type is not None else "NONE" -obj_header = ObjHeaderOptions() +class WorldExporter(Enum): + """ + Defines all supported exporters + with a fallback + """ + + # Mineways with CommonMCOBJ + Mineways = auto() + + # Jmc2OBJ with CommonMCOBJ + Jmc2OBJ = auto() + + # Cmc2OBJ, the reference + # implementation of CommonMCOBJ + # + # For the most part, this + # will be treated as + # Unknown as it's not meant + # for regular use. The distinct + # option exists for testing purposes + Cmc2OBJ = auto() + + # Any untested exporter + Unknown = auto() + # Mineways before the CommonMCOBJ standard + ClassicMW = auto() -def detect_world_exporter(filepath: Path) -> None: + # jmc2OBJ before the CommonMCOBJ standard + ClassicJmc = auto() + + +EXPORTER_MAPPING = { + "mineways" : WorldExporter.Mineways, + "jmc2obj" : WorldExporter.Jmc2OBJ, + "cmc2obj" : WorldExporter.Cmc2OBJ, + "mineways-c" : WorldExporter.ClassicMW, + "jmc2obj-c" : WorldExporter.ClassicJmc +} + +UNSUPPORTED_OR_NONE = (WorldExporter.Unknown, None) + +def get_exporter(context: Context) -> Optional[WorldExporter]: + """ + Return the exporter on the active object if it has + an exporter attribute. + + For maximum backwards compatibility, it'll convert the + explicit options we have in MCprep for world exporters to + WorldExporter enum objects, if the object does not have either + the CommonMCOBJ exporter attribute, or if it does not have the + MCPREP_OBJ_EXPORTER attribute added in MCprep 3.6. This backwards + compatibility will be removed by default in MCprep 4.0 + + Returns: + - WorldExporter if the world exporter can be detected + - None otherwise + """ + obj = context.active_object + if not obj: + return None + + if "COMMONMCOBJ_HEADER" in obj: + if obj["PARENTED_EMPTY"] is not None and obj["PARENTED_EMPTY"]["exporter"] in EXPORTER_MAPPING: + return EXPORTER_MAPPING[obj["PARENTED_EMPTY"]["exporter"]] + else: + return WorldExporter.Unknown + elif "MCPREP_OBJ_HEADER" in obj: + if "MCPREP_OBJ_EXPORTER" in obj: + return EXPORTER_MAPPING[obj["MCPREP_OBJ_EXPORTER"]] + + # This section will be placed behind a legacy + # option in MCprep 4.0, once CommonMCOBJ becomes + # more adopted in exporters + prefs = util.get_user_preferences(context) + if prefs.MCprep_exporter_type == "Mineways": + return WorldExporter.ClassicMW + elif prefs.MCprep_exporter_type == "jmc2obj": + return WorldExporter.ClassicJmc + return None + + +def detect_world_exporter(filepath: Path) -> Union[CommonMCOBJ, ObjHeaderOptions]: """Detect whether Mineways or jmc2obj was used, based on prefix info. Primary heruistic: if detect Mineways header, assert Mineways, else assume jmc2obj. All Mineways exports for a long time have prefix info set in the obj file as comments. """ + obj_header = ObjHeaderOptions() + + # First parse header for commonmcobj + with open(filepath, 'r') as obj_fd: + cmc_header = parse_header(obj_fd) + if cmc_header is not None: + return cmc_header + + # If not found, fall back to recognizing the mineway legacy convention with open(filepath, 'r') as obj_fd: try: header = obj_fd.readline() @@ -142,22 +229,17 @@ def detect_world_exporter(filepath: Path) -> None: "# File type: Export tiles for textures to directory textures", "# File type: Export individual textures to directory tex" ) - print('"{}"'.format(header)) if header in atlas: # If a texture atlas is used obj_header.set_atlas() elif header in tiles: # If the OBJ uses individual textures obj_header.set_seperated() - return + return obj_header except UnicodeDecodeError: print(f"Failed to read first line of obj: {filepath}") - return - obj_header.set_jmc2obj() - # Since this is the default for Jmc2Obj, - # we'll assume this is what the OBJ is using - obj_header.set_seperated() + return obj_header -def convert_mtl(filepath): +def convert_mtl(filepath) -> Union[bool, MCprepError]: """Convert the MTL file if we're not using one of Blender's built in colorspaces @@ -170,8 +252,15 @@ def convert_mtl(filepath): - Add a header at the end Returns: - True if success or skipped, False if failed, or None if skipped + - True if the file was converted + - False if conversion was skipped or it was already converted before + - MCprepError if failed (may return with message) """ + + # Perform this early to get it out of the way + if bpy.context.scene.view_settings.view_transform in BUILTIN_SPACES: + return False + # Check if the MTL exists. If not, then check if it # uses underscores. If still not, then return False mtl = Path(filepath.rsplit(".", 1)[0] + '.mtl') @@ -180,7 +269,8 @@ def convert_mtl(filepath): if mtl_underscores.exists(): mtl = mtl_underscores else: - return False + line, file = env.current_line_and_file() + return MCprepError(FileNotFoundError(), line, file) lines = None copied_file = None @@ -190,12 +280,12 @@ def convert_mtl(filepath): lines = mtl_file.readlines() except Exception as e: print(e) + line, file = env.current_line_and_file() + return MCprepError(e, line, file, "Could not read file!") + + # This checks to see if none of the lines have map_d. If so then skip + if not any("map_d" in s for s in lines): return False - - # This checks to see if the user is using a built-in colorspace or if none of the lines have map_d. If so - # then ignore this file and return None - if bpy.context.scene.view_settings.view_transform in BUILTIN_SPACES or not any("map_d" in s for s in lines): - return None # This represents a new folder that'll backup the MTL filepath original_mtl_path = Path(filepath).parent.absolute() / "ORIGINAL_MTLS" @@ -215,10 +305,11 @@ def convert_mtl(filepath): print("Header " + str(header)) copied_file = shutil.copy2(mtl, original_mtl_path.absolute()) else: - return True + return False except Exception as e: print(e) - return False + line, file = env.current_line_and_file() + return MCprepError(e, line, file) # In this section, we go over each line # and check to see if it begins with map_d. If @@ -231,7 +322,8 @@ def convert_mtl(filepath): lines[index] = "# " + line except Exception as e: print(e) - return False + line, file = env.current_line_and_file() + return MCprepError(e, line, file, "Could not read file!") # This needs to be seperate since it involves writing try: @@ -243,20 +335,35 @@ def convert_mtl(filepath): except Exception as e: print(e) shutil.copy2(copied_file, mtl) - return False + line, file = env.current_line_and_file() + return MCprepError(e, line, file) return True +class OBJImportCode(enum.Enum): + """ + This represents the state of the + OBJ import addon in pre-4.0 versions + of Blender + """ + ALREADY_ENABLED = 0 + DISABLED = 1 -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. +def enable_obj_importer() -> Union[OBJImportCode, MCprepError]: + """ + Checks if the obj import addon (pre-Blender 4.0) is enabled, + and enable it if it isn't enabled. + + Returns: + - OBJImportCode.ALREADY_ENABLED if either enabled already or + the user is using Blender 4.0. + - OBJImportCode.DISABLED if the addon had to be enabled. + - MCprepError with a message if the addon could not be enabled. """ enable_addon = None if util.min_bv((4, 0)): - return None # No longer an addon, native built in. + return OBJImportCode.ALREADY_ENABLED # No longer an addon, native built in. else: in_import_scn = "obj_import" not in dir(bpy.ops.wm) in_wm = "" @@ -264,13 +371,14 @@ def enble_obj_importer() -> Optional[bool]: enable_addon = "io_scene_obj" if enable_addon is None: - return None + return OBJImportCode.ALREADY_ENABLED try: bpy.ops.preferences.addon_enable(module=enable_addon) - return True + return OBJImportCode.DISABLED except RuntimeError: - return False + line, file = env.current_line_and_file() + return MCprepError(Exception(), line, file, "Could not enable the Built-in OBJ importer!") # ----------------------------------------------------------------------------- @@ -295,13 +403,14 @@ class MCPREP_OT_open_jmc2obj(bpy.types.Operator): def execute(self, context): addon_prefs = util.get_user_preferences(context) res = util.open_program(addon_prefs.open_jmc2obj_path) - - if res == -1: - bpy.ops.mcprep.install_jmc2obj('INVOKE_DEFAULT') - return {'CANCELLED'} - elif res != 0: - self.report({'ERROR'}, str(res)) - return {'CANCELLED'} + + if isinstance(res, MCprepError): + if isinstance(res.err_type, FileNotFoundError): + bpy.ops.mcprep.install_jmc2obj('INVOKE_DEFAULT') + return {'CANCELLED'} + else: + self.report({'ERROR'}, res.msg) + return {'CANCELLED'} else: self.report({'INFO'}, "jmc2obj should open soon") return {'FINISHED'} @@ -375,14 +484,16 @@ def execute(self, context): if os.path.isfile(addon_prefs.open_mineways_path): res = util.open_program(addon_prefs.open_mineways_path) else: - res = -1 - - if res == -1: - bpy.ops.mcprep.install_mineways('INVOKE_DEFAULT') - return {'CANCELLED'} - elif res != 0: - self.report({'ERROR'}, str(res)) - return {'CANCELLED'} + # Doesn't matter here, it's a dummy value + res = MCprepError(FileNotFoundError(), -1, "") + + if isinstance(res, MCprepError): + if isinstance(res.err_type, FileNotFoundError): + bpy.ops.mcprep.install_mineways('INVOKE_DEFAULT') + return {'CANCELLED'} + else: + self.report({'ERROR'}, res.msg) + return {'CANCELLED'} else: self.report({'INFO'}, "Mineways should open soon") return {'FINISHED'} @@ -465,24 +576,24 @@ def execute(self, context): # Auto change from MTL to OBJ, latet if's will check if existing. self.filepath = str(new_filename) if not self.filepath: - self.report({"ERROR"}, "File not found, could not import obj") + self.report({"ERROR"}, f"File not found, could not import obj \'{self.filepath}\'") return {'CANCELLED'} if not os.path.isfile(self.filepath): - self.report({"ERROR"}, "File not found, could not import obj") + self.report({"ERROR"}, f"File not found, could not import obj \'{self.filepath}\'") return {'CANCELLED'} if not self.filepath.lower().endswith(".obj"): self.report({"ERROR"}, "You must select a .obj file to import") return {'CANCELLED'} - res = enble_obj_importer() - if res is None: + res = enable_obj_importer() + if res is OBJImportCode.ALREADY_ENABLED: pass - elif res is True: + elif res is OBJImportCode.DISABLED: 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") + elif isinstance(res, MCprepError): + self.report({"ERROR"}, res.msg) return {'CANCELLED'} # There are a number of bug reports that come from the generic call @@ -503,10 +614,13 @@ def execute(self, context): # First let's convert the MTL if needed conv_res = convert_mtl(self.filepath) try: - if conv_res is None: - pass # skipped, no issue anyways. - elif conv_res is False: - self.report({"WARNING"}, "MTL conversion failed!") + if isinstance(conv_res, MCprepError): + if isinstance(conv_res.err_type, FileNotFoundError): + self.report({"WARNING"}, "MTL not found!") + elif conv_res.msg is not None: + self.report({"WARNING"}, conv_res.msg) + else: + self.report({"WARNING"}, conv_res.err_type) res = None if util.min_bv((3, 5)): @@ -591,17 +705,93 @@ def execute(self, context): return {'CANCELLED'} prefs = util.get_user_preferences(context) - detect_world_exporter(self.filepath) - prefs.MCprep_exporter_type = obj_header.exporter() - - for obj in context.selected_objects: - obj["MCPREP_OBJ_HEADER"] = True - obj["MCPREP_OBJ_FILE_TYPE"] = obj_header.texture_type() + header = detect_world_exporter(Path(self.filepath)) + + if isinstance(header, ObjHeaderOptions): + prefs.MCprep_exporter_type = header.exporter() + + # Create empty at the center of the OBJ + empty = None + if isinstance(header, CommonMCOBJ): + # Get actual 3D space coordinates of the full bounding box + # + # These are in Minecraft coordinates, so they translate + # from (X, Y, Z) to (X, -Z, Y) + max_pair = (header.export_bounds_max[0] + header.export_offset[0], + (-header.export_bounds_max[2]) + (-header.export_offset[2]), + header.export_bounds_max[1] + header.export_offset[1]) + + min_pair = (header.export_bounds_min[0] + header.export_offset[0], + (-header.export_bounds_min[2]) + (-header.export_offset[2]), + header.export_bounds_min[1] + header.export_offset[1]) + + # Calculate the center of the bounding box + # + # We do this by taking the average of the given + # points, so: + # (x1 + x2) / 2 + # (y1 + y2) / 2 + # (z1 + z2) / 2 + # + # This will give us the midpoints of these + # coordinates, which in turn will correspond + # to the center of the bounding box + location = ( + (max_pair[0] + min_pair[0]) / 2, + (max_pair[1] + min_pair[1]) / 2, + (max_pair[2] + min_pair[2]) / 2) + empty = bpy.data.objects.new( + name=header.world_name + "_mcprep_empty", object_data=None) + empty.empty_display_size = 2 + empty.empty_display_type = 'PLAIN_AXES' + empty.location = location + empty.hide_viewport = True # Hide empty globally + util.update_matrices(empty) + for field in fields(header): + if getattr(header, field.name) is None: + continue + if field.type == CommonMCOBJTextureType: + empty[field.name] = getattr(header, field.name).value + else: + empty[field.name] = getattr(header, field.name) - self.split_world_by_material(context) + else: + empty = bpy.data.objects.new("mcprep_obj_empty", object_data=None) + empty.empty_display_size = 2 + empty.empty_display_type = 'PLAIN_AXES' + empty.hide_viewport = True # Hide empty globally addon_prefs = util.get_user_preferences(context) - self.track_exporter = addon_prefs.MCprep_exporter_type # Soft detect. + + for obj in context.selected_objects: + if isinstance(header, CommonMCOBJ): + obj["COMMONMCOBJ_HEADER"] = True + obj["PARENTED_EMPTY"] = empty + obj.parent = empty + obj.matrix_parent_inverse = empty.matrix_world.inverted() # don't transform object + self.track_exporter = header.exporter + + elif isinstance(header, ObjHeaderOptions): + obj["MCPREP_OBJ_HEADER"] = True + obj["MCPREP_OBJ_FILE_TYPE"] = header.texture_type() + + obj.parent = empty + obj.matrix_parent_inverse = empty.matrix_world.inverted() # don't transform object + + # Future-proofing for MCprep 4.0 when we + # put global exporter options behind a legacy + # option and by default use the object for + # getting the exporter + obj["MCPREP_OBJ_EXPORTER"] = "mineways-c" if header.exporter() == "Mineways" else "jmc2obj-c" + self.track_exporter = addon_prefs.MCprep_exporter_type # Soft detect. + + # One final assignment of the preferences, to avoid doing each loop + val = header.exporter if isinstance(header, CommonMCOBJ) else header.exporter() + addon_prefs.MCprep_exporter_type = "Mineways" if val.lower().startswith("mineways") else "jmc2obj" + + new_col = self.split_world_by_material(context) + new_col.objects.link(empty) # parent empty + return {'FINISHED'} def obj_name_to_material(self, obj): @@ -617,7 +807,7 @@ def obj_name_to_material(self, obj): return obj.name = util.nameGeneralize(mat.name) - def split_world_by_material(self, context: Context) -> None: + def split_world_by_material(self, context: Context) -> bpy.types.Collection: """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] @@ -637,6 +827,7 @@ def split_world_by_material(self, context: Context) -> None: # Force renames based on material, as default names are not useful. for obj in worldg.objects: self.obj_name_to_material(obj) + return worldg class MCPREP_OT_prep_world(bpy.types.Operator): @@ -662,10 +853,8 @@ def execute(self, context): self.prep_world_cycles(context) elif engine == 'BLENDER_EEVEE' or engine == 'BLENDER_EEVEE_NEXT': self.prep_world_eevee(context) - elif engine == 'BLENDER_RENDER' or engine == 'BLENDER_GAME': - self.prep_world_internal(context) else: - self.report({'ERROR'}, "Must be cycles, eevee, or blender internal") + self.report({'ERROR'}, "Must be Cycles or EEVEE") return {'FINISHED'} def prep_world_cycles(self, context: Context) -> None: @@ -756,41 +945,7 @@ def prep_world_eevee(self, context: Context) -> None: # Renders faster at a (minor?) cost of the image output # TODO: given the output change, consider make a bool toggle for this - bpy.context.scene.render.use_simplify = True - - def prep_world_internal(self, context): - # check for any suns with the sky setting on; - if not context.scene.world: - return - context.scene.world.use_nodes = False - context.scene.world.horizon_color = (0.00938029, 0.0125943, 0.0140572) - context.scene.world.light_settings.use_ambient_occlusion = True - context.scene.world.light_settings.ao_blend_type = 'MULTIPLY' - context.scene.world.light_settings.ao_factor = 0.1 - context.scene.world.light_settings.use_environment_light = True - context.scene.world.light_settings.environment_energy = 0.05 - context.scene.render.use_shadows = True - context.scene.render.use_raytrace = True - context.scene.render.use_textures = True - - # check for any sunlamps with sky setting - sky_used = False - for lamp in context.scene.objects: - if lamp.type not in ("LAMP", "LIGHT") or lamp.data.type != "SUN": - continue - if lamp.data.sky.use_sky: - sky_used = True - break - if sky_used: - 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: - 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) - + bpy.context.scene.render.use_simplify = True class MCPREP_OT_add_mc_sky(bpy.types.Operator): """Add sun lamp and time of day (dynamic) driver, setup sky with sun and moon""" @@ -802,7 +957,7 @@ def enum_options(self, context: Context) -> List[tuple]: """Dynamic set of enums to show based on engine""" engine = bpy.context.scene.render.engine enums = [] - if bpy.app.version >= (2, 77) and engine in ("CYCLES", "BLENDER_EEVEE", "BLENDER_EEVEE_NEXT"): + if engine in ("CYCLES", "BLENDER_EEVEE", "BLENDER_EEVEE_NEXT"): enums.append(( "world_shader", "Dynamic sky + shader sun/moon", @@ -896,17 +1051,7 @@ def execute(self, context): if self.world_type in ("world_static_mesh", "world_static_only"): # Create world dynamically (previous, simpler implementation) new_sun = self.create_sunlamp(context) - new_objs.append(new_sun) - - if engine in ('BLENDER_RENDER', 'BLENDER_GAME'): - world = context.scene.world - if not world: - world = bpy.data.worlds.new("MCprep World") - context.scene.world = world - new_sun.data.shadow_method = 'RAY_SHADOW' - new_sun.data.shadow_soft_size = 0.5 - world.use_sky_blend = False - world.horizon_color = (0.00938029, 0.0125943, 0.0140572) + new_objs.append(new_sun) bpy.ops.mcprep.world(skipUsage=True) # do rest of sky setup elif engine == 'CYCLES' or engine == 'BLENDER_EEVEE' or engine == 'BLENDER_EEVEE_NEXT': @@ -920,35 +1065,7 @@ def execute(self, context): if wname in bpy.data.worlds: prev_world = bpy.data.worlds[wname] prev_world.name = "-old" - new_objs += self.create_dynamic_world(context, blendfile, wname) - - elif engine == 'BLENDER_RENDER' or engine == 'BLENDER_GAME': - # dynamic world using built-in sun sky and atmosphere - new_sun = self.create_sunlamp(context) - new_objs.append(new_sun) - new_sun.data.shadow_method = 'RAY_SHADOW' - new_sun.data.shadow_soft_size = 0.5 - - world = context.scene.world - if not world: - world = bpy.data.worlds.new("MCprep World") - context.scene.world = world - world.use_sky_blend = False - world.horizon_color = (0.00938029, 0.0125943, 0.0140572) - - # be sure to turn off all other sun lamps with atmosphere set - new_sun.data.sky.use_sky = True # use sun orientation settings if BI - for lamp in context.scene.objects: - if lamp.type not in ("LAMP", "LIGHT") or lamp.data.type != "SUN": - continue - if lamp == new_sun: - continue - lamp.data.sky.use_sky = False - - time_obj = get_time_object() - if not time_obj: - env.log( - "TODO: implement create time_obj, parent sun to it & driver setup") + new_objs += self.create_dynamic_world(context, blendfile, wname) if self.world_type in ("world_static_mesh", "world_mesh"): if not os.path.isfile(blendfile): @@ -1017,10 +1134,7 @@ def execute(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") - else: # 2.8 - newlamp = bpy.data.lights.new("Sun", "SUN") + newlamp = bpy.data.lights.new("Sun", "SUN") obj = bpy.data.objects.new("Sunlamp", newlamp) obj.location = (0, 0, 20) obj.rotation_euler[0] = 0.481711 diff --git a/README.md b/README.md index 02cb3399..f3d88e79 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ Feature list CREDIT ====== -While this addon is released as open source software, the assets are being released as [Creative Commons Attributions, CC-BY](https://creativecommons.org/licenses/by/3.0/us/). If you use MeshSwap, **please credit the creators** by linking to this page wherever your project may appear: [http://github.com/TheDuckCow/MCprep](https://github.com/TheDuckCow/MCprep) +While this addon is released as open source software under the GNU GPL license, the assets are being released as [Creative Commons Attributions, CC-BY](https://creativecommons.org/licenses/by/3.0/us/). If you use MeshSwap, **please credit the creators** by linking to this page wherever your project may appear: [http://github.com/TheDuckCow/MCprep](https://github.com/TheDuckCow/MCprep). In addition, different parts of MCprep are under different GPL compatible licenses (see `LICENSE-3RD-PARTY.txt`). Meshswap Block models developed by [Patrick W. Crawford](https://twitter.com/TheDuckCow), [SilverC16](http://youtube.com/user/silverC16), and [Nils Söderman (rymdnisse)](http://youtube.com/rymdnisse). diff --git a/rc-files/patches/rc-bl_info.patch b/rc-files/patches/rc-bl_info.patch index a231c202..7ff9c318 100644 --- a/rc-files/patches/rc-bl_info.patch +++ b/rc-files/patches/rc-bl_info.patch @@ -7,10 +7,10 @@ index e727f3f..39c9fa7 100755 bl_info = { - "name": "MCprep", -+ "name": "MCprep 3.6 RC-2", ++ "name": "MCprep 3.6 RC-3", "category": "Object", - "version": (3, 5, 3), -+ "version": (3, 5, 3, 2), ++ "version": (3, 5, 3, 3), "blender": (2, 80, 0), "location": "3D window toolshelf > MCprep tab", "description": "Minecraft workflow addon for rendering and animation", diff --git a/test_files/materials_test.py b/test_files/materials_test.py index de154cea..8af37b25 100644 --- a/test_files/materials_test.py +++ b/test_files/materials_test.py @@ -695,17 +695,17 @@ def cleanup(): # 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": Path(tmp_dir) / "oak_log_top.png", + "specular": Path(tmp_dir) / "oak_log_top-s.png", + "normal": Path(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": Path(tmp_dir) / "oak_log.jpg", + "specular": Path(tmp_dir) / "oak_log_s.jpg", + "normal": Path(tmp_dir) / "oak_log_n.jpeg", + "displace": Path(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"), + "diffuse": Path(tmp_dir) / "stonecutter_saw.tiff", + "normal": Path(tmp_dir) / "stonecutter_saw n.tiff", } ] @@ -886,7 +886,7 @@ def test_swap_texture_pack(self): obj.active_material = new_mat self.assertIsNotNone(obj.active_material, "Material should be applied") - # Ensure if no texture pack selected, it fails. + # Ensure if no exporter type selected, it fails. addon_prefs = util.get_user_preferences(bpy.context) addon_prefs.MCprep_exporter_type = "(choose)" with self.assertRaises(RuntimeError): diff --git a/test_files/test_data/jmc2obj_test_1_21.mtl b/test_files/test_data/jmc2obj_test_1_21.mtl new file mode 100644 index 00000000..8c6ebc7d --- /dev/null +++ b/test_files/test_data/jmc2obj_test_1_21.mtl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7155824abda1552da03233e2320dc89b3f1776a62470ab2a515f489269f9ff53 +size 82370 diff --git a/test_files/test_data/jmc2obj_test_1_21.obj b/test_files/test_data/jmc2obj_test_1_21.obj new file mode 100644 index 00000000..d7961ecf --- /dev/null +++ b/test_files/test_data/jmc2obj_test_1_21.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5032b39a22db60ecd3346a40c93b2b1f5871566e49496f04cdd846a934adc681 +size 409323 diff --git a/test_files/test_data/mineways_test_combined_1_21.mtl b/test_files/test_data/mineways_test_combined_1_21.mtl new file mode 100644 index 00000000..c81ef376 --- /dev/null +++ b/test_files/test_data/mineways_test_combined_1_21.mtl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06ef918eb7f9a84e3f33e2fe5970d58ea19e3376462161e62c0c1a05f6c1ab42 +size 133438 diff --git a/test_files/test_data/mineways_test_combined_1_21.obj b/test_files/test_data/mineways_test_combined_1_21.obj new file mode 100644 index 00000000..33504499 --- /dev/null +++ b/test_files/test_data/mineways_test_combined_1_21.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3f0b89937501e34913009f4e9c656674cb33fadded977589253f813b1c910d8 +size 583757 diff --git a/test_files/test_data/mineways_test_separated_1_21.mtl b/test_files/test_data/mineways_test_separated_1_21.mtl new file mode 100644 index 00000000..44029d93 --- /dev/null +++ b/test_files/test_data/mineways_test_separated_1_21.mtl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bbe8a7093f0c7799b7919509316eb18146ad423c252852fcb0687dbfe56c08ac +size 147361 diff --git a/test_files/test_data/mineways_test_separated_1_21.obj b/test_files/test_data/mineways_test_separated_1_21.obj new file mode 100644 index 00000000..4c2e3b1e --- /dev/null +++ b/test_files/test_data/mineways_test_separated_1_21.obj @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0f4b8e1742ef87a28b105330990628c6a6bc30d095a0cb87dcf0d3bdc90a250 +size 445809 diff --git a/test_files/world_saves/Test MCprep 1.14.4.zip b/test_files/world_saves/Test MCprep 1.14.4.zip new file mode 100644 index 00000000..4ab192fa --- /dev/null +++ b/test_files/world_saves/Test MCprep 1.14.4.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c7aaa259299eefd6f97640f842a40a22c7655cb2207b49cae16686fcb15f90a6 +size 933854 diff --git a/test_files/world_saves/Test MCprep 1.15.2.zip b/test_files/world_saves/Test MCprep 1.15.2.zip new file mode 100644 index 00000000..6ef09189 --- /dev/null +++ b/test_files/world_saves/Test MCprep 1.15.2.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:09a96a83293e40d581d6b3fb28496b5f3f1d59487e7fdfe0128b285355bec1cb +size 1057610 diff --git a/test_files/world_saves/Test MCprep 1.21.zip b/test_files/world_saves/Test MCprep 1.21.zip new file mode 100644 index 00000000..44c71bd8 --- /dev/null +++ b/test_files/world_saves/Test MCprep 1.21.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:117c5856616b825e2f5b6c34d4f6cf79efc1014cc2e2e4395ca243f3132449f0 +size 2647829 diff --git a/test_files/world_tools_test.py b/test_files/world_tools_test.py index ff56cadc..10145af1 100644 --- a/test_files/world_tools_test.py +++ b/test_files/world_tools_test.py @@ -85,14 +85,16 @@ def _import_materials_util(self, mapping_set): # can't import conf separately. mcprep_data = util.env.json_data["blocks"][mapping_set] + generalized = [get_mc_canonical_name(mat.name)[0] for mat in bpy.data.materials] + # 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! + name for name in generalized + if name in mcprep_data] # ok! unmapped = [ - mat.name for mat in bpy.data.materials - if mat.name not in mcprep_data] # not ok + name for name in generalized + if name not in mcprep_data] # not ok fullset = mapped + unmapped # ie all materials unleveraged = [ mat for mat in mcprep_data @@ -132,8 +134,9 @@ def _import_materials_util(self, mapping_set): 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") + if len(mats_not_canon) > 40: # arbitrary threshold + self.fail(("Too many materials found without canonical name: " + f"{len(mats_not_canon)}")) # affirm the correct mappings mats_no_packimage = [ @@ -168,8 +171,7 @@ def test_enable_obj_importer(self): in_import_scn = "obj_import" in dir(bpy.ops.wm) self.assertTrue(in_import_scn, "obj_import operator not found") - - def test_world_import_jmc_full(self): + def test_world_import_legacy_jmc_full(self): test_subpath = os.path.join( "test_data", "jmc2obj_test_1_15_2.obj") self._import_world_with_settings(file=test_subpath) @@ -190,7 +192,29 @@ def test_world_import_jmc_full(self): with self.subTest("test_mappings"): self._import_materials_util("block_mapping_jmc") - def test_world_import_mineways_separated(self): + def test_world_import_cmcobj_jmc_full(self): + test_subpath = os.path.join( + "test_data", "jmc2obj_test_1_21.obj") + self._import_world_with_settings(file=test_subpath) + # TODO: Check that affirms it picks up the mcobj format. + 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_legacy_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) @@ -212,7 +236,29 @@ def test_world_import_mineways_separated(self): with self.subTest("test_mappings"): self._import_materials_util("block_mapping_mineways") - def test_world_import_mineways_combined(self): + def test_world_import_cmcobj_mineways_separated(self): + test_subpath = os.path.join( + "test_data", "mineways_test_separated_1_21.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_legacy_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) @@ -247,6 +293,41 @@ def test_world_import_mineways_combined(self): with self.subTest("test_mappings"): self._import_materials_util("block_mapping_mineways") + def test_world_import_cmcobj_mineways_combined(self): + test_subpath = os.path.join( + "test_data", "mineways_test_combined_1_21.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") @@ -310,27 +391,16 @@ def test_convert_mtl_simple(self): # 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( + self.assertTrue( 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. + "Should return false ie skipped conversion") res = filecmp.cmp(tmp_mtl, modified_mtl, shallow=False) + os.remove(tmp_mtl) # Cleanup first, in case assert fails 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.""" @@ -362,11 +432,10 @@ def test_convert_mtl_skip(self): # 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") + self.assertFalse(res, "Should not have converter MTL for valid space") if __name__ == '__main__':