новая структура проекта

This commit is contained in:
2026-04-20 21:04:25 +04:00
parent 1fe2a72ef1
commit 1a56b22e38
1932 changed files with 1886 additions and 22779 deletions
@@ -0,0 +1,82 @@
import os
import bpy
from pathlib import Path
from .cycles import update_wmo_mat_node_tree_cycles
class BlenderWMOObjectRenderFlags:
IsOutdoor = 0x1
IsIndoor = 0x2
NoLocalLight = 0x4
HasBatchB = 0x8
HasBatchA = 0x10
HasVertexColor = 0x20
HasBlendmap = 0x40
HasLightmap = 0x80
class BlenderWMOMaterialRenderFlags:
Unlit = 0x1
SIDN = 0x2
IsTwoLayered = 0x4
IsOpaque = 0x10
node_groups = [
'MO_ApplyLighting',
'MO_ApplyLightingTrans',
'MO_ApplyLightingGeneric',
'MO_Equal',
'MO_IsFlagInBitmask',
'MO_IsInBatchMap',
'MO_MaterialFlags',
'MO_MixBatches',
'MO_MixTextures',
'MO_ObjectFlags',
'MO_Properties',
'MO_ApplyVertexColor',
'MO_FixVertexColorAlpha',
'MO_SaturateUpper',
'MO_SetLighting',
'MO_ShaderMix',
'MO_WMOShader'
]
def load_wmo_shader_dependencies(reload_shader=False):
render_engine = bpy.context.scene.render.engine
# remove old node groups
if reload_shader:
for ng_name in node_groups:
if ng_name in bpy.data.node_groups:
bpy.data.node_groups.remove(bpy.data.node_groups[ng_name])
missing_nodes = [ng_name for ng_name in node_groups if ng_name not in bpy.data.node_groups]
if render_engine in ('CYCLES', 'BLENDER_EEVEE'):
lib_path = os.path.join(str(Path(__file__).parent), 'cycles', 'wotlk_default.blend')
else:
print('\nWARNING: Failed loading shader: materials may not display correctly.'
'\nIncompatible render engine \""{}"\"'.format(render_engine))
return
with bpy.data.libraries.load(lib_path) as (data_from, data_to):
data_to.node_groups = [node_group for node_group in data_from.node_groups if node_group in missing_nodes]
def update_wmo_mat_node_tree(bl_mat):
render_engine = bpy.context.scene.render.engine
if render_engine in ('CYCLES', 'BLENDER_EEVEE'):
update_wmo_mat_node_tree_cycles(bl_mat)
bpy.context.scene.wow_render_settings.sun_direction = bpy.context.scene.wow_render_settings.sun_direction
else:
print('\nWARNING: Failed generating node tree: material \"{}\" may not display correctly.'
'\nIncompatible render engine \""{}"\"'.format(bl_mat.name, render_engine))
# sync scene lighting properties
bpy.context.scene.wow_render_settings.ext_ambient_color = bpy.context.scene.wow_render_settings.ext_ambient_color
bpy.context.scene.wow_render_settings.ext_dir_color = bpy.context.scene.wow_render_settings.ext_dir_color
bpy.context.scene.wow_render_settings.sidn_scalar = bpy.context.scene.wow_render_settings.sidn_scalar
@@ -0,0 +1,54 @@
import bpy
from ....utils.node_builder import NodeTreeBuilder
def update_wmo_mat_node_tree_cycles(bl_mat):
bl_mat.use_nodes = True
tree = bl_mat.node_tree
links = tree.links
tree_builder = NodeTreeBuilder(tree)
# get textures
img_1 = bl_mat.wow_wmo_material.diff_texture_1 if bl_mat.wow_wmo_material.diff_texture_1 else None
img_2 = bl_mat.wow_wmo_material.diff_texture_2 if bl_mat.wow_wmo_material.diff_texture_2 else None
# create nodes
ng_wmo_shader = bpy.data.node_groups['MO_WMOShader']
blendmap = tree_builder.add_node('ShaderNodeAttribute', 'Blendmap', 0, 0)
blendmap.attribute_name = 'Blendmap'
uvmap = tree_builder.add_node('ShaderNodeUVMap', 'UVMap', 0, 1)
uvmap.uv_map = 'UVMap'
uvmap2 = tree_builder.add_node('ShaderNodeUVMap', 'UVMap', 0, 2)
uvmap2.uv_map = 'UVMap.001'
emissive_color = tree_builder.add_node('ShaderNodeRGB', 'EmissiveColor', 1, 0)
diffuse_tex1 = tree_builder.add_node('ShaderNodeTexImage', 'DiffuseTexture1', 1, 1)
if img_1:
diffuse_tex1.image = img_1
diffuse_tex2 = tree_builder.add_node('ShaderNodeTexImage', 'DiffuseTexture2', 1, 2)
if img_2:
diffuse_tex2.image = img_2
shader = tree_builder.add_node('ShaderNodeGroup', 'WMO Shader', 2, 1)
shader.node_tree = ng_wmo_shader
output = tree_builder.add_node('ShaderNodeOutputMaterial', 'Output', 3, 1)
# set node links
links.new(blendmap.outputs['Color'], shader.inputs['Blendmap'])
links.new(uvmap.outputs['UV'], diffuse_tex1.inputs['Vector'])
links.new(uvmap2.outputs['UV'], diffuse_tex2.inputs['Vector'])
links.new(emissive_color.outputs['Color'], shader.inputs['EmissiveColor'])
links.new(diffuse_tex1.outputs['Color'], shader.inputs['DiffuseTexture1'])
links.new(diffuse_tex1.outputs['Alpha'], shader.inputs['Alpha1'])
links.new(diffuse_tex2.outputs['Color'], shader.inputs['DiffuseTexture2'])
links.new(diffuse_tex2.outputs['Alpha'], shader.inputs['Alpha2'])
links.new(shader.outputs['Shader'], output.inputs['Surface'])
@@ -0,0 +1,60 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://ddsywjux1ci4m"
path="res://.godot/imported/wotlk_default.blend-8823381dcda7ad00ec8840b03f38568d.scn"
[deps]
source_file="res://reference/blender-wow-studio-3.4-1.1.0_Experimental/io_scene_wmo/wmo/bl_render/cycles/wotlk_default.blend"
dest_files=["res://.godot/imported/wotlk_default.blend-8823381dcda7ad00ec8840b03f38568d.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
blender/nodes/visible=0
blender/nodes/active_collection_only=false
blender/nodes/punctual_lights=true
blender/nodes/cameras=true
blender/nodes/custom_properties=true
blender/nodes/modifiers=1
blender/meshes/colors=false
blender/meshes/uvs=true
blender/meshes/normals=true
blender/meshes/export_geometry_nodes_instances=false
blender/meshes/gpu_instances=false
blender/meshes/tangents=true
blender/meshes/skins=2
blender/meshes/export_bones_deforming_mesh_only=false
blender/materials/unpack_enabled=true
blender/materials/export_materials=1
blender/animation/limit_playback=true
blender/animation/always_sample=true
blender/animation/group_tracks=true
gltf/naming_version=2
@@ -0,0 +1,48 @@
from ..pywowlib import WoWVersionManager
from ..pywowlib.wmo_file import WMOFile
from ..third_party.tqdm import tqdm
from .wmo_scene import BlenderWMOScene
from ..ui.preferences import get_project_preferences
import bpy
import time
from pathlib import Path
def export_wmo_from_blender_scene(filepath, client_version, export_selected, export_method):
""" Export WoW WMO object from Blender scene to files """
try:
bpy.ops.object.mode_set(mode='OBJECT')
except:
pass
start_time = time.time()
WoWVersionManager().set_client_version(client_version)
wmo = WMOFile(client_version, filepath)
wmo.export = export_method != 'PARTIAL'
bl_scene = BlenderWMOScene(wmo, get_project_preferences())
bl_scene.build_references(export_selected, export_method)
bl_scene.save_materials()
bl_scene.save_doodad_sets()
bl_scene.save_lights()
bl_scene.save_fogs()
bl_scene.prepare_groups()
bl_scene.save_portals()
bl_scene.save_groups()
bl_scene.save_root_header()
# create directory if it doesn't exist, for the new quick save
file = Path(filepath)
file.parent.mkdir(parents=True, exist_ok=True)
for _ in tqdm(range(1), desc='Writing WMO files', ascii=True):
wmo.write()
print("\nExport finished successfully. Saved WMO to " + filepath +
"\nTotal export time: ", time.strftime("%M minutes %S seconds\a", time.gmtime(time.time() - start_time)))
@@ -0,0 +1,107 @@
import bpy
import time
import os
import struct
import timeit
from ..utils.misc import load_game_data
from ..utils.collections import get_current_wow_model_collection, create_wmo_model_collection, SpecialCollection
from .wmo_scene import BlenderWMOScene
from ..pywowlib import WoWVersionManager
from ..pywowlib.wmo_file import WMOFile
from ..ui.preferences import get_project_preferences
from .ui.handlers import DepsgraphLock
from .ui.collections import WMO_SPECIAL_COLLECTION_TYPES, DoodadSetsCollection
def import_wmo_to_blender_scene(filepath: str, client_version: int, wowfilepath: str = ''):
""" Read and import WoW WMO object to Blender scene"""
start_time = time.time()
WoWVersionManager().set_client_version(client_version)
print("\nImporting WMO")
project_preferences = get_project_preferences()
game_data = load_game_data()
if not bpy.wow_game_data.files:
raise Exception("WoW game data is not loaded. Check settings.")
if not project_preferences.cache_dir_path:
raise Exception("Cache directory is not set, textures might not work. Check settings.")
with DepsgraphLock():
wmo = WMOFile(client_version, filepath=filepath)
# wmo.read()
execution_time = timeit.timeit(lambda: wmo.read(), number=1)
ms_time = execution_time * 1000
print(f"Pywowlib WMO read time : {ms_time:.4f} ms")
wmo_scene = BlenderWMOScene(wmo=wmo, prefs=project_preferences)
# set wmo model collection
wow_model_collection = get_current_wow_model_collection(bpy.context.scene, 'wow_wmo')
if not wow_model_collection:
wow_model_collection = create_wmo_model_collection(bpy.context.scene, filepath, wowfilepath)
SpecialCollection.verify_root_collection_integrity(wow_model_collection, WMO_SPECIAL_COLLECTION_TYPES)
DoodadSetsCollection.verify_doodad_sets_collection_integrity(bpy.context.scene, wow_model_collection)
# extract textures to cache folder
game_data.extract_textures_as_png(project_preferences.cache_dir_path, wmo.motx.get_all_strings())
# load all WMO components
wmo_scene.load_materials()
wmo_scene.load_lights()
wmo_scene.load_properties()
wmo_scene.load_fogs()
wmo_scene.load_groups()
wmo_scene.load_portals()
wmo_scene.load_portal_relations()
wmo_scene.load_doodads()
# update visibility
bpy.context.scene.wow_visibility = bpy.context.scene.wow_visibility
print("\nDone importing WMO. \nTotal import time: ",
time.strftime("%M minutes %S seconds.\a", time.gmtime(time.time() - start_time)))
def import_wmo_to_blender_scene_gamedata(filepath: str, client_version: int):
filepath = filepath.replace('/', '\\')
game_data = load_game_data()
if not game_data or not game_data.files:
raise FileNotFoundError("Game data is not loaded.")
project_preferences = get_project_preferences()
cache_dir = project_preferences.cache_dir_path
game_data.extract_file(cache_dir, filepath)
if os.name != 'nt':
filepath = filepath.lower()
root_path = os.path.join(cache_dir, filepath.replace('\\', '/'))
else:
root_path = os.path.join(cache_dir, filepath)
with open(root_path, 'rb') as f:
f.seek(24)
n_groups = struct.unpack('I', f.read(4))[0]
group_paths = ["{}_{}.wmo".format(filepath[:-4], str(i).zfill(3)) for i in range(n_groups)]
game_data.extract_files(cache_dir, group_paths)
import_wmo_to_blender_scene(root_path, client_version, filepath)
# clean up unnecessary files and directories
os.remove(root_path)
for group_path in group_paths:
os.remove(os.path.join(cache_dir, *group_path.split('\\')))
@@ -0,0 +1,211 @@
from ...utils.collections import SpecialCollection, obj_swap_collections, collection_swap_parent_collection, get_or_create_collection, get_current_wow_model_collection
from ...utils.bl_id_types_utils import match_id_name
from ...ui.message_stack import MessageStack
from .enums import SpecialCollections
from .custom_objects import WoWWMOGroup, WoWWMOLight, WoWWMOFog\
, WoWWMOLiquid, WoWWMODoodad, WoWWMOCollision, WoWWMOPortal
from typing import Iterable
import bpy
def get_wmo_collection(scene: bpy.types.Scene,
collection_type: SpecialCollections) -> bpy.types.Collection | None:
wow_model_collection = get_current_wow_model_collection(scene, 'wow_wmo')
if wow_model_collection:
return get_or_create_collection(wow_model_collection, collection_type.name)
else:
return None
def iter_wmo_groups(scene: bpy.types.Scene) -> bpy.types.Object:
""" Iterate and return each Object in Indoor and Outdor group collections
How to use : 'for each in iter_wmo_groups(scene):
print(each)'
"""
for each in get_wmo_collection(scene, SpecialCollections.Outdoor).objects:
yield each
for each in get_wmo_collection(scene, SpecialCollections.Indoor).objects:
yield each
def get_wmo_groups_list(scene: bpy.types.Scene) -> list[bpy.types.Object]:
groups_list = []
for each in iter_wmo_groups(scene):
groups_list.append(each)
return groups_list
class _WMOCollection:
""" Base class for all WMO collections. """
__wbs_root_collection_id_prop_name__ = 'wow_wmo'
class _GroupCollection(_WMOCollection):
""" Base class for WMO group collections. """
__wbs_bl_object_types__ = {'MESH'}
__wbs_custom_object_types__ = {WoWWMOGroup}
class OutdoorGroupCollection(_GroupCollection, SpecialCollection):
""" Collection of objects marked as outdoor group. Accepts any MESH objects
except for those matching other types.
"""
__wbs_collection_name__ = SpecialCollections.Outdoor.name
class IndoorGroupCollection(_GroupCollection, SpecialCollection):
""" Collection of objects marked as indoor group. Accepts any MESH objects
except for those matching other types.
"""
__wbs_collection_name__ = SpecialCollections.Indoor.name
class LightCollection(_WMOCollection, SpecialCollection):
""" Collection of objects marked as lights. Only accepts lights created through an operator."""
__wbs_collection_name__ = SpecialCollections.Lights.name
__wbs_bl_object_types__ = {'LIGHT'}
__wbs_custom_object_types__ = {WoWWMOLight}
class CollisionCollection(_WMOCollection, SpecialCollection):
""" Collection of objects marked as collision. Accepts any MESH objects
except for those matching other types.
"""
__wbs_collection_name__ = SpecialCollections.Collision.name
__wbs_bl_object_types__ = {'MESH'}
__wbs_custom_object_types__ = {WoWWMOCollision}
class FogCollection(_WMOCollection, SpecialCollection):
""" Collection of objects marked as fogs. Only accepts fogs created through an operator. """
__wbs_collection_name__ = SpecialCollections.Fogs.name
__wbs_bl_object_types__ = {'MESH'}
__wbs_custom_object_types__ = {WoWWMOFog}
class LiquidCollection(_WMOCollection, SpecialCollection):
""" Collection of objects marked as liquids. Only accepts liquids created through an operator. """
__wbs_collection_name__ = SpecialCollections.Liquids.name
__wbs_bl_object_types__ = {'MESH'}
__wbs_custom_object_types__ = {WoWWMOLiquid}
class PortalCollection(_WMOCollection, SpecialCollection):
""" Collection of objects marked as portals. Accepts any MESH objects
except for those matching other types.
"""
__wbs_collection_name__ = SpecialCollections.Portals.name
__wbs_bl_object_types__ = {'MESH'}
__wbs_custom_object_types__ = {WoWWMOPortal}
class DoodadSetsCollection(_WMOCollection, SpecialCollection):
""" Collection of collections representing doodad sets. Does not accept any objects.
Child collections accept objects marked as doodads (created by operators).
"""
__wbs_collection_name__ = SpecialCollections.Doodads.name
__wbs_bl_object_types__ = {'MESH'}
__wbs_custom_object_types__ = {WoWWMODoodad}
@classmethod
def is_doodad_set_collection(cls
, scene: bpy.types.Scene
, col: bpy.types.Collection) -> bool:
"""
Identify if a collection is a doodad set collection (child collection of special collection Doodads)
:param scene: Current scene.
:param col: Collection to test.
:return: True if is doodad set collection, else False.
"""
for scene_col_child in scene.collection.children:
# check if we are inside the WMO collection
if getattr(scene_col_child, cls.__wbs_root_collection_id_prop_name__).enabled:
for wmo_col_child in scene_col_child.children:
if match_id_name(wmo_col_child.name, cls.__wbs_collection_name__) \
and col.name in wmo_col_child.children:
return True
return False
@classmethod
def verify_doodad_sets_collection_integrity(cls
, scene: bpy.types.Scene
, root_col: bpy.types.Collection):
if root_col is None:
return False
for child_col in root_col.children:
if match_id_name(child_col.name, DoodadSetsCollection.__wbs_collection_name__):
default_global_coll = None
for col in child_col.children:
if col.name.startswith("Set_$DefaultGlobal"):
default_global_coll = col
break
if not default_global_coll:
default_global_coll = bpy.data.collections.new("Set_$DefaultGlobal")
child_col.children.link(default_global_coll)
default_global_coll.color_tag = 'COLOR_04'
@classmethod
def handle_collection_if_matched(cls
, scene: bpy.types.Scene
, update: bpy.types.DepsgraphUpdate
, special_collection_ts: Iterable['SpecialCollection']) -> bool:
collection: bpy.types.Collection = update.id.original
# test if collection is within a root collection
root_col = cls._get_root_collection(scene, collection)
if root_col is None:
return False
cls.verify_root_collection_integrity(root_col, special_collection_ts)
cls.verify_doodad_sets_collection_integrity(scene, root_col)
# doodad sets collection of collections
if match_id_name(collection.name, cls.__wbs_collection_name__):
# make sure it has no object linked to it directly
if len(collection.objects):
for obj in collection.objects:
obj_swap_collections(obj, collection, root_col)
MessageStack().push_message(msg=f'Collection "{collection.name}" can only contain child collections.')
# doodads set individual collection
elif cls.is_doodad_set_collection(scene, collection):
# make sure no collection is linked to it directly
if len(collection.children):
for child_col in collection.children:
collection_swap_parent_collection(child_col, collection, root_col)
child_col.color_tag = 'COLOR_04'
MessageStack().push_message(msg=f'Collection "{collection.name}" cannot contain child collections.')
for obj in collection.objects:
# handle custom object types (doodads)
if cls.__wbs_custom_object_types__ is not None \
and not any(c_obj.match(obj) for c_obj in cls.__wbs_custom_object_types__):
obj_swap_collections(obj, collection, root_col)
MessageStack().push_message(msg=f'Object "{obj.name}" removed from special collection '
f'"{cls.__wbs_collection_name__}" due to not matching '
f'with any custom object type.', icon='ERROR')
else:
return False
return True
WMO_SPECIAL_COLLECTION_TYPES = (
OutdoorGroupCollection,
IndoorGroupCollection,
FogCollection,
PortalCollection,
LiquidCollection,
LightCollection,
CollisionCollection,
DoodadSetsCollection
)
@@ -0,0 +1,277 @@
from ...utils.custom_object import CustomObject
from ...utils.bl_id_types_utils import match_id_name
from ..bl_render import BlenderWMOObjectRenderFlags
from .enums import SpecialColorLayers
from functools import partial
import bpy
_OBJECT_MODE_DESTRUCTIVE_OPS = {
'OBJECT_OT_transform_apply',
'OBJECT_OT_transforms_to_deltas',
'OBJECT_OT_origin_set',
'TRANSFORM_OT_mirror',
'OBJECT_OT_visual_transform_apply'
}
class WoWWMOGroup(CustomObject):
__wbs_bl_object_type__ = 'MESH'
__wbs_prop_group_id__ = 'wow_wmo_group'
_bl_render_renderflag_map = {
SpecialColorLayers.Lightmap.name: BlenderWMOObjectRenderFlags.HasLightmap,
SpecialColorLayers.BatchmapInt.name: BlenderWMOObjectRenderFlags.HasBatchB,
SpecialColorLayers.BatchmapTrans.name: BlenderWMOObjectRenderFlags.HasBatchA,
SpecialColorLayers.Blendmap.name: BlenderWMOObjectRenderFlags.HasBlendmap
}
@classmethod
def match(cls, obj: bpy.types.Object) -> bool:
return super().match(obj) \
and any(match_id_name(col.name, 'Indoor')
or match_id_name(col.name, 'Outdoor') for col in obj.users_collection)
@classmethod
def is_indoor(cls, obj: bpy.types.Object) -> bool:
if WoWWMOGroup.match(obj):
for col in obj.users_collection:
if match_id_name(col.name, 'Indoor'):
return True
return False
else:
return False
@classmethod
def is_outdoor(cls, obj: bpy.types.Object) -> bool:
if WoWWMOGroup.match(obj):
for col in obj.users_collection:
if match_id_name(col.name, 'Outdoor'):
return True
return False
else:
return False
@classmethod
def handle_object_if_matched(cls
, update: bpy.types.DepsgraphUpdate) -> bool:
obj: bpy.types.Object = update.id.original
if not cls.match(obj):
return False
cls.on_each_update(update)
return True
@classmethod
def on_each_update(cls, update: bpy.types.DepsgraphUpdate) -> bool:
obj: bpy.types.Object = update.id.original
mesh: bpy.types.Mesh = obj.data
# change shader behavior depending on the presence of specially named color layers
for col_name, flag in cls._bl_render_renderflag_map.items():
col = mesh.color_attributes.get(col_name)
if col:
obj.pass_index |= flag
else:
obj.pass_index &= ~flag
# update shader based on current place type
if cls.is_outdoor(obj):
obj.pass_index |= BlenderWMOObjectRenderFlags.IsOutdoor
obj.pass_index &= ~BlenderWMOObjectRenderFlags.IsIndoor
else:
obj.pass_index &= ~BlenderWMOObjectRenderFlags.IsOutdoor
obj.pass_index |= BlenderWMOObjectRenderFlags.IsIndoor
return True
class WoWWMOFog(CustomObject):
__wbs_bl_object_type__ = 'MESH'
__wbs_prop_group_id__ = 'wow_wmo_fog'
__wbs_allowed_modes__ = set()
__wbs_allow_scale__ = True
__wbs_allow_non_uniform_scale__ = False
__wbs_allow_rotation__ = False
__wbs_allow_modifiers__ = False
__wbs_allow_constraints__ = False
__wbs_allow_material_properties__ = False
__wbs_allow_mesh_properties__ = False
__wbs_allow_particles__ = False
__wbs_allow_physics__ = False
__wbs_banned_ops__ = _OBJECT_MODE_DESTRUCTIVE_OPS
class WoWWMODoodad(CustomObject):
__wbs_bl_object_type__ = 'MESH'
__wbs_prop_group_id__ = 'wow_wmo_doodad'
__wbs_allowed_modes__ = set()
__wbs_allow_scale__ = True
__wbs_allow_non_uniform_scale__ = False
__wbs_allow_rotation__ = True
__wbs_allow_modifiers__ = False
__wbs_allow_constraints__ = False
__wbs_allow_material_properties__ = False
__wbs_allow_mesh_properties__ = False
__wbs_allow_particles__ = False
__wbs_allow_physics__ = False
__wbs_banned_ops__ = _OBJECT_MODE_DESTRUCTIVE_OPS
@classmethod
def on_each_update(cls, update: bpy.types.DepsgraphUpdate) -> bool:
obj: bpy.types.Object = update.id.original
# handle object copies
if obj.active_material:
if obj.active_material.users > 1:
for i, mat in enumerate(obj.data.materials):
mat = mat.copy()
obj.data.materials[i] = mat
return True
class WoWWMOLight(CustomObject):
__wbs_bl_object_type__ = 'LIGHT'
__wbs_prop_group_id__ = 'wow_wmo_light'
__wbs_allowed_modes__ = set()
__wbs_allow_scale__ = False
__wbs_allow_non_uniform_scale__ = False
__wbs_allow_rotation__ = False
__wbs_allow_modifiers__ = False
__wbs_allow_constraints__ = False
__wbs_allow_material_properties__ = False
__wbs_allow_particles__ = False
__wbs_allow_physics__ = False
__wbs_banned_ops__ = set()
class WoWWMOLiquid(CustomObject):
__wbs_bl_object_type__ = 'MESH'
__wbs_prop_group_id__ = 'wow_wmo_liquid'
__wbs_allowed_modes__ = {'EDIT', 'SCULPT', 'VERTEX_PAINT'}
__wbs_allow_scale__ = False
__wbs_allow_non_uniform_scale__ = False
__wbs_allow_rotation__ = False
__wbs_allow_modifiers__ = False
__wbs_allow_constraints__ = False
__wbs_allow_material_properties__ = False
__wbs_allow_particles__ = False
__wbs_allow_physics__ = False
__wbs_banned_ops__ = {
'TRANSFORM_OT_mirror',
'TRANSFORM_OT_mirror',
'MESH_OT_delete',
'MESH_OT_duplicate_move',
'MESH_OT_extrude_region',
'MESH_OT_extrude_verts_indiv',
'MESH_OT_split',
'MESH_OT_symmetrize',
'MESH_OT_sort_elements',
'MESH_OT_delete_loose',
'MESH_OT_decimate',
'MESH_OT_dissolve_degenerate',
'MESH_OT_dissolve_limited',
'MESH_OT_face_make_planar',
'MESH_OT_face_make_planar',
'MESH_OT_vert_connect_nonplanar',
'MESH_OT_vert_connect_concave',
'MESH_OT_bevel',
'MESH_OT_merge'
}
@staticmethod
def _liquid_edit_mode_timer(context: bpy.types.Context):
bpy.ops.wow.liquid_edit_mode(context, 'INVOKE_DEFAULT')
@staticmethod
def _edit_mode(_: bpy.types.DepsgraphUpdate):
win = bpy.context.window
# avoid focusing settings window if left open
if win.screen.name == 'temp':
for win_ in bpy.context.window_manager.windows:
if win_.screen.name != 'temp':
win = win_
scr = win.screen
areas3d = [area for area in scr.areas if area.type == 'VIEW_3D']
region = [region for region in areas3d[0].regions if region.type == 'WINDOW'][0]
space = [space for space in areas3d[0].regions if space.type == 'VIEW_3D']
override = {'window': win,
'screen': scr,
'area': areas3d[0],
'region': region,
'scene': bpy.context.scene,
'workspace': bpy.context.workspace,
'space_data': space,
'region_data': region
}
# we need a timer here to prevent operator recognizing tab event as exit
bpy.app.timers.register(partial(WoWWMOLiquid._liquid_edit_mode_timer, override), first_interval=0.1)
@staticmethod
def _sculpt_mode(_: bpy.types.DepsgraphUpdate):
for brush in bpy.data.brushes:
brush.sculpt_plane = 'Z'
__wbs_on_mode_handlers__ = {
'EDIT': _edit_mode,
'SCULPT': _sculpt_mode
}
class WoWWMOPortal(CustomObject):
__wbs_bl_object_type__ = 'MESH'
__wbs_prop_group_id__ = 'wow_wmo_portal'
__wbs_allowed_modes__ = {'EDIT'}
__wbs_allow_modifiers__ = False
__wbs_allow_mesh_properties__ = False
__wbs_allow_particles__ = False
__wbs_allow_physics__ = False
@classmethod
def match(cls, obj: bpy.types.Object) -> bool:
return super().match(obj) \
and any(match_id_name(col.name, 'Portals') for col in obj.users_collection)
class WoWWMOCollision(CustomObject):
__wbs_bl_object_type__ = 'MESH'
__wbs_prop_group_id__ = ''
__wbs_allow_modifiers__ = True
__wbs_allow_constraints__ = False
__wbs_allow_material_properties__ = False
__wbs_allow_particles__ = False
__wbs_allow_physics__ = False
@classmethod
def match(cls, obj: bpy.types.Object) -> bool:
return super().match(obj) \
and any(match_id_name(col.name, 'Collision') for col in obj.users_collection)
@classmethod
def handle_object_if_matched(cls
, update: bpy.types.DepsgraphUpdate) -> bool:
obj: bpy.types.Object = update.id.original
return cls.match(obj)
WMO_CUSTOM_OBJECT_TYPES = (
WoWWMOGroup,
WoWWMOFog,
WoWWMOPortal,
WoWWMOLight,
WoWWMOLiquid,
WoWWMOCollision,
WoWWMODoodad
)
""" Tuple of all custom objects defined in this file. """
@@ -0,0 +1,155 @@
from ... import ui_icons
from enum import Enum, auto
__reload_order_index__ = -1
shader_enum = [
('0', "Diffuse", ""),
('1', "Specular", ""),
('2', "Metal", ""),
('3', "Env", ""),
('4', "Opaque", ""),
('5', "EnvMetal", ""),
('6', "TwoLayerDiffuse", ""),
('7', "TwoLayerEnvMetal", ""),
('8', "TwoLayerTerrain", ""),
('9', "DiffuseEmissive", ""),
('10', "waterWindow", ""),
('11', "MaskedEnvMetal", ""),
('12', "EnvMetalEmissive", ""),
('13', "TwoLayerDiffuseOpaque", ""),
('14', "submarineWindow", ""),
('15', "TwoLayerDiffuseEmissive", ""),
('16', "DiffuseTerrain", ""),
('17', "AdditiveMaskedEnvMetal", ""),
('18', "TwoLayerDiffuseMod2x", ""),
('19', "TwoLayerDiffuseMod2xNA", ""),
('20', "TwoLayerDiffuseAlpha", ""),
('21', "MapObjLod", ""),
('22', "MapObjParallax", "")
]
terrain_type_enum = [ # can be loaded from DBC. TODO: legion terrain types
('0', "Dirt", ""), ('1', "Metallic", ""), ('2', "Stone", ""),
('3', "Snow", ""), ('4', "Wood", ""), ('5', "Grass", ""),
('6', "Leaves", ""), ('7', "Sand", ""), ('8', "Soggy", ""),
('9', "Dusty Grass", ""), ('10', "None", ""), ('11', "Water", ""),
('12', "Unknown", ""),('13', "Unknown", ""),
]
blending_enum = [
('0', "Blend_Opaque", ""), ('1', "Blend_AlphaKey", ""), ('2', "Blend_Alpha", ""),
('3', "Blend_Add", ""), ('4', "Blend_Mod", ""), ('5', "Blend_Mod2x", ""),
('6', "Blend_ModAdd", ""), ('7', "Blend_InvSrcAlphaAdd", ""), ('8', "Blend_InvSrcAlphaOpaque", ""),
('9', "Blend_SrcAlphaOpaque", ""), ('10', "Blend_NoAlphaAdd", ""), ('11', "Blend_ConstantAlpha", "")
]
material_flag_enum = [
("1", "Unlit", "Disable lighting", 'SHADING_SOLID', 0x1),
("2", "Unfogged", "Disable fog", 'MOD_FLUID', 0x2),
("4", "Two-sided", "Render from both sides", 'MOD_UVPROJECT', 0x4),
("8", "Exterior light", "Ignore local WMO lighting, use world lighting instead", 'LIGHT_SUN', 0x8),
("16", "Night Glow", "Used for windows to glow at nighttime", ui_icons['MAT_NIGHT_GLOW'], 0x10),
("32", "Window", "Unknown, used for windows", ui_icons['MAT_WINDOW'], 0x20),
("64", "Clamp_S", "Force texture to use clamp _s adressing", 'DISCLOSURE_TRI_DOWN', 0x40),
("128", "Clamp_T", "Force texture to use clamp _t adressing", 'TRIA_DOWN', 0x80)
]
group_flag_enum = [
('0', "Vertex color", "Check if you need vertex color in this group", 'SHADING_RENDERED', 0x1),
('1', "No local lighting", "Use world-defined lighting in a group", 'LIGHT', 0x2),
('2', "Always draw", "Always draw the model. Disable portal-based geometry culling", 'SHAPEKEY_DATA', 0x4),
('3', "Mounts allowed", "Allow mounts in this indoor group", 'MESH_MONKEY', 0x8),
('4', "Use Skybox", "Display WMO skybox in this indoor group", 'SURFACE_NSPHERE', 0x10),
('5', "Show exterior sky", "Show exterior sky in interior WMO group", 'MAT_SPHERE_SKY', 0x20)
]
place_type_enum = [
('8', "Outdoor", "", 'OBJECT_DATA', 0),
('8192', "Indoor", "", 'MOD_SUBSURF', 1)
]
liquid_type_enum = [
('0', "No liquid", ""), ('1', "Water", ""), ('2', "Ocean", ""),
('3', "Magma", ""), ('4', "Slime", ""), ('5', "Slow Water", ""),
('6', "Slow Ocean", ""), ('7', "Slow Magma", ""), ('8', "Slow Slime", ""),
('9', "Fast Water", ""), ('10', "Fast Ocean", ""), ('11', "Fast Magma", ""),
('12', "Fast Slime", ""), ('13', "WMO Water", ""), ('14', "WMO Ocean", ""),
('15', "Green Lava", ""), ('17', "WMO Water - Interior", ""), ('19', "WMO Magma", ""),
('20', "WMO Slime", ""), ('21', "Naxxramas - Slime", ""), ('41', "Coilfang Raid - Water", ""),
('61', "Hyjal Past - Water", ""), ('81', "Lake Wintergrasp - Water", ""), ('100', "Basic Procedural Water", ""),
('121', "CoA Black - Magma", ""), ('141', "Chamber Magma", ""), ('181', "Orange Slime", "")
]
portal_dir_alg_enum = [
("0", "Auto", "", 'MONKEY', 0),
("1", "Positive", "", 'ADD', 1),
("2", "Negative", "", 'REMOVE', 2)
]
portal_detail_enum = [
("0", "None", ""),
("1", "First", ""),
("2", "Second", "")
]
root_flags_enum = [
("0", "No Auto Attenuation", "Do not attenuate light on vertices based on distance from portal", 'NODE_TEXTURE', 0x1),
("1", "Do Not Use Lightmap", "", 'LIGHT_SUN', 0x2),
("2", "Unified rendering", "Use ambient lighting inside indoor groups", 'OUTLINER_OB_LIGHT', 0x4),
# ("3", "Use real Liquid Type", "Use real liquid type ID from DBCs instead of local one.\nDisabling this might result in filling the WMO with water.", 'MOD_OCEAN', 0x8)
]
light_type_enum = [
('0', "Omni", ""), ('1', "Spot", ""),
('2', "Direct", ""), ('3', "Ambient", "")
]
class SpecialCollections(Enum):
"""
Represents names of collections that have special meaning within the addon.
"""
Outdoor = auto()
""" Collection used to store outdoor WMO groups. """
Indoor = auto()
""" Collection used to store indor WMO groups. """
Lights = auto()
""" Collection used to store lights. """
Fogs = auto()
""" Collection used to store fogs. """
Liquids = auto()
""" Collection used to store liquids such as water or lava. """
Portals = auto()
""" Collection used to store portal planes. """
Doodads = auto()
""" Collection used to store doodad sets (collections of doodads). """
Collision = auto()
""" Collection used to store WoW coliision. """
class SpecialColorLayers(Enum):
"""
Represents vertex color layer names that have special meaning within the addon.
"""
Lightmap = auto()
""" Color representing the lightmap (blending of exterior and interior lighting). """
BatchmapInt = auto()
""" Color layer representing markup of batch types for interior batches. """
BatchmapTrans = auto()
""" Color layer representing markup of batch types for transitional batches. (priority over BatchmapInt). """
Blendmap = auto()
""" Color layer representing blending mask for materials that use multiple textures. """
@@ -0,0 +1,86 @@
from ...ui.locks import DepsgraphLock
from ...ui.message_stack import MessageStack
from ...utils.collections import SpecialCollection
from ..ui.custom_objects import WMO_CUSTOM_OBJECT_TYPES
from ..ui.collections import WMO_SPECIAL_COLLECTION_TYPES
from ...ui.enums import WoWSceneTypes
from .collections import DoodadSetsCollection
from bpy.app.handlers import persistent
import bpy
def handle_material_update(update: bpy.types.DepsgraphUpdate):
"""
Handles update to materials.
:param update: Current update.
"""
... # TODO
def handle_collection_update(scene: bpy.types.Scene, update: bpy.types.DepsgraphUpdate):
"""
Handles update to collections.
:param scene: Current scene.
:param update: Current update.
"""
collection: bpy.types.Collection = update.id.original
# check if we are inside a WMO collection.
if collection.wow_wmo.enabled:
if collection.name not in scene.collection.children:
collection.wow_wmo.enabled = False
return
# verify integrity of child
else:
SpecialCollection.verify_root_collection_integrity(collection, WMO_SPECIAL_COLLECTION_TYPES)
DoodadSetsCollection.verify_doodad_sets_collection_integrity(scene, collection)
return
for col_type in WMO_SPECIAL_COLLECTION_TYPES:
if col_type.handle_collection_if_matched(scene, update, WMO_SPECIAL_COLLECTION_TYPES):
break
def handle_object_update(update: bpy.types.DepsgraphUpdate):
"""
Handles update to objects.
:param scene: Current scene.
:param update: Current update.
"""
for obj_type in WMO_CUSTOM_OBJECT_TYPES:
if obj_type.handle_object_if_matched(update):
break
@persistent
def on_depsgraph_update(scene: bpy.types.Scene, depsgraph: bpy.types.Depsgraph):
"""
Called on every update to depsgraph.
:param scene: Current scene.
:param depsgraph: Updated depsgraph.
"""
if scene.wow_scene.type != WoWSceneTypes.WMO.name or DepsgraphLock().status:
return
with DepsgraphLock():
for update in depsgraph.updates:
if isinstance(update.id, bpy.types.Object):
handle_object_update(update)
elif isinstance(update.id, bpy.types.Collection):
handle_collection_update(scene, update)
elif isinstance(update.id, bpy.types.Material):
handle_material_update(update)
MessageStack().invoke_message_box(icon='ERROR')
def register():
bpy.app.handlers.depsgraph_update_post.append(on_depsgraph_update)
def unregister():
bpy.app.handlers.depsgraph_update_post.remove(on_depsgraph_update)
@@ -0,0 +1,28 @@
from bpy.types import Menu
class VIEW3D_MT_wmo_doodad_actions(Menu):
bl_label = "Doodad Actions"
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
pie.operator("scene.wow_doodads_bake_color", text='Bake Color', icon='SHADING_RENDERED')
pie.operator("scene.wow_doodad_set_color", text='Set Color', icon='SHADING_SOLID')
op = pie.operator("scene.wow_doodad_set_template_action", text='Template Select', icon='PMARKER_ACT')
op.action = 'SELECT'
op = pie.operator("scene.wow_doodad_set_template_action", text='Template Replace', icon='FILE_REFRESH')
op.action = 'REPLACE'
op = pie.operator("scene.wow_doodad_set_template_action", text='Template Delete', icon='CANCEL')
op.action = 'DELETE'
op = pie.operator("scene.wow_doodad_set_template_action", text='Template Rotate', icon='LOOP_FORWARDS')
op.action = 'ROTATE'
op = pie.operator("scene.wow_doodad_set_template_action", text='Template Resize', icon='FULLSCREEN_ENTER')
op.action = 'RESIZE'
@@ -0,0 +1,14 @@
from bpy.types import Menu
class VIEW3D_MT_wmo_group_actions(Menu):
bl_label = "Group Actions"
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
pie.operator("scene.wow_wmo_generate_materials", text='Generate materials', icon='MATERIAL_DATA')
pie.operator("scene.wow_fill_textures", text='Fill texture paths', icon='SEQ_SPLITVIEW')
pie.operator("scene.wow_quick_collision", text='Quick collision', icon='MOD_TRIANGULATE')
@@ -0,0 +1,331 @@
import bpy
import bmesh
import traceback
import os
from bpy.types import Menu
from functools import partial
from ....ui.preferences import get_project_preferences
from ....utils.misc import load_game_data
from ...utils.wmv import wmv_get_last_texture
from ...utils.materials import load_texture
from ...bl_render import update_wmo_mat_node_tree, load_wmo_shader_dependencies
class WMO_OT_assign_material(bpy.types.Operator):
bl_idname = "mesh.wow_assign_material"
bl_label = "Assign material"
bl_description = "Assign selected material to selected polygons"
bl_options = {'UNDO', 'REGISTER', 'INTERNAL'}
mat_name: bpy.props.StringProperty(default='Material')
action: bpy.props.EnumProperty(
name='Action',
items=(('NAME', "", ""),
('NEW', "", "")),
default='NAME'
)
@classmethod
def poll(cls, context):
return context.object and context.object.type == 'MESH' and context.object.mode == 'EDIT'
def execute(self, context):
try:
obj = context.object
mesh = obj.data
mat = bpy.data.materials[self.mat_name] if self.action == 'NAME' else bpy.data.materials.new(self.mat_name)
if self.action == 'NEW':
texture = context.scene.wow_last_selected_images[-1].pointer
mat.wow_wmo_material.path = texture.wow_wmo_texture.path
mat.wow_wmo_material.diff_texture_1 = texture
load_wmo_shader_dependencies()
update_wmo_mat_node_tree(mat)
mat_index = -1
for i, m in enumerate(mesh.materials):
if m is mat:
mat_index = i
break
if mat_index < 0:
mat_index = len(mesh.materials)
mesh.materials.append(mat)
bm = bmesh.from_edit_mesh(mesh)
for face in bm.faces:
if face.select:
face.material_index = mat_index
bmesh.update_edit_mesh(mesh, loop_triangles=False, destructive=False)
except:
traceback.print_exc()
self.report({'ERROR'}, "Setting material failed.")
return {'CANCELLED'}
return {'FINISHED'}
class WMO_OT_import_texture_from_filepath(bpy.types.Operator):
bl_idname = "mesh.wow_import_texture_filepath"
bl_label = "Import texture from filepath"
bl_description = "Import a texture file from filepath and create material for it."
bl_options = {'UNDO', 'REGISTER', 'INTERNAL'}
filepath: bpy.props.StringProperty(subtype="FILE_PATH")
@classmethod
def poll(cls, context):
return context.object and context.object.type == 'MESH' and context.object.mode == 'EDIT'
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def execute(self, context):
game_data = load_game_data()
if not game_data:
self.report({'ERROR'}, "Importing texture failed. Game data was not loaded.")
return {'CANCELLED'}
path = game_data.traverse_file_path(bpy.path.abspath(self.filepath))
if not path:
self.report({'INFO'}, "Texture was not found in WoW file system. Set the path in material manually.")
texture = bpy.data.images.load(bpy.path.abspath(self.filepath))
texture.wow_wmo_texture.path = path if path else self.filepath
texture.name = os.path.basename(self.filepath)
mat = bpy.data.materials.new(name=texture.name)
mat.wow_wmo_material.diff_texture_1 = texture
load_wmo_shader_dependencies()
update_wmo_mat_node_tree(mat)
global display_material_select_pie
display_material_select_pie = False
context.scene.wow_cur_image = texture
display_material_select_pie = True
bpy.ops.mesh.wow_assign_material(mat_name=mat.name, action='NAME')
return {'FINISHED'}
class WMO_OT_import_texture_from_wmv(bpy.types.Operator):
bl_idname = "mesh.wow_import_texture_wmv"
bl_label = "Import texture from WoWModelViewer"
bl_description = "Import a texture file from WMV and create material for it."
bl_options = {'UNDO', 'REGISTER', 'INTERNAL'}
@classmethod
def poll(cls, context):
return context.object and context.object.type == 'MESH' and context.object.mode == 'EDIT'
def execute(self, context):
project_preferences = get_project_preferences()
game_data = load_game_data()
if not game_data:
self.report({'ERROR'}, "Importing texture failed. Game data was not loaded.")
return {'CANCELLED'}
path = wmv_get_last_texture()
if not path:
self.report({'ERROR'}, "WMV log does not contain any texture paths.")
return {'CANCELLED'}
game_data.extract_textures_as_png(project_preferences.cache_dir_path, (path,))
texture = load_texture({}, path, project_preferences.cache_dir_path)
mat = bpy.data.materials.new(name=path.split('\\')[-1][:-4] + '.PNG')
mat.wow_wmo_material.diff_texture_1 = texture
load_wmo_shader_dependencies()
update_wmo_mat_node_tree(mat)
# slot = context.scene.wow_wmo_root_elements.materials.add()
# slot.pointer = mat
global display_material_select_pie
display_material_select_pie = False
context.scene.wow_cur_image = texture
display_material_select_pie = True
bpy.ops.mesh.wow_assign_material(mat_name=mat.name, action='NAME')
return {'FINISHED'}
class WMO_OT_select_texture_from_recent(bpy.types.Operator):
bl_idname = "mesh.wow_select_texture_from_recent"
bl_label = "Select texture"
bl_description = "Select recently used texture"
bl_options = {'REGISTER', 'INTERNAL'}
index: bpy.props.IntProperty()
@classmethod
def poll(cls, context):
return context.object and context.object.type == 'MESH' and context.object.mode == 'EDIT'
def execute(self, context):
context.scene.wow_cur_image = context.scene.wow_last_selected_images[self.index].pointer
return {'FINISHED'}
class VIEW3D_MT_wmo_select_texture(Menu):
bl_label = "Select texture"
@classmethod
def poll(cls, context):
return context.object and context.object.type == 'MESH' and context.object.mode == 'EDIT'
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
box = pie.box()
box.label(text='Select texture', icon='VIEWZOOM')
row = box.row()
row.scale_x = 0.5
row.scale_y = 0.5
row.template_ID_preview(context.scene, 'wow_cur_image', hide_buttons=True, rows=5, cols=5)
if len(context.scene.wow_last_selected_images):
box = pie.box()
row = box.row()
for i, texture in enumerate(context.scene.wow_last_selected_images):
col = row.column()
col.template_icon(row.icon(texture.pointer), scale=5.0)
op = col.operator("mesh.wow_select_texture_from_recent", text='', icon='ADD')
op.index = i
# pie.operator("mesh.wow_import_texture_wmv", text='Import from WMV', icon='ADD')
pie.operator("mesh.wow_import_texture_filepath", text='Import from file', icon='ADD')
def timer(override):
bpy.ops.wm.call_menu_pie(override, name="VIEW3D_MT_wmo_select_material")
def handle_last_selected_images(scene):
# clear invalid items
while True:
for i, tex in enumerate(scene.wow_last_selected_images):
if not tex.pointer:
scene.wow_last_selected_images.remove(i)
break
else:
break
# avoid duplicates and truncate the collection
for i, tex in enumerate(scene.wow_last_selected_images):
if tex.pointer == scene.wow_cur_image:
scene.wow_last_selected_images.remove(i)
break
else:
if len(scene.wow_last_selected_images) > 5:
scene.wow_last_selected_images.remove(0)
slot = scene.wow_last_selected_images.add()
slot.pointer = scene.wow_cur_image
scene.wow_cur_image = None
display_material_select_pie = True
def set_image(self, value):
if self.wow_cur_image:
global display_material_select_pie
handle_last_selected_images(self)
if display_material_select_pie:
bpy.app.timers.register(partial(timer, bpy.context.copy()), first_interval=0.0)
def get_more_materials_list(self, context):
scene = bpy.context.scene
materials = list([mat for mat in bpy.data.materials
if mat.wow_wmo_material.diff_texture_1 == scene.wow_last_selected_images[-1].pointer])
return list([(mat.name, mat.name, mat.name, 'MATERIAL', i) for i, mat in enumerate(materials[5:])]) if len(
materials) > 6 else []
def update_more_materials(self, context):
if self.more_materials:
bpy.ops.mesh.wow_assign_material(mat_name=self.more_materials, action='NAME')
class VIEW3D_MT_wmo_select_material(Menu):
bl_label = "Select material"
def draw(self, context):
scene = bpy.context.scene
layout = self.layout
pie = layout.menu_pie()
op = pie.operator("mesh.wow_assign_material", text='New material', icon='ADD')
op.action = 'NEW'
materials = list([mat for mat in bpy.data.materials
if mat.wow_wmo_material.diff_texture_1 == scene.wow_last_selected_images[-1].pointer])
if len(materials) > 6:
for mat in materials[:5]:
op = pie.operator("mesh.wow_assign_material", text=mat.pointer.name, icon='MATERIAL')
op.mat_name = mat.pointer.name
op.action = 'NAME'
box = pie.box()
box.prop(scene, "more_materials", text='More', icon='MATERIAL')
else:
for mat in materials:
op = pie.operator("mesh.wow_assign_material", text=mat.pointer.name, icon='MATERIAL')
op.mat_name = mat.pointer.name
op.action = 'NAME'
class ImagePointerPropertyGroup(bpy.types.PropertyGroup):
pointer: bpy.props.PointerProperty(type=bpy.types.Image)
def register():
bpy.types.Scene.wow_cur_image = bpy.props.PointerProperty(type=bpy.types.Image, update=set_image)
bpy.types.Scene.wow_last_selected_images = bpy.props.CollectionProperty(type=ImagePointerPropertyGroup)
bpy.types.Scene.more_materials = bpy.props.EnumProperty( name='More'
, items=get_more_materials_list
, update=update_more_materials
)
def unregister():
del bpy.types.Scene.more_materials
del bpy.types.Scene.wow_last_selected_images
del bpy.types.Scene.wow_cur_image
@@ -0,0 +1,45 @@
import bpy
from ..custom_objects import *
class WMO_OT_pie_menu_manager(bpy.types.Operator):
bl_idname = 'wm.wmo_pie_menu_manager'
bl_label = 'Pie Menu Manager'
bl_options = {'INTERNAL', 'UNDO'}
def invoke(self, context, event):
if not context.object:
return {'FINISHED'}
if context.object.type == 'MESH':
if WoWWMOGroup.match(context.object):
if context.object.mode == 'EDIT':
bpy.ops.wm.call_menu_pie(name='VIEW3D_MT_wmo_select_texture')
elif context.object.mode == 'OBJECT':
bpy.ops.wm.call_menu_pie(name='VIEW3D_MT_wmo_group_actions')
elif WoWWMODoodad.match(context.object):
bpy.ops.wm.call_menu_pie(name='VIEW3D_MT_wmo_doodad_actions')
elif WoWWMOPortal.match(context.object):
bpy.ops.wm.call_menu_pie(name='VIEW3D_MT_wmo_portal_actions')
return {'FINISHED'}
keymap = None
def register():
global keymap
wm = bpy.context.window_manager
km = wm.keyconfigs.addon.keymaps.new(name='3D View', space_type='VIEW_3D', region_type='WINDOW')
kmi = km.keymap_items.new("wm.wmo_pie_menu_manager", type='Q', value='PRESS', shift=True)
keymap = km, kmi
def unregister():
global keymap
km, kmi = keymap
km.keymap_items.remove(kmi)
@@ -0,0 +1,14 @@
from bpy.types import Menu
from .... import ui_icons
class VIEW3D_MT_wmo_portal_actions(Menu):
bl_label = "Portal Actions"
def draw(self, context):
layout = self.layout
pie = layout.menu_pie()
pie.operator("scene.wow_set_portal_dir_alg", text='Set portal direction', icon='INDIRECT_ONLY_ON')
pie.operator("scene.wow_bake_portal_relations", text='Bake portal relations', icon='LIBRARY_DATA_DIRECT')
@@ -0,0 +1,381 @@
import bpy
import mathutils
from ..panels.toolbar import switch_doodad_set, get_doodad_sets
from ...utils.doodads import import_doodad
from ...utils.wmv import wmv_get_last_m2, wow_export_get_last_m2
from ....ui.preferences import get_project_preferences
from ....utils.misc import find_nearest_object
from ....third_party.tqdm import tqdm
from ..custom_objects import WoWWMODoodad, WoWWMOGroup
from ...ui.collections import get_wmo_collection, get_current_wow_model_collection, get_or_create_collection, DoodadSetsCollection, get_wmo_groups_list
from ...ui.enums import SpecialCollections
class WMO_OT_wmv_import_doodad_from_wmv(bpy.types.Operator):
bl_idname = 'scene.wow_wmo_import_doodad_from_wmv'
bl_label = 'Import last M2 from WMV'
bl_description = 'Import last M2 from WoW Model Viewer'
bl_options = {'REGISTER', 'UNDO'}
def get_active_doodad_collection(self, scene : bpy.types.Scene) -> bpy.types.Collection | None:
active_coll = bpy.context.collection
active_set_collection : bpy.types.Collection = None
if DoodadSetsCollection.is_doodad_set_collection(scene, active_coll):
active_set_collection = active_coll
else:
# if the current selected collection isn't a doodadset coll, import to DefaultGlobal
wmo_model_collection = get_current_wow_model_collection(bpy.context.scene, 'wow_wmo')
if wmo_model_collection:
for set_collection in get_wmo_collection(scene, SpecialCollections.Doodads).children:
DoodadSetsCollection.verify_doodad_sets_collection_integrity(scene, wmo_model_collection)
if set_collection.name.startswith("Set_$DefaultGlobal"):
active_set_collection = set_collection
self.report({'INFO'}, "No Doodad Set collection selected, importing it to [Set_$DefaultGlobal].")
else:
self.report({'ERROR'}, "Failed to find an active WMO model collection.")
if not active_set_collection or active_set_collection is None:
self.report({'ERROR'}, "Failed to import doodad. No active doodad set is selected."
"Select the doodad set to import the doodad to in the WMO panel")
return None
return active_set_collection
def execute(self, context):
project_preferences = get_project_preferences()
if project_preferences.import_method == 'WMV':
if project_preferences.wmv_path:
m2_path = wmv_get_last_m2()
elif project_preferences.import_method == 'WowExport':
if project_preferences.wow_export_path:
m2_path = wow_export_get_last_m2()
cache_path = get_project_preferences().cache_dir_path
active_set_collection = self.get_active_doodad_collection(context.scene)
if active_set_collection is None:
return {'FINISHED'}
if not m2_path:
self.report({'ERROR'}, "WoW Model Viewer log contains no model entries."
"Make sure to use compatible WMV version or open an .m2 there.")
return {'CANCELLED'}
obj = import_doodad(m2_path, cache_path)
obj.location = context.scene.cursor.location
active_set_collection.objects.link(obj)
context.view_layer.objects.active = obj
obj.wow_wmo_doodad.color = (1, 1, 1, 1)
return {'FINISHED'}
class WMO_OT_doodads_bake_color(bpy.types.Operator):
bl_idname = "scene.wow_doodads_bake_color"
bl_label = "Bake doodads color"
bl_description = "Bake doodads colors from nearby vertex color values"
bl_options = {'UNDO', 'REGISTER'}
tree_map = {}
@staticmethod
def get_object_radius(obj):
corner_min = [32767, 32767, 32767]
corner_max = [0, 0, 0]
mesh = obj.data
for vertex in mesh.vertices:
for i in range(3):
corner_min[i] = min(corner_min[i], vertex.co[i])
corner_max[i] = max(corner_max[i], vertex.co[i])
result = (mathutils.Vector(corner_min) - mathutils.Vector(corner_max))
return (abs(result.x) + abs(result.y) + abs(result.z)) / 3
def gen_doodad_color(self, obj, group, wmo_model_collection):
mesh = group.data
if 'Col' not in mesh.color_attributes:
return 0.5, 0.5, 0.5, 1.0
radius = self.get_object_radius(obj)
colors = []
kd_tree = self.tree_map.get(group.name)
if not kd_tree:
kd_tree = mathutils.kdtree.KDTree(len(mesh.polygons))
for index, poly in enumerate(mesh.polygons):
kd_tree.insert(group.matrix_world @ poly.center, index)
kd_tree.balance()
self.tree_map[group.name] = kd_tree
polygons = kd_tree.find_range(obj.location, radius)
if not polygons:
polygons.append(kd_tree.find(obj.location))
color_layer = mesh.color_attributes["Col"]
is_corner_domain = (color_layer.domain == 'CORNER')
for poly in polygons:
if is_corner_domain:
for loop_index in mesh.polygons[poly[1]].loop_indices:
# colors.append(mesh.vertex_colors['Col'].data[loop_index].color)
colors.append(color_layer.data[loop_index].color)
else:
for vertex_index in mesh.polygons[poly[1]].vertices:
colors.append(color_layer.data[vertex_index].color)
if not colors:
return 0.5, 0.5, 0.5, 1.0
final_color = mathutils.Vector((0, 0, 0, 0))
for color in colors:
final_color += mathutils.Vector(color)
final_color = final_color / len(colors)
flags = wmo_model_collection.wow_wmo.flags
if "2" in flags and WoWWMOGroup.is_indoor(group):
final_color += mathutils.Vector(tuple([c / 2 for c in wmo_model_collection.wow_wmo.ambient_color]))
return final_color
def execute(self, context):
wmo_model_collection = get_current_wow_model_collection(bpy.context.scene, 'wow_wmo')
if wmo_model_collection:
doodad_counter = 0
groups = [obj for obj in get_wmo_groups_list(bpy.context.scene)]
for index, obj in enumerate(tqdm(bpy.context.selected_objects, desc='Baking doodad colors', ascii=True)):
if WoWWMODoodad.match(obj):
doodad_counter += 1
group = find_nearest_object(obj, groups)
if not group:
self.report({'ERROR'}, "No WMO group found.")
continue
group.data.color_attributes.get(('Col'))
if '0' not in group.wow_wmo_group.flags or not group.data.color_attributes.get('Col'):
self.report({'ERROR'}, "Nearest group object({}) of doodad {} does not have vertex colors.".format(group.name,obj))
continue
vertex_color = self.gen_doodad_color(obj, group, wmo_model_collection)
color = [x for x in vertex_color]
obj.wow_wmo_doodad.color = color
if doodad_counter:
self.report({'INFO'}, "Done baking colors to {} doodad instances.".format(doodad_counter))
else:
self.report({'ERROR'}, "No doodad instances found among selected objects.")
return {'FINISHED'}
def invoke(self, context, event):
return context.window_manager.invoke_confirm(self, event)
class WMO_OT_doodad_set_color(bpy.types.Operator):
bl_idname = 'scene.wow_doodad_set_color'
bl_label = 'Set Doodad Color'
bl_description = "Set color to selected doodads"
bl_options = {'REGISTER', 'UNDO'}
color: bpy.props.FloatVectorProperty(
name='Color',
description='Color applied to doodads',
subtype='COLOR',
size=4
)
def draw(self, context):
self.layout.column().prop(self, "color")
def execute(self, context):
if not bpy.context.selected_objects:
self.report({'ERROR'}, "No objects selected.")
return {'FINISHED'}
success = False
for obj in bpy.context.selected_objects:
if WoWWMODoodad.match(obj):
obj.wow_wmo_doodad.color = self.color
success = True
if success:
self.report({'INFO'}, "Successfully assigned color to selected doodads.")
else:
self.report({'ERROR'}, "No doodads found among selected objects.")
return {'FINISHED'}
class WMO_OT_doodadset_template_action(bpy.types.Operator):
bl_idname = 'scene.wow_doodad_set_template_action'
bl_label = 'Template action'
bl_description = 'Apply an action to all instances of selected object on the scene'
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}
action: bpy.props.EnumProperty(
items=[
('SELECT', "Select", "Rotate all instances of selected doodads", 'PMARKER_ACT', 0),
('REPLACE', "Replace", "Replace all instances of selected doodads with last M2 from WMV", 'FILE_REFRESH', 1),
('RESIZE', "Resize", "Resize all instances of selected doodads", 'FULLSCREEN_ENTER', 2),
('DELETE', "Delete", "Delete all instances of selected doodads", 'CANCEL', 3),
('ROTATE', "Rotate", "Rotate all instances of selected doodads", 'LOOP_FORWARDS', 4)],
default='SELECT'
)
scale: bpy.props.FloatProperty(
name="Scale",
description="Scale applied to doodads",
min=0.01,
max=20,
default=1
)
rotation: bpy.props.FloatVectorProperty(
name="Rotation",
default=(0, 0, 0, 0),
size=4
)
@classmethod
def poll(cls, context):
return True
def draw(self, context):
col = self.layout.column()
col.prop(self, "action", expand=True)
if self.action == 'RESIZE':
col.prop(self, "scale")
elif self.action == 'ROTATE':
col.prop(self, "rotation")
def execute(self, context):
target = None
active = bpy.context.view_layer.objects.active
if active and active.wow_wmo_doodad:
target = active.wow_wmo_doodad.path
else:
self.report({'ERROR'}, "Template functions require an active object.")
return {'CANCELLED'}
selected_only = False
if len(bpy.context.selected_objects) > 1:
selected_only = True
selected_objects = bpy.context.selected_objects.copy()
objects_to_select = []
success = False
if target:
new_obj = None
if self.action == 'REPLACE':
if not bpy.data.is_saved:
self.report({'ERROR'}, "Saved your blendfile first.")
return {'FINISHED'}
if not hasattr(bpy, "wow_game_data"):
self.report({'ERROR'}, "Connect to game data first.")
return {'FINISHED'}
bpy.ops.scene.wow_wmo_import_doodad_from_wmv()
new_obj = bpy.context.view_layer.objects.active
for obj in bpy.context.scene.objects:
is_selected = obj in selected_objects if selected_only else True
if obj.wow_wmo_doodad.path == target and is_selected:
if self.action == 'REPLACE':
location = obj.location
rotation = obj.rotation_quaternion
scale = obj.scale
parent = obj.parent
color = obj.wow_wmo_doodad.color
flags = obj.wow_wmo_doodad.flags
bpy.data.objects.remove(obj, do_unlink=True)
obj = new_obj.copy()
bpy.context.collection.objects.link(obj)
bpy.context.view_layer.objects.active = obj
obj.location = location
obj.rotation_mode = 'QUATERNION'
obj.rotation_quaternion = rotation
obj.scale = scale
obj.parent = parent
obj.wow_wmo_doodad.color = color
obj.wow_wmo_doodad.flags = flags
objects_to_select.append(obj)
elif self.action == 'RESIZE':
obj.scale *= self.scale
obj.select_set(True)
elif self.action == 'DELETE':
bpy.data.objects.remove(obj, do_unlink=True)
elif self.action == 'ROTATE':
obj.rotation_mode = 'QUATERNION'
for i, _ in enumerate(self.rotation):
obj.rotation_quaternion[i] += self.rotation[i]
elif self.action == 'SELECT':
obj.select_set(True)
success = True
for ob in objects_to_select:
ob.select_set(True)
if new_obj:
bpy.data.objects.remove(new_obj, do_unlink=True)
if success:
self.report({'INFO'}, "Template action applied.")
return {'FINISHED'}
else:
self.report({'ERROR'}, "No doodad is selected.")
return {'FINISHED'}
@@ -0,0 +1,32 @@
import bpy
from ...utils.fogs import create_fog_object
from ...ui.collections import get_wmo_collection, SpecialCollections
class WMO_OT_add_fog(bpy.types.Operator):
bl_idname = 'scene.wow_add_fog'
bl_label = 'Add fog'
bl_description = 'Add a WoW fog object to the scene'
def execute(self, context):
scn = context.scene
fog_collection = get_wmo_collection(scn, SpecialCollections.Fogs)
if not fog_collection:
self.report({'WARNING'}, "Can't add WMO Fog: No WMO Object Collection found in the scene.")
return {'FINISHED'}
fog_obj = create_fog_object()
# applying object properties
fog_obj.wow_wmo_fog.enabled = True
fog_collection.objects.link(fog_obj)
bpy.context.view_layer.objects.active = fog_obj
fog_obj.scale = (5.0, 5.0, 5.0) # default size to 5
fog_obj.wow_wmo_fog.color2 = (0.0, 0.0, 1.0) # set underwater color as blue
self.report({'INFO'}, "Successfully created WoW fog: " + fog_obj.name)
return {'FINISHED'}
@@ -0,0 +1,56 @@
import bpy
import traceback
from ....ui.preferences import get_project_preferences
from ...import_wmo import import_wmo_to_blender_scene_gamedata
from ...utils.wmv import wmv_get_last_wmo, wow_export_get_last_wmo, noggit_red_get_last_wmo
from ....utils.misc import load_game_data
class WMO_OT_import_last_wmo_from_wmv(bpy.types.Operator):
bl_idname = "scene.wow_import_last_wmo_from_wmv"
bl_label = "Load last WMO from preferred import method"
bl_description = "Load last WMO from preferred import method"
bl_options = {'UNDO', 'REGISTER'}
@classmethod
def poll(cls, context):
return True
def execute(self, context):
game_data = load_game_data()
if not game_data or not game_data.files:
self.report({'ERROR'}, "Failed to import model. Connect to game client first.")
return {'CANCELLED'}
project_preferences = get_project_preferences()
if project_preferences.import_method == 'WMV':
if project_preferences.wmv_path:
wmo_path = wmv_get_last_wmo()
elif project_preferences.import_method == 'WowExport':
if project_preferences.wow_export_path:
wmo_path = wow_export_get_last_wmo()
elif project_preferences.import_method == 'NoggitRed':
if project_preferences.noggit_red_path:
wmo_path = noggit_red_get_last_wmo()
if not wmo_path:
self.report({'ERROR'}, """Log contains no WMO entries.
Make sure to use compatible WMV version or WoW.Export and open a .wmo there.""")
return {'CANCELLED'}
try:
import_wmo_to_blender_scene_gamedata(wmo_path, bpy.context.scene.wow_scene.version)
except:
traceback.print_exc()
self.report({'ERROR'}, "Failed to import model.")
return {'CANCELLED'}
self.report({'INFO'}, "Done importing WMO object to scene.")
return {'FINISHED'}
def invoke(self, context, event):
return context.window_manager.invoke_confirm(self, event)
@@ -0,0 +1,40 @@
import bpy
from ...ui.collections import get_wmo_collection, SpecialCollections
class WMO_OT_add_light(bpy.types.Operator):
bl_idname = 'scene.wow_add_light'
bl_label = 'Add light'
bl_description = 'Add a WoW light object to the scene'
def execute(self, context):
scn = context.scene
light_collection = get_wmo_collection(scn, SpecialCollections.Lights)
if not light_collection:
self.report({'WARNING'}, "Can't add WMO Light: No WMO Object Collection found in the scene.")
return {'FINISHED'}
light = bpy.data.lights.new(name='WoW Light', type='POINT')
obj = bpy.data.objects.new('WoW Light', light)
light.color = (1.0, 0.565, 0.0)
light.energy = 1.0
obj.wow_wmo_light.enabled = True
obj.wow_wmo_light.use_attenuation = True
obj.wow_wmo_light.color = light.color # set yellow as default
obj.wow_wmo_light.color_alpha = 1.0
obj.wow_wmo_light.intensity = light.energy
# light.falloff_type = 'INVERSE_LINEAR'
# move lights to collection
light_collection.objects.link(obj)
bpy.context.view_layer.objects.active = obj
bpy.data.objects[obj.name].select_set(True)
obj.location = bpy.context.scene.cursor.location
self.report({'INFO'}, "Successfully created WoW light: " + obj.name)
return {'FINISHED'}
@@ -0,0 +1,810 @@
import bpy
import bmesh
import os
from math import cos, sin, tan, radians
from time import time
from mathutils import Vector
from mathutils.bvhtree import BVHTree
from bpy_extras import view3d_utils
from ....addon_common.cookiecutter.cookiecutter import CookieCutter
from ....addon_common.common import ui
from ....addon_common.common.utils import delay_exec
from ....addon_common.common.drawing import Drawing
from ....addon_common.common.boundvar import BoundInt, BoundFloat, BoundBool
from ....addon_common.common.ui_styling import load_defaultstylings
from ....addon_common.common.globals import Globals
from ..handlers import DepsgraphLock
from .. import handlers
from ...ui.collections import get_wmo_collection, SpecialCollections
def angled_vertex(origin: Vector, pos: Vector, angle: float, orientation: float) -> float:
return origin.z + ((pos.x - origin.x) * cos(orientation) + (pos.y - origin.y) * sin(orientation)) * tan(angle)
def get_median_point(bm: bmesh.types.BMesh) -> Vector:
selected_vertices = [v for v in bm.verts if v.select]
f = 1 / len(selected_vertices)
median = Vector((0, 0, 0))
for vert in selected_vertices:
median += vert.co * f
return median
def align_vertices(bm : bmesh.types.BMesh, mesh : bpy.types.Mesh, median : Vector, angle : float, orientation : float):
for vert in bm.verts:
if vert.select:
vert.co[2] = angled_vertex(median, vert.co, radians(angle), radians(orientation))
bmesh.update_edit_mesh(mesh, loop_triangles=True, destructive=True)
def reload_stylings():
load_defaultstylings()
path = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'ui', 'ui.css')
try:
Globals.ui_draw.load_stylesheet(path)
except AssertionError as e:
# TODO: show proper dialog to user here!!
print('could not load stylesheet "%s"' % path)
print(e)
Globals.ui_document.body.dirty('Reloaded stylings', children=True)
Globals.ui_document.body.dirty_styling()
Globals.ui_document.body.dirty_flow()
event_keymap = {
'ONE' : 0,
'TWO' : 1,
'THREE': 2,
'FOUR': 3,
'FIVE': 4,
'SIX': 5,
'SEVEN': 6,
'EIGHT': 7,
'NUMPAD_1': 0,
'NUMPAD_2': 1,
'NUMPAD_3': 2,
'NUMPAD_4': 3,
'NUMPAD_5': 4,
'NUMPAD_6': 5,
'NUMPAD_7': 6,
'NUMPAD_8': 7,
}
# some settings container
options = {}
options["variable_1"] = 10.0
options["variable_3"] = True
options["x_size"] = 10
options["y_size"] = 10
options["brush_size"] = 2
flags = {}
flags["flag_1"] = True
flags["flag_2"] = False
flags["flag_3"] = False
flag_checkboxes = []
class WMO_OT_edit_liquid(CookieCutter, bpy.types.Operator):
bl_idname = "wow.liquid_edit_mode"
bl_label = "Edit WoW Liquid"
default_keymap = {
'cancel': {'ESC', 'TAB'},
'grab': 'G',
'rotate': 'R',
'equalize': 'E',
'flag': 'F',
'paint': {'LEFTMOUSE', 'SHIFT+LEFTMOUSE'}
}
@property
def variable_2_gs(self):
return getattr(self, '_var_cut_count_value', 0)
@variable_2_gs.setter
def variable_2_gs(self, v):
if self.variable_2 == v: return
self.variable_2 = v
# if self.variable_2.disabled: return
def start(self):
self.init_loc = 0.0
self.speed_modifier = 1.0
self.orientation = 0.0
self.angle = 0.0
self.median = Vector((0, 0, 0))
self.color_type = 'TEXTURE'
self.shading_type = 'SOLID'
self.selected_verts = {}
self.viewports = []
self.init_time = time()
self.obj = self.context.object
self.mesh = self.context.object.data
self.active_tool = 'select'
DepsgraphLock().push()
bpy.ops.mesh.select_mode(bpy.context.copy(), type='VERT', action='ENABLE', use_extend=True)
bpy.ops.mesh.select_mode(bpy.context.copy(), type='EDGE', action='ENABLE', use_extend=True)
bpy.ops.mesh.select_mode(bpy.context.copy(), type='FACE', action='ENABLE', use_extend=True)
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.wm.tool_set_by_id(bpy.context.copy(), name="builtin.select_box") # force a benign select tool
# create a bmesh to operate on
self.bm = bmesh.from_edit_mesh(self.context.object.data)
self.bm.verts.ensure_lookup_table()
# create BVH tree for ray_casting
self.bvh_tree = BVHTree.FromBMesh(self.bm)
# store viewports
self.viewports = [a for a in self.context.screen.areas if a.type == 'VIEW_3D']
for viewport in self.viewports:
self.color_type = viewport.spaces[0].shading.color_type
self.shading_type = viewport.spaces[0].shading.type
viewport.spaces[0].shading.type = 'SOLID'
viewport.spaces[0].shading.color_type = 'VERTEX'
# setup UI variables
self.tools = {
"select": ("Select (TODO : Select tool)", "select.png", "Select liquid area"),
"grab": ("Raise / Lower (G)", "raise_lower.png", "Raise \ Lower liquid surface"),
"rotate": ("Rotate (R)", "rotate.png", "Rotate liquid area"),
"equalize": ("Equalize (E)", "equalize.png", "Equalize liquid level"),
"flag": ("Edit flags (F)", "flags.png", "Mark flags on the liquid grid. Hold Shift to unmark"),
}
self.variable_1 = BoundFloat('''options['variable_1']''', min_value=0.5, max_value=15.5)
self.variable_2 = BoundInt('''self.variable_2_gs''', min_value=0, max_value=10)
self.variable_3 = BoundBool('''options['variable_3']''')
self.x_size = BoundInt('''options['x_size']''', min_value=1, max_value=100000) # uint32, is there a limit ?
self.y_size = BoundInt('''options['y_size']''', min_value=1, max_value=100000)
self.brush_size = BoundInt('''options['brush_size']''', min_value=1, max_value=4)
self.flag_1 = BoundBool('''flags['flag_1']''')
self.flag_2 = BoundBool('''flags['flag_2']''')
self.flag_3 = BoundBool('''flags['flag_3']''')
self.blender_ui_set()
self.setup_ui()
def blender_ui_set(self):
self.viewaa_simplify()
self.manipulator_hide()
self._space.show_gizmo = True
self.panels_hide()
#self.region_darken()
def update_ui(self):
self.ui_main.dirty('update', parent=True, children=True)
def select_tool(self, action):
tool_id = "tool-{}".format(action)
e = self.document.body.getElementById('tool-{}'.format(tool_id))
if e: e.checked = True
self.active_tool = action
self.update_ui()
def setup_ui(self):
reload_stylings()
self.ui_main = ui.framed_dialog(label='Liquid Editor',
resizable=None,
resizable_x=True,
resizable_y=False,
closeable=False,
moveable=True,
hide_on_close=True,
parent=self.document.body)
# tools
ui_tools = ui.div(id="tools", parent=self.ui_main)
def add_tool(action="", name="", icon="", title=""):
nonlocal ui_tools
nonlocal self
# must be a fn so that local vars are unique and correctly captured
lbl, img = name, icon
radio = ui.input_radio(id='tool-{}'.format(action), value=lbl.lower(), title=title, name="tool",
classes="tool", checked=False, parent=ui_tools)
radio.add_eventListener('on_input', delay_exec('''if radio.checked: self.select_tool("{}")'''.format(action)))
ui.img(src=img, parent=radio, title=title)
ui.label(innerText=lbl, parent=radio, title=title)
for key, value in self.tools.items(): add_tool(action=key, name=value[0], icon=value[1], title=value[2])
# ui.button(label='ui.button', title='self.tool_action() method linked to button', parent=ui_tools,
# on_mouseclick=self.tool_action)
# create a collapsille container to hold a few variables
# container = ui.collapsible('ui.collapse container', parent=self.ui_main)
#
# i1 = ui.labeled_input_text(label='Sui.labeled_input_text',
# title='float property to BoundFLoat',
# value=self.variable_1)
#
# i2 = ui.labeled_input_text(label='ui.labled_input_text',
# title='integer property to BoundInt',
# value=self.variable_2)
#
# i3 = ui.input_checkbox(
# label='ui.input_checkbox',
# title='True/False property to BoundBool')
#
# container.builder([i1, i2, i3])
size_container = ui.collapsible('Liquid Size', parent=self.ui_main, collapsed=False)
j1 = ui.labeled_input_text(label='X subdivisions',
title='Amount of WoW liquid planes in a row. One plane is 4.1666625 in its radius.',
value=self.x_size)
j2 = ui.labeled_input_text(label='Y subdivisions',
title='Amount of WoW liquid planes in a row. One plane is 4.1666625 in its radius.',
value=self.y_size)
# j3 = ui.button(label='Apply size', on_mouseclick=self.set_grid_size)
j3 = ui.button(label='Apply size ( Not implemented yet)')
size_container.builder([j1, j2, j3])
self.get_grid_size()
flags_brush_size = ui.labeled_input_text(label='Flags brush size', parent=ui_tools,
title='Size of the brush used to paint flags, from 1 to 4. Also press K/shift+K to set/unset all the liquid.',
value=self.brush_size)
chk_flag_1 = ui.input_checkbox(
label='No Render flag', parent=ui_tools,
title='Set "No Render" as the flag to set/unset',
on_mouseclick=lambda: self.set_editable_flag(1),
value = self.flag_1)
chk_flag_2 = ui.input_checkbox(
label='Fishing flag', parent=ui_tools,
title='Set "Fishing" as the flag to set/unset',
on_mouseclick=lambda: self.set_editable_flag(2),
value = self.flag_2)
chk_flag_3 = ui.input_checkbox(
label='Fatigue Flag', parent=ui_tools,
title='Set "Fatigue" as the flag to set/unset',
on_mouseclick=lambda: self.set_editable_flag(3),
value = self.flag_3)
self.flag_checkboxes = [chk_flag_1, chk_flag_2, chk_flag_3]
chk_flag_1.checked = True
self.mesh.vertex_colors.get("flag_0").active = True
ui.button(label='Sculpt liquid', title="Sculpt the Liquid mesh, locked in Z edit.", parent=ui_tools,
on_mouseclick=self.activate_sculpt_mode)
def set_editable_flag(self, flag): # TODO : come up with a better solution
if flag == 1:
self.mesh.vertex_colors.get("flag_0").active = True
if self.flag_checkboxes[0].checked:
return
else:
self.flag_checkboxes[1].checked = False
self.flag_checkboxes[2].checked = False
elif flag == 2:
self.mesh.vertex_colors.get("flag_6").active = True
if self.flag_checkboxes[1].checked:
# self.flag_checkboxes[1].checked = True
return
else:
# self.flag_2.set(False)
self.flag_checkboxes[0].checked = False
# self.flag_3.set(False)
self.flag_checkboxes[2].checked = False
elif flag == 3:
self.mesh.vertex_colors.get("flag_7").active = True
if self.flag_checkboxes[2].checked:
# self.flag_checkboxes[2].checked = True
return
else:
self.flag_checkboxes[0].checked = False
self.flag_checkboxes[1].checked = False
def get_grid_size(self):
x_tiles = round(self.context.object.dimensions[0] / 4.1666625)
y_tiles = round(self.context.object.dimensions[1] / 4.1666625)
self.x_size.set(x_tiles)
self.y_size.set(y_tiles)
def set_grid_size(self):
start_vertex = 0
sum = 0
for vertex in self.context.object.data.vertices:
cur_sum = vertex.co[0] + vertex.co[1]
if cur_sum < sum:
start_vertex = vertex.index
sum = cur_sum
old_x_tiles = round(self.context.object.dimensions[0] / 4.1666625)
old_y_tiles = round(self.context.object.dimensions[1] / 4.1666625)
old_x_verts = old_x_tiles + 1
old_y_verts = old_y_tiles + 1
position = self.context.object.data.vertices[start_vertex].co.to_tuple()
if old_x_tiles == self.x_size.get() and old_y_tiles == self.y_size.get():
return
# create each missing vert (for in newmax tile - cur tile)
x_verts = self.x_size.get()+1
y_verts = self.y_size.get()+1
vertices = []
for y in range(old_y_verts, y_verts):
y_pos = position[1] + y * 4.1666625
for x in range(old_x_verts, x_verts):
x_pos = position[1] + x * 4.1666625
vertices.append((x_pos, y_pos, position[2]))
# calculate faces
indices = []
for y in range(self.y_size.get()):
for x in range(self.x_size.get()):
indices.append(y * x_verts + x)
indices.append(y * x_verts + x + 1)
indices.append((y + 1) * y_verts + x)
indices.append((y + 1) * y_verts + x + 1)
faces = []
for i in range(0, len(indices), 4):
faces.append((indices[i], indices[i + 1], indices[i + 3], indices[i + 2]))
bpy.ops.object.mode_set(mode='OBJECT')
print(len(vertices))
print(len(faces))
name = "_Liquid"
mesh = bpy.data.meshes.new(name)
# obj = bpy.data.objects.new(name, mesh)
#
# # create mesh from python data
mesh.from_pydata(vertices, [], faces)
mesh.update(calc_edges=True)
mesh.validate()
# self.bm.verts
bm = bmesh.new()
bm.from_mesh(mesh)
bpy.ops.object.mode_set(mode='EDIT')
bmesh.update_edit_mesh(self.mesh, loop_triangles=True, destructive=True)
def should_pass_through(self, context, event):
# allow selection events to pass through
return True if event.type in {'A', 'B', 'C'} else False
def tool_action(self):
print('tool action')
return
def activate_sculpt_mode(self):
bpy.ops.object.mode_set(mode='SCULPT')
for viewport in self.viewports:
viewport.spaces[0].shading.type = self.shading_type
viewport.spaces[0].shading.color_type = self.color_type
self.done(cancel=False)
return
def activate_tool(self, name):
self.active_tool = name
e = self.document.body.getElementById('tool-{}'.format(self.active_tool))
if e: e.checked = True
def update_bmesh(self):
self.bm = bmesh.from_edit_mesh(self.context.object.data)
self.bm.verts.ensure_lookup_table()
def select_all_verts(self):
# for v in self.mesh:
# v.select = True
bpy.ops.mesh.select_all(action='SELECT')
def unselect_all_verts(self):
bpy.ops.mesh.select_all(action='DESELECT')
@CookieCutter.FSM_State('main', 'enter')
def enter_main(self):
self.update_bmesh()
@CookieCutter.FSM_State('main')
def modal_main(self):
self.context.area.tag_redraw()
Drawing.set_cursor('DEFAULT')
if self.actions.pressed('grab') or self.active_tool == 'grab':
bpy.ops.mesh.select_all(action='SELECT')
self.activate_tool('grab')
Drawing.set_cursor('MOVE_X')
self.init_loc = self.event.mouse_x
self.selected_verts = {vert: vert.co[2] for vert in self.bm.verts if vert.select}
return 'grab'
elif self.actions.pressed('rotate') or self.active_tool == 'rotate':
self.activate_tool('rotate')
self.report({'INFO'}, "Rotating vertices. Shift + Scroll - tilt | Alt + Scroll - rotate")
bpy.ops.mesh.select_all(action='SELECT')
self.selected_verts = {vert: vert.co[2] for vert in self.bm.verts if vert.select}
self.median = get_median_point(self.bm)
self.orientation = 0.0
self.angle = 0.0
return 'rotate'
elif self.actions.pressed('equalize') or self.active_tool == 'equalize':
bpy.ops.mesh.select_all(action='SELECT')
self.activate_tool('equalize')
return 'equalize'
elif self.actions.pressed('flag') or self.active_tool == 'flag':
bpy.ops.mesh.select_all(action='DESELECT')
self.activate_tool('flag')
return 'flag'
elif self.actions.pressed('cancel') and (time() - self.init_time) > 0.5:
bpy.ops.object.mode_set(mode='OBJECT')
for viewport in self.viewports:
viewport.spaces[0].shading.type = self.shading_type
viewport.spaces[0].shading.color_type = self.color_type
DepsgraphLock().pop()
self.done(cancel=False)
return 'finished'
else:
self.activate_tool('select')
# return 'select'
@CookieCutter.FSM_State('select')
def modal_select(self):
if self.active_tool != 'select':
return 'main'
if self.actions.pressed('cancel') and (time() - self.init_time) > 0.5:
bpy.ops.object.mode_set(mode='OBJECT')
for viewport in self.viewports:
viewport.spaces[0].shading.type = self.shading_type
viewport.spaces[0].shading.color_type = self.color_type
DepsgraphLock().DEPSGRAPH_UPDATE_LOCK = False
self.done(cancel=False)
return 'finished'
@CookieCutter.FSM_State('grab')
def modal_grab(self):
if self.active_tool != 'grab':
return 'main'
else:
self.activate_tool('grab')
# alter vertex height
if self.actions.mousemove:
fac = 10 if self.actions.shift else 30
for vert, height in self.selected_verts.items():
vert.co[2] = height + (self.event.mouse_x - self.init_loc) / fac
bmesh.update_edit_mesh(self.mesh, loop_triangles=True, destructive=True)
# accept
if self.actions.event_type == 'LEFTMOUSE':
self.active_tool = 'select'
return 'main'
# cancel
elif self.actions.event_type == 'RIGHTMOUSE':
for vert, height in self.selected_verts.items():
vert.co[2] = height
bmesh.update_edit_mesh(self.mesh, loop_triangles=True, destructive=True)
self.active_tool = 'select'
return 'main'
# switch state
for action in self.default_keymap.keys():
if self.actions.pressed(action):
self.update_bmesh()
self.activate_tool(action)
return action
return 'grab'
@CookieCutter.FSM_State('rotate')
def modal_rotate(self):
if self.active_tool != 'rotate':
return 'main'
Drawing.set_cursor('SCROLL_Y')
if self.actions.event_type == 'WHEELUPMOUSE':
if self.actions.shift:
self.angle = min(self.angle + 5, 89.9)
align_vertices(self.bm, self.context.object.data, self.median, self.angle, self.orientation)
elif self.actions.alt:
self.orientation += 10
if self.orientation > 360:
self.orientation -= 360
align_vertices(self.bm, self.context.object.data, self.median, self.angle, self.orientation)
elif self.actions.event_type == 'WHEELDOWNMOUSE':
if self.actions.shift:
self.angle = max(self.angle - 5, -89.9)
align_vertices(self.bm, self.context.object.data, self.median, self.angle, self.orientation)
elif self.actions.alt:
self.orientation -= 10
if self.orientation < 0:
self.orientation = 360 - self.orientation
align_vertices(self.bm, self.context.object.data, self.median, self.angle, self.orientation)
# accept
if self.actions.event_type == 'LEFTMOUSE':
self.active_tool = 'select'
return 'main'
# cancel
elif self.actions.event_type == 'RIGHTMOUSE':
for vert, height in self.selected_verts.items():
vert.co[2] = height
bmesh.update_edit_mesh(self.mesh, loop_triangles=True, destructive=True)
self.active_tool = 'select'
return 'main'
# switch state
for action in self.default_keymap.keys():
if self.actions.pressed(action):
self.update_bmesh()
self.activate_tool(action)
return action
return 'rotate'
@CookieCutter.FSM_State('equalize')
def equalize(self):
median = get_median_point(self.bm)
for vert in self.bm.verts:
if vert.select:
vert.co[2] = median[2]
bmesh.update_edit_mesh(self.mesh, loop_triangles=True, destructive=True)
self.report({'INFO'}, "Equalized vertex height")
self.active_tool = 'select'
return 'main'
@CookieCutter.FSM_State('flag')
def modal_flag(self):
if self.active_tool != 'flag':
return 'main'
Drawing.set_cursor('PAINT_BRUSH')
# if self.actions.event_type in event_keymap.keys():
# flag_number = event_keymap.get(self.actions.event_type, 0)
# layer = self.mesh.vertex_colors.get("flag_{}".format(flag_number))
# layer.active = True
layer = self.bm.loops.layers.color.active
color = (0, 0, 255, 255) if not self.actions.shift else (255, 255, 255, 255)
if self.actions.event_type == 'K':
bpy.ops.mesh.select_all(action='SELECT') # Remove when selecting tool is fixed
for face in self.bm.faces:
if face.select:
for loop in face.loops:
loop[layer] = color
bmesh.update_edit_mesh(self.mesh, loop_triangles=True, destructive=True)
bpy.ops.mesh.select_all(action='DESELECT')
self.report({'INFO'}, "Flag unset" if self.actions.shift else "Flag set")
if not self.actions.released('paint'):
# TODO: radius brush
# get the context arguments
region = self.context.region
rv3d = self.context.region_data
coord = self.event.mouse_region_x, self.event.mouse_region_y
# get the ray from the viewport and mouse
view_vector = view3d_utils.region_2d_to_vector_3d(region, rv3d, coord)
ray_origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coord)
ray_target = ray_origin + view_vector
ray_origin_obj = self.obj.matrix_world.inverted() @ ray_origin
ray_target_obj = self.obj.matrix_world.inverted() @ ray_target
ray_direction_obj = ray_target_obj - ray_origin_obj
# cast the ray
location, normal, face_index, distance = self.bvh_tree.ray_cast(ray_origin_obj, ray_direction_obj)
if face_index is not None:
color = (0, 0, 255, 255) if not self.actions.shift else (255, 255, 255, 255)
face = self.bm.faces[face_index]
############
# This is extremly innefficient, capped the variable at 3 or it lags too much.
# TODO : find a betetr way
faces_list = []
faces_list.append(face)
size_count = 1
while size_count < self.brush_size.get():
curr_faces = faces_list.copy()
for face in faces_list:
for vert in face.verts:
linked_faces = vert.link_faces
for linked_face in linked_faces:
curr_faces.append(linked_face)
size_count += 1
for curr_face in curr_faces:
faces_list.append(curr_face)
for face in faces_list:
for loop in face.loops:
loop[layer] = color
##############
# for loop in face.loops:
# loop[layer] = color
bmesh.update_edit_mesh(self.mesh, loop_triangles=True, destructive=True)
if self.actions.event_type == 'RIGHTMOUSE':
self.active_tool = 'select'
return 'main'
else:
return 'flag'
class WMO_OT_add_liquid(bpy.types.Operator):
bl_idname = 'scene.wow_add_liquid'
bl_label = 'Add liquid'
bl_description = 'Add a WoW liquid plane'
bl_options = {'REGISTER', 'UNDO'}
x_planes: bpy.props.IntProperty(
name="X subdivisions:",
description="Amount of WoW liquid planes in a row. One plane is 4.1666625 in its radius.",
default=10,
min=1
)
y_planes: bpy.props.IntProperty(
name="Y subdivisions:",
description="Amount of WoW liquid planes in a column. One plane is 4.1666625 in its radius.",
default=10,
min=1
)
def execute(self, context):
liquid_collection = get_wmo_collection(context.scene, SpecialCollections.Liquids)
if not liquid_collection:
self.report({'WARNING'}, "Can't add WMO Liquid: No WMO Object Collection found in the scene.")
return {'FINISHED'}
bpy.ops.mesh.primitive_grid_add(x_subdivisions=self.x_planes,
y_subdivisions=self.y_planes,
size=4.1666625
)
water = bpy.context.view_layer.objects.active
bpy.ops.transform.resize(value=(self.x_planes, self.y_planes, 1.0))
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
water.name += "_Liquid"
mesh = water.data
water.wow_wmo_liquid.enabled = True
# move to collection
liquid_collection.objects.link(water)
bit = 1
counter = 0
while bit <= 0x80:
vc_layer = mesh.vertex_colors.new(name="flag_{}".format(counter))
# set flag 7 which is very likely related to swimming and not fishing (it's enabled in most liquids, even lava)
if bit == 0x40:
for poly in mesh.polygons:
for loop in poly.loop_indices:
vc_layer.data[loop].color = (0, 0, 255, 255)
counter += 1
bit <<= 1
water.hide_set(False if "4" in bpy.context.scene.wow_visibility else True)
self.report({'INFO'}, "Successfully created WoW liquid: {}".format(water.name))
return {'FINISHED'}
@@ -0,0 +1,234 @@
import bpy
import bmesh
import os
from ...bl_render import load_wmo_shader_dependencies, update_wmo_mat_node_tree
from ...utils.wmv import wmv_get_last_texture, wow_export_get_last_texture
from ....utils.misc import resolve_texture_path, resolve_outside_texture_path, load_game_data
from ...utils.materials import load_texture
from ....ui.preferences import get_project_preferences
from ...ui.handlers import DepsgraphLock
from ..custom_objects import WoWWMOGroup
class WMO_OT_generate_materials(bpy.types.Operator):
bl_idname = "scene.wow_wmo_generate_materials"
bl_label = "Generate WMO Materials"
bl_description = "Generate WMO materials."
bl_options = {'UNDO', 'REGISTER'}
@classmethod
def poll(cls, context):
return context.scene.wow_scene.type == 'WMO'
def execute(self, context):
load_wmo_shader_dependencies()
materials = []
if context.selected_objects:
for obj in context.selected_objects:
if not WoWWMOGroup.match(obj):
continue
materials.extend(obj.data.materials)
for mat in materials:
tex = None
if mat.use_nodes:
for node in mat.node_tree.nodes:
if node.bl_idname == 'ShaderNodeTexImage':
tex = node.image
break
update_wmo_mat_node_tree(mat)
with DepsgraphLock():
# if context.scene.wow_wmo_root_elements.materials.find(mat.name) < 0:
if bpy.data.materials.find(mat.name) < 0:
mat.wow_wmo_material.diff_texture_1 = tex
return {'FINISHED'}
class WMO_OT_material_assign(bpy.types.Operator):
bl_idname = "object.wow_wmo_material_assign"
bl_label = "Assign WMO Material"
bl_description = "Assign WMO material to selected faces."
bl_options = {'UNDO', 'REGISTER', 'INTERNAL'}
def execute(self, context):
mesh = context.view_layer.objects.active.data
bm = bmesh.from_edit_mesh(mesh)
# mat = context.scene.wow_wmo_root_elements.materials[context.scene.wow_wmo_root_elements.cur_material]
# TODO : cur material in new system?
mat = bpy.data.materials[cur_material]
# if not mat.pointer:
# self.report({'ERROR'}, "Cannot assign an empty material")
# return {'CANCELLED'}
mat_index = mesh.materials.find(mat.name)
if mat_index < 0:
mat_index = len(mesh.materials)
mesh.materials.append(mat)
for face in bm.faces:
if not face.select:
continue
face.material_index = mat_index
bmesh.update_edit_mesh(mesh)
return {'FINISHED'}
class WMO_OT_material_select(bpy.types.Operator):
bl_idname = "object.wow_wmo_material_select"
bl_label = "Select WMO Material"
bl_description = "Select WMO material to selected faces."
bl_options = {'UNDO', 'REGISTER', 'INTERNAL'}
def execute(self, context):
mesh = context.view_layer.objects.active.data
bm = bmesh.from_edit_mesh(mesh)
#mat = context.scene.wow_wmo_root_elements.materials[context.scene.wow_wmo_root_elements.cur_material]
# TODO : cur material in new system
mat = bpy.data.materials[cur_material]
# if not mat.pointer:
# self.report({'ERROR'}, "Cannot select an empty material")
# return {'CANCELLED'}
mat_index = mesh.materials.find(mat.name)
for face in bm.faces:
if face.material_index == mat_index:
face.select = True
bmesh.update_edit_mesh(mesh)
return {'FINISHED'}
class WMO_OT_material_deselect(bpy.types.Operator):
bl_idname = "object.wow_wmo_material_deselect"
bl_label = "Deselect WMO Material"
bl_description = "Deselect WMO material to selected faces."
bl_options = {'UNDO', 'REGISTER', 'INTERNAL'}
def execute(self, context):
mesh = context.view_layer.objects.active.data
bm = bmesh.from_edit_mesh(mesh)
# mat = context.scene.wow_wmo_root_elements.materials[context.scene.wow_wmo_root_elements.cur_material]
# TODO
mat = bpy.data.materials[cur_material]
# if not mat.pointer:
# self.report({'ERROR'}, "Cannot deselect an empty material")
# return {'CANCELLED'}
mat_index = mesh.materials.find(mat.name)
for face in bm.faces:
if face.material_index == mat_index:
face.select = False
bmesh.update_edit_mesh(mesh)
return {'FINISHED'}
class WMO_OT_fill_textures(bpy.types.Operator):
bl_idname = 'scene.wow_fill_textures'
bl_label = 'Fill textures'
bl_description = "Fill Texture field of WoW materials with paths from applied image"
bl_options = {'REGISTER'}
def execute(self, context):
for ob in filter(lambda o: WoWWMOGroup.match(o), bpy.context.selected_objects):
mesh = ob.data
for material in mesh.materials:
if not WoWWMOGroup.match(ob) :
continue
texture1 = material.wow_wmo_material.diff_texture_1
texture2 = material.wow_wmo_material.diff_texture_2
if not texture1 or texture1.type != 'IMAGE':
continue
t1_resolved_path = resolve_texture_path(texture1.filepath)
if t1_resolved_path is None:
t1_resolved_path = resolve_outside_texture_path(texture1.filepath)
texture1.wow_wmo_texture.path = t1_resolved_path
if not texture2 or texture2.type != 'IMAGE':
continue
if texture2 is not None:
t2_resolved_path = resolve_texture_path(texture2.filepath)
if t2_resolved_path is None:
t2_resolved_path = resolve_outside_texture_path(texture2.filepath)
texture2.wow_wmo_texture.path = t2_resolved_path
self.report({'INFO'}, "Done filling texture paths")
return {'FINISHED'}
class WMO_OT_import_texture(bpy.types.Operator):
bl_idname = "scene.wow_wmo_texture_import"
bl_label = "Import WoW Texture"
bl_description = "Import last texture from WoW Model Viewer as a WMO material.\n(Browse WMV in 'Images' mode, modeling textures are usualy in 'Dungeons' directory)"
bl_options = {'UNDO', 'REGISTER'}
def execute(self, context):
project_preferences = get_project_preferences()
game_data = load_game_data()
if not game_data:
self.report({'ERROR'}, "Importing texture failed. Game data was not loaded.")
return {'CANCELLED'}
if project_preferences.import_method == 'WMV':
path = wmv_get_last_texture().capitalize()
elif project_preferences.import_method == 'WowExport' or 'NoggitRed':
path = wow_export_get_last_texture().capitalize()
if not path:
self.report({'ERROR'}, "WMV log does not contain any texture paths.")
return {'CANCELLED'}
game_data.extract_textures_as_png(project_preferences.cache_dir_path, (path,))
texture = load_texture({}, path, project_preferences.cache_dir_path)
mat = bpy.data.materials.new(name="T1_" + os.path.basename(path).replace('.blp', ''))
mat.wow_wmo_material.diff_texture_1 = texture
mat.wow_wmo_material.diff_color = (0.584314,0.584314,0.584314,1)
mat.wow_wmo_material.emissive_color = (0,0,0,1)
load_wmo_shader_dependencies()
update_wmo_mat_node_tree(mat)
print("Info: Successfully imported texture: " + mat.name)
bpy.ops.wbs.viewport_text_display('INVOKE_DEFAULT', message="Info: Successfully imported texture: " + mat.name, font_size=24, y_offset=67)
return {'FINISHED'}
@@ -0,0 +1,470 @@
import hashlib
import math
import random
import bpy
import io
import os
import bmesh
from ....utils.collections import get_current_wow_model_collection
from .... import PACKAGE_NAME
from ....utils.misc import load_game_data
from ....pywowlib.blp import PNG2BLP
# from ....pywowlib.io_utils.types import *
from ...ui.custom_objects import *
from ..collections import get_wmo_collection, SpecialCollections, get_wmo_groups_list
from ....ui.preferences import get_project_preferences
from ....third_party.tqdm import tqdm
class WMO_OT_add_scale(bpy.types.Operator):
bl_idname = 'scene.wow_add_scale_reference'
bl_label = 'Add scale'
bl_description = 'Add a WoW scale prop'
bl_options = {'REGISTER', 'UNDO'}
scale_type: bpy.props.EnumProperty(
name="Scale Type",
description="Select scale reference type",
items=[('HUMAN', "Human Scale (average)", ""),
('TAUREN', "Tauren Scale (thickest)", ""),
('TROLL', "Troll Scale (tallest)", ""),
('GNOME', "Gnome Scale (smallest)", "")
],
default='HUMAN'
)
def execute(self, context):
if self.scale_type == 'HUMAN':
bpy.ops.object.add(type='LATTICE')
scale_obj = bpy.context.object
scale_obj.name = "Human Scale"
scale_obj.dimensions = (0.582, 0.892, 1.989)
elif self.scale_type == 'TAUREN':
bpy.ops.object.add(type='LATTICE')
scale_obj = bpy.context.object
scale_obj.name = "Tauren Scale"
scale_obj.dimensions = (1.663, 1.539, 2.246)
elif self.scale_type == 'TROLL':
bpy.ops.object.add(type='LATTICE')
scale_obj = bpy.context.object
scale_obj.name = "Troll Scale"
scale_obj.dimensions = (1.116, 1.291, 2.367)
elif self.scale_type == 'GNOME':
bpy.ops.object.add(type='LATTICE')
scale_obj = bpy.context.object
scale_obj.name = "Gnome Scale"
scale_obj.dimensions = (0.362, 0.758, 0.991)
self.report({'INFO'}, "Successfully added " + self.scale_type + " scale")
return {'FINISHED'}
class WMO_OT_quick_collision(bpy.types.Operator):
bl_idname = 'scene.wow_quick_collision'
bl_label = 'Generate collision'
bl_description = 'Generate WoW collision equal to geometry of the selected objects'
bl_options = {'REGISTER', 'UNDO'}
leaf_size: bpy.props.IntProperty(
name="Node max size",
description="Max count of faces for a node in bsp tree on export. 0 = Dynamic based on face count",
default=0, min=0,
soft_max=1000
)
clean_up: bpy.props.BoolProperty(
name="Clean up",
description="Remove unreferenced vertex groups",
default=False
)
def execute(self, context):
success = False
selected_objects = bpy.context.selected_objects[:]
bpy.ops.object.select_all(action='DESELECT')
for ob in tqdm(selected_objects, desc='Generating collision', ascii=True):
if WoWWMOGroup.match(ob):
bpy.context.view_layer.objects.active = ob
if self.clean_up:
for vertex_group in ob.vertex_groups:
if vertex_group.name != ob.wow_wmo_vertex_info.vertex_group:
ob.vertex_groups.remove(vertex_group)
if ob.vertex_groups.get(ob.wow_wmo_vertex_info.vertex_group):
bpy.ops.object.vertex_group_set_active(group=ob.wow_wmo_vertex_info.vertex_group)
else:
new_vertex_group = ob.vertex_groups.new(name="Collision")
bpy.ops.object.vertex_group_set_active(group=new_vertex_group.name)
ob.wow_wmo_vertex_info.vertex_group = new_vertex_group.name
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.object.vertex_group_assign()
bpy.ops.mesh.select_all(action='DESELECT')
bpy.ops.object.mode_set(mode='OBJECT')
ob.wow_wmo_vertex_info.node_size = self.leaf_size
success = True
if success:
self.report({'INFO'}, "Successfully generated automatic collision for selected WMO groups")
return {'FINISHED'}
else:
self.report({'ERROR'}, "No WMO group objects found among selected objects")
return {'CANCELLED'}
class WMO_OT_select_entity(bpy.types.Operator):
bl_idname = 'scene.wow_wmo_select_entity'
bl_label = 'Select WMO entities'
bl_description = 'Select all WMO entities of given type'
bl_options = {'REGISTER', 'INTERNAL'}
entity: bpy.props.EnumProperty(
name="Entity",
description="Select WMO component entity objects",
items=[
("Outdoor", "Outdoor", ""),
("Indoor", "Indoor", ""),
("wow_wmo_portal", "Portals", ""),
("wow_wmo_liquid", "Liquids", ""),
("wow_wmo_fog", "Fogs", ""),
("wow_wmo_light", "Lights", ""),
("wow_wmo_doodad", "Doodads", ""),
("Collision", "Collision", "")
]
)
def execute(self, context):
# can optimise by selecting by collection
scene = bpy.context.scene
if self.entity == "Outdoor":
for obj in get_wmo_collection(scene, SpecialCollections.Outdoor).objects:
obj.select_set(True)
elif self.entity == "Indoor":
for obj in get_wmo_collection(scene, SpecialCollections.Indoor).objects:
obj.select_set(True)
elif self.entity == "wow_wmo_portal":
for obj in get_wmo_collection(scene, SpecialCollections.Portals).objects:
obj.select_set(True)
elif self.entity == "wow_wmo_liquid":
for obj in get_wmo_collection(scene, SpecialCollections.Liquids).objects:
obj.select_set(True)
elif self.entity == "wow_wmo_fog":
for obj in get_wmo_collection(scene, SpecialCollections.Fogs).objects:
obj.select_set(True)
elif self.entity == "wow_wmo_light":
for obj in get_wmo_collection(scene, SpecialCollections.Lights).objects:
obj.select_set(True)
elif self.entity == "wow_wmo_doodad":
for obj in get_wmo_collection(scene, SpecialCollections.Doodads).all_objects:
if not obj.hide_get():
obj.select_set(True)
elif self.entity == "Collision":
for obj in get_wmo_collection(scene, SpecialCollections.Collision).objects:
obj.select_set(True)
return {'FINISHED'}
class WMO_OT_generate_minimaps(bpy.types.Operator):
bl_idname = 'scene.wow_wmo_generate_minimaps'
bl_label = 'Generate Minimaps'
bl_description = 'Generate a wow minimap for WMO indoor groups(To the project folder)'
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return context.scene.wow_scene.type == 'WMO'
def parse_existing_blp_strings(self, data):
existing_blp_strings = set()
lines = data.split(b'\r\n')
for line in lines:
if not line.startswith(b"dir:"):
try:
blp_string = line.split(b'\t')[-1].strip()
existing_blp_strings.add(blp_string.decode('utf-8'))
except Exception as e:
print(f"Error parsing line: {line} - {str(e)}")
return existing_blp_strings
def generate_random_blp_string(self, existing_blp_strings):
while True:
random_string = hashlib.md5(str(random.random()).encode()).hexdigest() + ".blp"
if random_string not in existing_blp_strings:
return random_string
def execute(self, context):
wow_model_collection = get_current_wow_model_collection(bpy.context.scene, 'wow_wmo')
if not wow_model_collection.wow_wmo.dir_path:
raise Exception("Game path is empty. You must set the model's client path in |Collection properties->Directory Path| and name your collection with your wmo name to use this feature.\n(Example : World\wmo\Dungeon\AZ_Deadmines for the path and AZ_Deadmines_A for the collection name")
wmo_path = str(wow_model_collection.wow_wmo.dir_path + '\\' + wow_model_collection.name)
md5_path = os.path.relpath(wmo_path.split('.')[0].lower(), 'world')
md5_entries = []
game_data = load_game_data()
try:
file, _ = game_data.read_file("textures\\Minimap\\md5translate.trs")
md5_file = io.BytesIO(file)
except KeyError:
raise FileNotFoundError("\nMD5 File <<{}>> not found in WoW file system.".format("textures\\Minimap\\md5translate.trs"))
md5_file = md5_file.read()
existing_strings = self.parse_existing_blp_strings(file)
###########
# TODOs:
# BLP conversion?
def create_camera_object():
# Return if a camera exists.
if bpy.data.cameras.find('MinimapsCamera') == -1:
bpy.data.cameras.new(name='MinimapsCamera')
if bpy.data.objects.find('MinimapsCamera') != -1:
if bpy.data.scenes["Scene"].collection.objects.find('MinimapsCamera') == -1:
bpy.data.scenes["Scene"].collection.objects.link(bpy.data.objects['MinimapsCamera'])
else:
cam_obj = bpy.data.objects.new('MinimapsCamera', bpy.data.cameras["MinimapsCamera"])
bpy.data.scenes["Scene"].collection.objects.link(cam_obj)
bpy.data.scenes["Scene"].camera = bpy.data.objects['MinimapsCamera']
def set_mat_backface_culling():
for group_object in get_wmo_groups_list(bpy.context.scene):
for material in group_object.data.materials:
material.use_backface_culling = True
def disable_object_wmo_render_visiblity():
for group_object in get_wmo_groups_list(bpy.context.scene):
group_object.hide_render = True
for obj in bpy.context.scene.objects:
obj.hide_render = True
def apply_render_settings():
bpy.context.scene.view_settings.view_transform = 'Filmic'
bpy.context.scene.view_settings.exposure = -0.5
bpy.context.scene.view_settings.gamma = 1.5
bpy.context.scene.view_settings.look = 'None'
bpy.context.scene.render.image_settings.file_format = 'PNG'
bpy.context.scene.render.image_settings.color_mode = 'RGBA'
bpy.context.scene.render.film_transparent = True
bpy.data.cameras["MinimapsCamera"].type = 'ORTHO'
bpy.data.cameras["MinimapsCamera"].ortho_scale = 128.0
def iterate_groups():
sorted_objects = sorted(get_wmo_groups_list(bpy.context.scene), key=lambda obj: obj.wow_wmo_group.export_order)
for i, wmo_group in tqdm(enumerate(sorted_objects)):
if WoWWMOGroup.is_indoor(wmo_group) and wmo_group.name != 'antiportal':
group_id = wmo_group.wow_wmo_group.export_order
render_images(wmo_group, group_id)
def render_images(obj, group_id):
bpy.context.view_layer.objects.active = obj
camera = bpy.data.cameras["MinimapsCamera"]
if not get_project_preferences().project_dir_path:
output_path = bpy.context.scene.render.filepath
else:
output_path = os.path.join(get_project_preferences().project_dir_path, r'textures\Minimap')
# md5_text = ""
md5_text = b''
def set_render_resolution(res):
if res == 128:
camera.ortho_scale = 64
elif res == 64:
camera.ortho_scale = 32
elif res == 32:
camera.ortho_scale = 16
else:
camera.ortho_scale = 128
bpy.context.scene.render.resolution_x = res
bpy.context.scene.render.resolution_y = res
def position_camera(bounds, offset_x, offset_y):
center_offset = camera.ortho_scale / 2
tile_offset_size = camera.ortho_scale
tile_x = tile_offset_size * offset_x
tile_y = tile_offset_size * offset_y
cam_position = [
bounds[0] + center_offset + tile_x,
bounds[1] + center_offset + tile_y,
bounds[2]
]
bpy.data.objects["MinimapsCamera"].location = cam_position
def add_md5_entry(offset_x, offset_y, md5_text):
offset_name = str(offset_x).zfill(2) + '_' + str(offset_y).zfill(2)
md5_a = md5_path + "_" + str(group_id).zfill(3) + '_' + offset_name + '.blp'
md5_b = self.png_name
md5_text += md5_a.encode() + b'\t' + md5_b.encode() + b'\r\n'
md5_entries.append(md5_text)
def renderliquid(liquidobj):
# create bmesh
# bm = bmesh.new()
# bm.from_object(liquidobj, bpy.context.evaluated_depsgraph_get())
bm = liquidobj.copy()
bpy.context.collection.objects.link(bm)
bpy.context.view_layer.update()
bpy.ops.object.mode_set(mode = 'OBJECT')
bpy.context.view_layer.objects.active = bm
bpy.ops.object.mode_set(mode = 'EDIT')
mesh = bm.data
renderflag_layer = mesh.vertex_colors['flag_0']
def comp_colors(color1, color2):
for i in range(3):
if color1[i] != color2[i]:
return False
return True
blue = [0.0, 0.0, 1.0]
for poly in mesh.polygons:
if comp_colors(renderflag_layer.data[poly.loop_indices[0]].color, blue):
poly.select = True
# bpy.ops.object.mode_set(mode = 'EDIT')
# bpy.ops.object.editmode_toggle()
bpy.ops.mesh.delete(type='FACE')
bpy.ops.object.mode_set(mode = 'OBJECT')
bm.hide_render = False
return bm
def render(offset_x, offset_y):
self.png_name = self.generate_random_blp_string(existing_strings)
png_name = self.png_name.replace('.blp', '')
bpy.context.scene.render.filepath = output_path + '\\' + png_name
obj.hide_render = False
# titi liquids
#liquidobj = obj.wow_wmo_group.liquid_mesh
#if liquidobj:
#bm = renderliquid(liquidobj)
bpy.ops.render.render(write_still=True)
obj.hide_render = True
#if liquidobj:
# bm.free()
#bm.hide_render = True
#bpy.ops.object.delete() # should delete previosuly selected liquid copy
bpy.context.scene.render.filepath = output_path
# titi, attempt to covnert to blp using png2blp
# minimaps format : DXTC, alphachannem 0 bit, header 1024, 1 mipmap
# img = PNG2BLP().load(pngData, uint32_t pngSize)
# blp = PNG2BLP().createBlpDxtInMemory(bool generateMipMaps, int dxtFormat, uint32_t& fileSize)
# with open(output_path + '\\' + png_name, "rb") as f:
#
# print("test blp")
# # img = PNG2BLP().load(f, 256)
# pngbytes = f.read()
# #
# # print(img) World\wmo\Dungeon\AZ_Deadmines\AZ_Deadmines_A.wmo
#
# # blpdata = PNG2BLP(pngbytes, 256).createBlpDxtInMemory(True, 1, 256)
# blpdata = PNG2BLP(pngbytes, len(pngbytes)).create_blp_paletted_in_memory(True, 1)
#
# print(blpdata)
#
# with open(output_path + '\\' + name + "_" + str(group_id).zfill(3) + '_' + offset_name + '.blp', "wb") as blp:
# blp.write(blpdata)
# write blp file
# Get necessary bounding box values
bounds = [v[:] for v in obj.bound_box]
bounds_size_x = abs(bounds[0][0] - bounds[4][0])
bounds_size_y = abs(bounds[0][1] - bounds[3][1])
if bounds_size_x <= 16 and bounds_size_y <= 16:
set_render_resolution(32)
elif bounds_size_x <= 32 and bounds_size_y <= 32:
set_render_resolution(64)
elif bounds_size_x <= 64 and bounds_size_y <= 64:
set_render_resolution(128)
else:
set_render_resolution(256)
tiles_x = int(math.ceil(bounds_size_x / 128))
tiles_y = int(math.ceil(bounds_size_y / 128))
for offset_x in range(tiles_x):
for offset_y in range(tiles_y):
position_camera(bounds[1], offset_x, offset_y)
render(offset_x, offset_y)
add_md5_entry(offset_x, offset_y, md5_text)
def write_md5_entries(md5_file):
md5_output = b''
md5_output += b'dir: ' + os.path.dirname(md5_path).encode() + b'\r\n'
for entry in md5_entries:
md5_output += entry
if not get_project_preferences().project_dir_path: # if project dir not set in settings, use blender's render path
output_path = os.path.join(bpy.context.scene.render.filepath, r'md5translate.trs')
else:
output_path = os.path.join(get_project_preferences().project_dir_path, r'textures\Minimap\md5translate.trs')
with open(output_path, "wb") as f:
f.write(md5_file)
f.write(md5_output)
# open the folder when done
os.startfile(os.path.dirname(output_path))
create_camera_object()
set_mat_backface_culling()
disable_object_wmo_render_visiblity()
apply_render_settings()
iterate_groups()
write_md5_entries(md5_file)
return {'FINISHED'}
@@ -0,0 +1,74 @@
import bpy
from ..enums import portal_dir_alg_enum
from ..custom_objects import WoWWMOPortal, WoWWMOGroup
class WMO_OT_bake_portal_relations(bpy.types.Operator):
bl_idname = 'scene.wow_bake_portal_relations'
bl_label = 'Bake portal relations'
bl_description = 'Bake portal relations'
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
def find_nearest_objects_pair(object, objects):
pairs = []
for obj in objects:
hit = obj.closest_point_on_mesh(
obj.matrix_world.inverted() @ (object.matrix_world @ object.data.polygons[0].center))
hit_dist = (obj.matrix_world @ hit[1] - object.matrix_world @ object.data.polygons[0].center).length
pairs.append((obj, hit_dist))
pairs.sort(key=lambda x: x[1])
return pairs[0][0], pairs[1][0]
if not bpy.context.selected_objects:
self.report({'ERROR'}, "No objects selected.")
return {'FINISHED'}
success = False
groups = tuple(x for x in bpy.context.scene.objects if WoWWMOGroup.match(x) and not x.hide_get())
for obj in bpy.context.selected_objects:
if WoWWMOPortal.match(obj):
direction = find_nearest_objects_pair(obj, groups)
obj.wow_wmo_portal.first = direction[0] if direction[0] else ""
obj.wow_wmo_portal.second = direction[1] if direction[1] else ""
success = True
if success:
self.report({'INFO'}, "Done baking portal relations.")
else:
self.report({'ERROR'}, "No portal objects found among selected objects.")
return {'FINISHED'}
class WMO_OT_invert_portals(bpy.types.Operator):
bl_idname = 'scene.wow_set_portal_dir_alg'
bl_label = 'Set portal direction algorithm'
bl_description = 'Set portal direction calculation algorithm.'
bl_options = {'REGISTER', 'UNDO'}
algorithm: bpy.props.EnumProperty(
items=portal_dir_alg_enum,
default="0"
)
def execute(self, context):
success = False
for ob in bpy.context.selected_objects:
if WoWWMOPortal.match(ob):
ob.wow_wmo_portal.algorithm = self.algorithm
success = True
if success:
self.report({'INFO'}, "Successfully inverted selected portals")
return {'FINISHED'}
else:
self.report({'ERROR'}, "No portals found among selected objects")
return {'CANCELLED'}
@@ -0,0 +1,112 @@
from ....utils.callbacks import on_release, string_property_validator, string_filter_internal_dir
from .common import panel_poll
from ..enums import root_flags_enum
import bpy
class WMO_PT_collection(bpy.types.Panel):
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = 'collection'
bl_label = 'World Map Object'
def draw(self, context):
layout = self.layout
layout.use_property_split = True
layout.enabled = context.collection.wow_wmo.enabled
layout.prop(context.collection.wow_wmo, "dir_path")
col = layout.column()
col.separator()
col.prop(context.collection.wow_wmo, "flags")
col.separator()
if "2" in context.collection.wow_wmo.flags:
col.prop(context.collection.wow_wmo, "ambient_color")
col.separator()
col.prop(context.collection.wow_wmo, "skybox_path")
col.prop(context.collection.wow_wmo, "wmo_id")
def draw_header(self, context):
layout = self.layout
row = layout.row()
row.prop(context.collection.wow_wmo, 'enabled', text='')
@classmethod
def poll(cls, context):
return panel_poll(cls, context) and context.collection.name in context.scene.collection.children
@on_release()
def update_flags(self, context):
properties = bpy.data.node_groups.get('MO_Properties')
if properties:
properties.nodes['IsRenderPathUnified'].outputs[0].default_value = int('2' in self.flags)
properties.nodes['DoNotFixColorVertexAlpha'].outputs[0].default_value = int('1' in self.flags)
@on_release()
def update_ambient_color(self, context):
properties = bpy.data.node_groups.get('MO_Properties')
if properties:
properties.nodes['IntAmbientColor'].outputs[0].default_value = self.ambient_color
class WoWWMOCollectionPropertyGroup(bpy.types.PropertyGroup):
enabled: bpy.props.BoolProperty(
name='Enabled'
, description='Enable this collection as a WMO object.'
, default=False
)
dir_path: bpy.props.StringProperty(
name='Directory path'
, description='Full path of the WMO in WoW filesystem.'
, options={'TEXTEDIT_UPDATE'}
, update=lambda self, ctx: string_property_validator(self, ctx
, name='dir_path'
, str_filter=string_filter_internal_dir
, lockable=True)
)
flags: bpy.props.EnumProperty(
name="Root flags",
description="WoW WMO root flags",
items=root_flags_enum,
options={"ENUM_FLAG"},
update=update_flags
)
ambient_color: bpy.props.FloatVectorProperty(
name="Ambient Color",
subtype='COLOR',
default=(1, 1, 1, 1),
size=4,
min=0.0,
max=1.0,
update=update_ambient_color
)
skybox_path: bpy.props.StringProperty(
name="Skybox Path",
description="Skybox for WMO (.MDX)",
default='',
)
wmo_id: bpy.props.IntProperty(
name="DBC ID",
description="Used in WMOAreaTable (optional)",
default=0,
)
def register():
bpy.types.Collection.wow_wmo = bpy.props.PointerProperty(type=WoWWMOCollectionPropertyGroup)
def unregister():
del bpy.types.Collection.wow_wmo
@@ -0,0 +1,15 @@
from ....ui.enums import WoWSceneTypes
import bpy
def panel_poll(cls, context: bpy.types.Context) -> bool:
"""
Common poll for some WMO panels to determined if a panel should be rendered..
:param cls: Panel.
:param context: Current context.
:return: True if should be rendered, else False.
"""
return (context.scene is not None
and context.scene.wow_scene.type == WoWSceneTypes.WMO.name)
@@ -0,0 +1,70 @@
from ....ui.locks import DepsgraphLock
from ....ui.panels import WBS_PT_object_properties_common
from ....ui.enums import WoWSceneTypes
from ..custom_objects import WoWWMODoodad
import bpy
class WMO_PT_doodad(WBS_PT_object_properties_common, bpy.types.Panel):
bl_label = "WMO Doodad"
bl_context = "object"
__wbs_custom_object_type__ = WoWWMODoodad
__wbs_scene_type__ = WoWSceneTypes.WMO
def draw(self, context):
layout = self.layout
layout.use_property_split = True
layout.prop(context.object.wow_wmo_doodad, "path")
layout.prop(context.object.wow_wmo_doodad, "color")
col = layout.column()
col.prop(context.object.wow_wmo_doodad, "flags")
def update_doodad_color(self, context):
mesh = self.id_data.data
# print(mesh) # <bpy_struct, Object("BOOTSLEATHERBROWN01.011") at 0x0000017A1A837208>
# print(type(mesh)) # <class 'bpy_types.Object'>
with DepsgraphLock():
for mat in mesh.materials: # TODO : this broke somehow
# mat.node_tree.nodes['DoodadColor'].outputs[0].default_value = self.color
mat.node_tree.nodes['DoodadColor'].attribute_type = 'OBJECT'
mat.node_tree.nodes['DoodadColor'].attribute_name = 'wow_wmo_doodad.color'
class WoWDoodadPropertyGroup(bpy.types.PropertyGroup):
enabled: bpy.props.BoolProperty()
""" Set by operators. To make an object a doodad. """
path: bpy.props.StringProperty(name="Path", description='Path of doodad in WoW filesystem.')
color: bpy.props.FloatVectorProperty(
name="Color",
subtype='COLOR',
size=4,
default=(1, 1, 1, 1),
min=0.0,
max=1.0,
update=update_doodad_color
)
flags: bpy.props.EnumProperty(
name="Settings",
description="WoW doodad instance settings",
items=[("1", "Accept Projected Tex.", ""),
("2", "Adjust lighting", ""),
("4", "Unknown", ""),
("8", "Unknown", "")],
options={"ENUM_FLAG"}
)
def register():
bpy.types.Object.wow_wmo_doodad = bpy.props.PointerProperty(type=WoWDoodadPropertyGroup)
def unregister():
bpy.types.Object.wow_wmo_doodad = None
@@ -0,0 +1,121 @@
from ....ui.panels import WBS_PT_object_properties_common
from ....ui.enums import WoWSceneTypes
from ..custom_objects import WoWWMOFog
import bpy
class WMO_PT_fog(WBS_PT_object_properties_common, bpy.types.Panel):
bl_label = "WMO Fog"
bl_context = "object"
__wbs_custom_object_type__ = WoWWMOFog
__wbs_scene_type__ = WoWSceneTypes.WMO
def draw(self, context):
layout = self.layout
layout.use_property_split = True
self.layout.prop(context.object.wow_wmo_fog, "ignore_radius")
self.layout.prop(context.object.wow_wmo_fog, "unknown")
self.layout.prop(context.object.wow_wmo_fog, "inner_radius")
self.layout.prop(context.object.wow_wmo_fog, "end_dist")
self.layout.prop(context.object.wow_wmo_fog, "start_factor")
self.layout.prop(context.object.wow_wmo_fog, "color1")
self.layout.prop(context.object.wow_wmo_fog, "end_dist2")
self.layout.prop(context.object.wow_wmo_fog, "start_factor2")
self.layout.prop(context.object.wow_wmo_fog, "color2")
def update_fog_color(self, context):
fog = self.id_data
fog.color = (self.color1[0], self.color1[1], self.color1[2], 0.5)
class WowFogPropertyGroup(bpy.types.PropertyGroup):
enabled: bpy.props.BoolProperty()
fog_id: bpy.props.IntProperty(
name="WMO Group ID",
description="Used internally for exporting",
default=0,
)
ignore_radius: bpy.props.BoolProperty(
name="Ignore Radius",
description="Ignore radius in CWorldView::QueryCameraFog",
default=False
)
unknown: bpy.props.BoolProperty(
name="Unknown Flag",
description="Check that in if you know what it is",
default=False
)
inner_radius: bpy.props.FloatProperty(
name="Inner Radius (%)",
description="A radius of fog starting to fade",
default=100.0,
min=0.0,
max=100.0
)
end_dist: bpy.props.FloatProperty(
name="Farclip",
description="Fog farclip",
default=70.0,
min=0.0,
max=2048.0
)
start_factor: bpy.props.FloatProperty(
name="Nearclip",
description="Fog nearclip",
default=0.1,
min=0.0,
max=1.0
)
color1: bpy.props.FloatVectorProperty(
name="Color",
subtype='COLOR',
default=(1, 1, 1),
min=0.0,
max=1.0,
update=update_fog_color
)
end_dist2: bpy.props.FloatProperty(
name="Underwater farclip",
description="Underwater fog farclip",
default=70.0,
min=0.0,
max=250.0
)
start_factor2: bpy.props.FloatProperty(
name="Underwater nearclip",
description="Underwater fog nearclip",
default=0.1,
min=0.0,
max=1.0
)
color2: bpy.props.FloatVectorProperty(
name="Underwater Color",
subtype='COLOR',
default=(1, 1, 1),
min=0.0,
max=1.0
)
def register():
bpy.types.Object.wow_wmo_fog = bpy.props.PointerProperty(type=WowFogPropertyGroup)
def unregister():
del bpy.types.Object.wow_wmo_fog
@@ -0,0 +1,172 @@
from .liquid import WMO_PT_liquid
from ..enums import *
from ..custom_objects import WoWWMOGroup
from ....ui.panels import WBS_PT_object_properties_common
from ....ui.enums import WoWSceneTypes
from ..custom_objects import *
from ..collections import get_wmo_collection, SpecialCollections
from collections import namedtuple
import bpy
class WMO_PT_wmo_group(WBS_PT_object_properties_common, bpy.types.Panel):
bl_label = "WMO Group"
bl_context = "object"
__wbs_custom_object_type__ = WoWWMOGroup
__wbs_scene_type__ = WoWSceneTypes.WMO
def draw(self, context):
self.layout.use_property_split = True
col = self.layout.column()
col.prop(context.object.wow_wmo_group, "export_order")
col.separator()
col.prop(context.object.wow_wmo_group, "description")
col.separator()
col.prop(context.object.wow_wmo_group, "flags")
col.separator()
box = col.box()
box.prop(context.object.wow_wmo_group, "fog1")
box.prop(context.object.wow_wmo_group, "fog2")
box.prop(context.object.wow_wmo_group, "fog3")
box.prop(context.object.wow_wmo_group, "fog4")
col.separator()
col.prop(context.object.wow_wmo_group, "group_dbc_id")
col.prop(context.object.wow_wmo_group, "liquid_type")
box = col.box()
box.prop(context.object.wow_wmo_group, "liquid_mesh")
if context.object.wow_wmo_group.liquid_mesh:
ctx_override = namedtuple('ctx_override', ('layout', 'object'))
ctx = ctx_override(box, context.object.wow_wmo_group.liquid_mesh)
WMO_PT_liquid.draw(ctx, ctx)
box.prop(context.object.wow_wmo_group, "collision_mesh")
def fog_validator(self, context):
scn = bpy.context.scene
if self.fog1 and (not WoWWMOFog.match(self.fog1) or self.fog1.name not in get_wmo_collection(scn, SpecialCollections.Fogs).objects):
self.fog1 = None
if self.fog2 and (not WoWWMOFog.match(self.fog2) or self.fog2.name not in get_wmo_collection(scn, SpecialCollections.Fogs).objects):
self.fog2 = None
if self.fog3 and (not WoWWMOFog.match(self.fog3) or self.fog3.name not in get_wmo_collection(scn, SpecialCollections.Fogs).objects):
self.fog3 = None
if self.fog4 and (not WoWWMOFog.match(self.fog4) or self.fog4.name not in get_wmo_collection(scn, SpecialCollections.Fogs).objects):
self.fog4 = None
def update_flags(self, context):
obj = context.object
if not obj:
obj = context.view_layer.objects.active
if not obj:
return
if '0' in self.flags:
obj.pass_index |= 0x20 # BlenderWMOObjectRenderFlags.HasVertexColor
mesh = obj.data
if 'Col' not in mesh.color_attributes:
vertex_color_layer = mesh.color_attributes.new(name="Col", type='BYTE_COLOR', domain='POINT')
else:
obj.pass_index &= ~0x20
if '1' in self.flags:
obj.pass_index |= 0x4 # BlenderWMOObjectRenderFlags.NoLocalLight
else:
obj.pass_index &= ~0x4
class WowWMOGroupPropertyGroup(bpy.types.PropertyGroup):
description: bpy.props.StringProperty(
name="Description",
description='Saved in the WMO file.'
)
export_order: bpy.props.IntProperty(
name="Export Order",
min=0,
max=999
)
flags: bpy.props.EnumProperty(
items=group_flag_enum,
options={'ENUM_FLAG'},
update=update_flags
)
group_dbc_id: bpy.props.IntProperty(
name="DBC Group ID",
description="WMO Group ID in DBC file"
)
liquid_type: bpy.props.EnumProperty(
items=liquid_type_enum,
name="LiquidType",
description="Fill this WMO group with selected liquid."
)
fog1: bpy.props.PointerProperty(
type=bpy.types.Object,
name="Fog #1",
poll=lambda self, obj: WoWWMOFog.match(obj) and obj.name in get_wmo_collection(bpy.context.scene, SpecialCollections.Fogs).objects,
update=fog_validator
)
fog2: bpy.props.PointerProperty(
type=bpy.types.Object,
name="Fog #2",
poll=lambda self, obj: WoWWMOFog.match(obj) and obj.name in get_wmo_collection(bpy.context.scene, SpecialCollections.Fogs).objects,
update=fog_validator
)
fog3: bpy.props.PointerProperty(
type=bpy.types.Object,
name="Fog #3",
poll=lambda self, obj: WoWWMOFog.match(obj) and obj.name in get_wmo_collection(bpy.context.scene, SpecialCollections.Fogs).objects,
update=fog_validator
)
fog4: bpy.props.PointerProperty(
type=bpy.types.Object,
name="Fog #4",
poll=lambda self, obj: WoWWMOFog.match(obj) and obj.name in get_wmo_collection(bpy.context.scene, SpecialCollections.Fogs).objects,
update=fog_validator
)
collision_mesh: bpy.props.PointerProperty(
type=bpy.types.Object,
name='Collision',
description='Invisible collision geometry of this group',
poll=lambda self, obj: obj.type == 'MESH' and obj.name in get_wmo_collection(bpy.context.scene, SpecialCollections.Collision).objects
)
liquid_mesh: bpy.props.PointerProperty(
type=bpy.types.Object,
name='Liquid',
description='Liquid plane linked to this group',
poll=lambda self, obj: obj.type == 'MESH' and WoWWMOLiquid.match(obj)
)
def register():
bpy.types.Object.wow_wmo_group = bpy.props.PointerProperty(type=WowWMOGroupPropertyGroup)
def unregister():
del bpy.types.Object.wow_wmo_group
@@ -0,0 +1,90 @@
import bpy
from ..enums import *
from ..custom_objects import WoWWMOLight
from ....ui.panels import WBS_PT_object_properties_common
from ....ui.enums import WoWSceneTypes
class WMO_PT_light(WBS_PT_object_properties_common, bpy.types.Panel):
bl_label = "WMO Light"
bl_context = "data"
__wbs_custom_object_type__ = WoWWMOLight
__wbs_scene_type__ = WoWSceneTypes.WMO
def draw(self, context):
self.layout.use_property_split = True
self.layout.prop(context.object.wow_wmo_light, "light_type")
self.layout.prop(context.object.wow_wmo_light, "use_attenuation")
self.layout.prop(context.object.wow_wmo_light, "color")
self.layout.prop(context.object.wow_wmo_light, "intensity")
self.layout.prop(context.object.wow_wmo_light, "attenuation_start")
self.layout.prop(context.object.wow_wmo_light, "attenuation_end")
class WowLightPropertyGroup(bpy.types.PropertyGroup):
enabled: bpy.props.BoolProperty()
light_type: bpy.props.EnumProperty(
items=light_type_enum,
name="Type",
description="Type of the lamp"
)
type: bpy.props.BoolProperty(
name="Type",
description="Unknown"
)
use_attenuation: bpy.props.BoolProperty(
name="Use attenuation",
description="True if lamp uses attenuation"
)
padding: bpy.props.BoolProperty(
name="Padding",
description="True if lamp uses padding"
)
color: bpy.props.FloatVectorProperty(
name="Color",
subtype='COLOR',
default=(1, 1, 1),
min=0.0,
max=1.0
)
intensity: bpy.props.FloatProperty(
name="Intensity",
description="Intensity of the lamp"
)
color_alpha: bpy.props.FloatProperty(
name="ColorAlpha",
description="Color alpha",
default=1,
min=0.0,
max=1.0
)
attenuation_start: bpy.props.FloatProperty(
name="Attenuation start",
description="Distance at which light intensity starts to decrease"
)
attenuation_end: bpy.props.FloatProperty(
name="Attenuation end",
description="Distance at which light intensity reach 0"
)
def register():
bpy.types.Object.wow_wmo_light = bpy.props.PointerProperty(type=WowLightPropertyGroup)
def unregister():
del bpy.types.Object.wow_wmo_light
@@ -0,0 +1,40 @@
from ..custom_objects import WoWWMOLiquid
from ....ui.panels import WBS_PT_object_properties_common
from ....ui.enums import WoWSceneTypes
import bpy
class WMO_PT_liquid(WBS_PT_object_properties_common, bpy.types.Panel):
bl_label = "WMO Liquid"
bl_context = "object"
__wbs_custom_object_type__ = WoWWMOLiquid
__wbs_scene_type__ = WoWSceneTypes.WMO
def draw(self, context):
layout = self.layout
layout.use_property_split = True
layout.prop(context.object.wow_wmo_liquid, "color")
class WowLiquidPropertyGroup(bpy.types.PropertyGroup):
enabled: bpy.props.BoolProperty()
color: bpy.props.FloatVectorProperty(
name="Color",
subtype='COLOR',
default=(0.08, 0.08, 0.08, 1.0),
size=4,
min=0.0,
max=1.0
)
def register():
bpy.types.Object.wow_wmo_liquid = bpy.props.PointerProperty(type=WowLiquidPropertyGroup)
def unregister():
del bpy.types.Object.wow_wmo_liquid
@@ -0,0 +1,219 @@
import bpy
from ..enums import *
from ...bl_render import update_wmo_mat_node_tree
from ....utils.callbacks import on_release
from .common import panel_poll
from ...ui.custom_objects import WoWWMOGroup
from ....pywowlib import WoWVersions
class WMO_PT_material(bpy.types.Panel):
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "material"
bl_label = "WMO Material"
def draw(self, context):
layout = self.layout
layout.use_property_split = True
col = layout.column()
col.prop(context.material.wow_wmo_material, "shader")
col.prop(context.material.wow_wmo_material, "terrain_type")
col.prop(context.material.wow_wmo_material, "blending_mode")
col.separator()
box = col.box()
box.prop(context.material.wow_wmo_material, "diff_texture_1")
if context.material.wow_wmo_material.diff_texture_1:
box.prop(context.material.wow_wmo_material.diff_texture_1.wow_wmo_texture, "path")
# only display 2nd texture for multi tetxure shader types
if int(context.material.wow_wmo_material.shader) in (3, 5, 6, 7, 8, 9, 11, 12, 13, 15, 17):
box.prop(context.material.wow_wmo_material, "diff_texture_2")
if context.material.wow_wmo_material.diff_texture_2:
box.prop(context.material.wow_wmo_material.diff_texture_2.wow_wmo_texture, "path")
col.separator()
col.prop(context.material.wow_wmo_material, "flags")
layout.prop(context.material.wow_wmo_material, "emissive_color")
layout.prop(context.material.wow_wmo_material, "diff_color")
@classmethod
def poll(cls, context):
return (panel_poll(cls, context)
and context.material is not None
and WoWWMOGroup.match(context.object)
)
def update_flags(self, context):
material = self.id_data
if '1' in self.flags:
material.pass_index |= 0x1 # BlenderWMOMaterialRenderFlags.Unlit
else:
material.pass_index &= ~0x1
if '16' in self.flags:
material.pass_index |= 0x2 # BlenderWMOMaterialRenderFlags.SIDN
else:
material.pass_index &= ~0x2
def update_shader(self, context):
material = self.id_data
if int(self.shader) in (3, 5, 6, 7, 8, 9, 11, 12, 13, 15, 17):
material.pass_index |= 0x4 # BlenderWMOMaterialRenderFlags.IsTwoLayered
else:
material.pass_index &= ~0x4
def update_blending_mode(self, context):
material = self.id_data
blend_mode = int(self.blending_mode)
if blend_mode in (0, 8, 9):
material.pass_index |= 0x10 # BlenderWMOMaterialRenderFlags.IsOpaque
else:
material.pass_index &= ~0x10
if blend_mode in (0, 8, 9):
material.blend_method = 'OPAQUE'
elif blend_mode == 1:
material.blend_method = 'CLIP'
material.alpha_threshold = 0.9
# those blending modes don't exist anymore in 2.9+
# elif wmo_material.blend_mode in (3, 7, 10):
# mat.blend_method = 'ADD'
# elif wmo_material.blend_mode in (4, 5):
# mat.blend_method = 'MULTIPLY'
else:
material.blend_method = 'BLEND'
def update_diff_texture_1(self, context):
if not self.id_data.use_nodes or ('DiffuseTexture1' not in self.id_data.node_tree.nodes):
return
if bpy.context.scene.render.engine in ('CYCLES', 'BLENDER_EEVEE') and self.diff_texture_1:
self.id_data.node_tree.nodes['DiffuseTexture1'].image = self.diff_texture_1
def update_diff_texture_2(self, context):
if not self.id_data.use_nodes or ('DiffuseTexture2' not in self.id_data.node_tree.nodes):
return
if bpy.context.scene.render.engine in ('CYCLES', 'BLENDER_EEVEE') and self.diff_texture_2:
self.id_data.node_tree.nodes['DiffuseTexture2'].image = self.diff_texture_2
@on_release()
def update_emissive_color(self, context):
if not self.id_data.use_nodes or ('EmissiveColor' not in self.id_data.node_tree.nodes):
return
self.id_data.node_tree.nodes['EmissiveColor'].outputs[0].default_value = self.emissive_color
def update_wmo_material_enabled(self, context):
if self.id_data:
update_wmo_mat_node_tree(self.id_data)
def set_shader_enum(self, context):
wow_version = int(bpy.context.scene.wow_scene.version)
if wow_version == WoWVersions.WOTLK:
tmp_shader_enum = [x for x in shader_enum if int(x[0]) < 7]
# elif wow_version == WoWVersions.LEGION:
# shader_enum = shader_enum
else:
tmp_shader_enum = shader_enum
return tmp_shader_enum
def set_terraintype_enum(self, context):
wow_version = int(bpy.context.scene.wow_scene.version)
if wow_version == WoWVersions.WOTLK:
tmp_terrain_type_enum = [x for x in terrain_type_enum if int(x[0]) < 12]
else:
tmp_terrain_type_enum = terrain_type_enum
return tmp_terrain_type_enum
class WowMaterialPropertyGroup(bpy.types.PropertyGroup):
flags: bpy.props.EnumProperty(
name="Material Flags",
description="WoW material flags",
items=material_flag_enum,
options={"ENUM_FLAG"},
update=update_flags
)
shader: bpy.props.EnumProperty(
items=set_shader_enum,
name="Shader",
description="WoW shader assigned to this material",
update=update_shader
)
blending_mode: bpy.props.EnumProperty(
items=blending_enum,
name="Blending",
description="WoW material blending mode",
update=update_blending_mode
)
emissive_color: bpy.props.FloatVectorProperty(
name="Emissive Color",
subtype='COLOR',
default=(1,1,1,1),
size=4,
min=0.0,
max=1.0,
update=update_emissive_color
)
diff_color: bpy.props.FloatVectorProperty(
name="Diffuse Color",
subtype='COLOR',
default=(1, 1, 1, 1),
size=4,
min=0.0,
max=1.0
)
terrain_type: bpy.props.EnumProperty(
items=set_terraintype_enum,
name="Terrain Type",
description="Terrain type assigned to this material. Used for producing correct footstep sounds."
)
diff_texture_1: bpy.props.PointerProperty(
type=bpy.types.Image,
name='Texture 1',
update=update_diff_texture_1
)
diff_texture_2: bpy.props.PointerProperty(
type=bpy.types.Image,
name='Texture 2',
update=update_diff_texture_2
)
def register():
bpy.types.Material.wow_wmo_material = bpy.props.PointerProperty(type=WowMaterialPropertyGroup)
def unregister():
del bpy.types.Material.wow_wmo_material
@@ -0,0 +1,107 @@
from ..enums import *
from ..custom_objects import WoWWMOPortal
from ....ui.panels import WBS_PT_object_properties_common
from ....ui.enums import WoWSceneTypes
from ....wmo.ui.custom_objects import WoWWMOGroup
from ....wmo.ui.collections import get_wmo_groups_list
from ...ui.enums import SpecialCollections
from ...ui.collections import get_wmo_collection
import bpy
class WMO_PT_portal(WBS_PT_object_properties_common, bpy.types.Panel):
bl_label = "WMO Portal"
bl_context = "object"
__wbs_custom_object_type__ = WoWWMOPortal
__wbs_scene_type__ = WoWSceneTypes.WMO
def draw(self, context):
layout = self.layout
layout.use_property_split = True
column = layout.column()
column.prop(context.object.wow_wmo_portal, "first")
column.prop(context.object.wow_wmo_portal, "second")
col = layout.column()
col.separator()
col.prop(context.object.wow_wmo_portal, "detail", expand=True)
col.separator()
col.prop(context.object.wow_wmo_portal, "algorithm", expand=True)
def portal_validator(self, context):
if self.second and not WoWWMOGroup.match(self.second):
self.second = None
if self.first and not WoWWMOGroup.match(self.first):
self.first = None
def build_first_group_list(self, context):
obj_list = []
# print(bpy.data.collections.get('Outdoor').objects)
scn = bpy.context.scene
for outdoor_obj in list(get_wmo_collection(scn, SpecialCollections.Outdoor).objects):
# print(outdoor_obj)
if self.second != outdoor_obj and outdoor_obj.name:
obj_list.append(outdoor_obj)
for indoor_obj in list(get_wmo_collection(scn, SpecialCollections.Indoor).objects):
print(indoor_obj)
if self.second != indoor_obj and indoor_obj.name:
obj_list.append(indoor_obj)
return obj_list
class WowPortalPlanePropertyGroup(bpy.types.PropertyGroup):
first: bpy.props.PointerProperty(
type=bpy.types.Object,
name="First group",
poll=lambda self, obj: WoWWMOGroup.match(obj) and self.second != obj and obj.name in bpy.context.scene.objects,
update=portal_validator
)
second: bpy.props.PointerProperty(
type=bpy.types.Object,
name="Second group",
# doesn't work
# poll=lambda self, obj: WoWWMOGroup.match(obj) and self.first != obj and obj.name
# in get_wmo_groups_list(bpy.context.scene),
poll=lambda self, obj: WoWWMOGroup.match(obj) and self.first != obj and obj.name in bpy.context.scene.objects,
update=portal_validator
)
detail: bpy.props.EnumProperty(
items=portal_detail_enum,
name="Detail",
description="Disable this group will only work as a target for the portal. "
"See Stormwind cathedral for reference.",
default="0"
)
portal_id: bpy.props.IntProperty(
name="Portal's ID",
description="Portal ID"
)
algorithm: bpy.props.EnumProperty(
items=portal_dir_alg_enum,
name="Algorithm",
default="0"
)
def register():
bpy.types.Object.wow_wmo_portal = bpy.props.PointerProperty(type=WowPortalPlanePropertyGroup)
def unregister():
del bpy.types.Object.wow_wmo_portal
@@ -0,0 +1,36 @@
import bpy
from .common import panel_poll
class WMO_PT_texture(bpy.types.Panel):
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "image"
bl_label = "WMO Texture"
def draw(self, context):
layout = self.layout
layout.use_property_split = True
col = layout.column()
col.prop(context.edit_image.wow_wmo_texture, "path")
@classmethod
def poll(cls, context):
return panel_poll(cls, context) and context.image is not None
class WowWMOTexturePropertyGroup(bpy.types.PropertyGroup):
path: bpy.props.StringProperty(
name="Path",
description="Warning: texture path is applied on a per-texture (per-image), not on per-material basis."
)
def register():
bpy.types.Image.wow_wmo_texture = bpy.props.PointerProperty(type=WowWMOTexturePropertyGroup)
def unregister():
del bpy.types.Image.wow_wmo_texture
@@ -0,0 +1,335 @@
import bpy
from ....ui.preferences import get_project_preferences
from ..enums import *
from .common import panel_poll
from ..custom_objects import *
from ...ui.enums import SpecialCollections
from ...ui.collections import get_current_wow_model_collection, get_or_create_collection, get_wmo_groups_list
def update_wow_visibility(self, context):
values = self.wow_visibility
for obj in self.objects:
if 'wow_hide' not in obj:
obj['wow_hide'] = obj.hide_get()
if obj['wow_hide'] != obj.hide_get():
continue
if obj.type == "MESH":
if WoWWMOGroup.match(obj):
if WoWWMOGroup.is_outdoor(obj):
obj.hide_set('0' not in values)
elif WoWWMOGroup.is_indoor(obj):
obj.hide_set('1' not in values)
if obj.wow_wmo_group.collision_mesh:
col = obj.wow_wmo_group.collision_mesh
if 'wow_hide' not in col:
col['wow_hide'] = col.hide_get()
if col['wow_hide'] != col.hide_get():
continue
col.hide_set('6' not in values)
col['wow_hide'] = col.hide_get()
elif WoWWMOPortal.match(obj):
obj.hide_set('2' not in values)
elif WoWWMOFog.match(obj):
obj.hide_set('3' not in values)
elif WoWWMOLiquid.match(obj):
obj.hide_set('4' not in values)
elif obj.type == "LIGHT" and WoWWMOLight.match(obj):
obj.hide_set('5' not in values)
obj['wow_hide'] = obj.hide_get()
def update_wow_wmo_culling(self, context):
values = self.wow_enable_culling
for group_object in get_wmo_groups_list(bpy.context.scene):
for material in group_object.data.materials:
if not "4" in material.wow_wmo_material.flags: # two-sided flag
material.use_backface_culling = ('culling' in values)
def get_doodad_sets(self, context):
has_global = False
global_col = None
doodad_set_collections = set()
doodad_sets = []
wmo_model_collection = get_current_wow_model_collection(bpy.context.scene, 'wow_wmo')
if wmo_model_collection:
for set_collection in get_or_create_collection(wmo_model_collection, SpecialCollections.Doodads.name).children:
if "Set_$DefaultGlobal" not in set_collection.name:
doodad_set_collections.add(set_collection)
else:
has_global = True
global_col = set_collection
for index, set_collection in enumerate(sorted(doodad_set_collections, key=lambda x: x.name), 1 + has_global):
doodad_sets.append((set_collection.name, set_collection.name, "", 'SCENE_DATA', index))
doodad_sets.insert(0, ("None", "No set", "", 'X', 0))
if has_global:
doodad_sets.insert(1, (global_col.name, "Set_$DefaultGlobal", "", 'WORLD', 1))
return doodad_sets
def switch_doodad_set(self, context):
set = self.wow_doodad_visibility
wmo_model_collection = get_current_wow_model_collection(bpy.context.scene, 'wow_wmo')
if wmo_model_collection:
for set_collection in get_or_create_collection(wmo_model_collection, SpecialCollections.Doodads.name).children:
name = set_collection.name
for obj in set_collection.objects:
obj.hide_set(set == "None" or name != set and name != "Set_$DefaultGlobal")
class WMO_PT_tools_object_mode_display(bpy.types.Panel):
bl_label = 'Display'
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_context = 'objectmode'
bl_category = 'WMO'
def draw(self, context):
layout = self.layout.split()
col = layout.column(align=True)
col_row = col.row()
col_row.column(align=True).prop(context.scene, "wow_visibility")
col_col = col_row.column(align=True)
col_col.operator("scene.wow_wmo_select_entity", text='', icon='VIEWZOOM').entity = 'Outdoor'
col_col.operator("scene.wow_wmo_select_entity", text='', icon='VIEWZOOM').entity = 'Indoor'
col_col.operator("scene.wow_wmo_select_entity", text='', icon='VIEWZOOM').entity = 'wow_wmo_portal'
col_col.operator("scene.wow_wmo_select_entity", text='', icon='VIEWZOOM').entity = 'wow_wmo_fog'
col_col.operator("scene.wow_wmo_select_entity", text='', icon='VIEWZOOM').entity = 'wow_wmo_liquid'
col_col.operator("scene.wow_wmo_select_entity", text='', icon='VIEWZOOM').entity = 'wow_wmo_light'
col_col.operator("scene.wow_wmo_select_entity", text='', icon='VIEWZOOM').entity = 'Collision'
box2_row2 = col.row()
box2_row2.prop(context.scene, "wow_doodad_visibility", expand=False)
box2_row2.operator("scene.wow_wmo_select_entity", text='', icon='VIEWZOOM').entity = 'wow_wmo_doodad'
box2_row3 = col.row()
box2_row3.prop(context.scene, "wow_enable_culling")
@classmethod
def poll(cls, context):
return panel_poll(cls, context)
class WMO_PT_tools_panel_object_mode_add_to_scene(bpy.types.Panel):
bl_label = 'Add to scene'
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_context = 'objectmode'
bl_category = 'WMO'
def draw(self, context):
layout = self.layout.split()
wow_model_collection = get_current_wow_model_collection(bpy.context.scene, 'wow_wmo')
col = layout.column(align=True)
if wow_model_collection:
col.label(text=f'WMO: {wow_model_collection.name}')
col.separator()
if bpy.context.scene.wow_scene.doodadset_mode == False:
col.separator()
col1_col = col.column(align=True)
col1_row0 = col1_col.row(align=True)
col1_row1 = col1_col.row(align=True)
col1_row2 = col1_col.row(align=True)
col1_row3 = col1_col.row(align=True)
col1_row4 = col1_col.row(align=True)
col1_row4 = col1_col.row(align=True)
col1_row5 = col1_col.row(align=True)
col.separator()
if proj_prefs := get_project_preferences():
col1_row0.prop(proj_prefs, 'import_method', text='')
if proj_prefs.import_method == 'DirectPath':
box = col1_row1.box()
box.prop(proj_prefs, "direct_path")
col1_row2.operator("scene.wow_wmo_import_doodad_from_wmv", text='M2',
icon_value=ui_icons['WOW_STUDIO_DOODADS_ADD'])
col1_row2.operator("scene.wow_import_last_wmo_from_wmv", text='WMO',
icon_value=ui_icons['WOW_STUDIO_WMO_ADD'])
col1_row3.operator("scene.wow_add_fog", text='Fog', icon_value=ui_icons['WOW_STUDIO_FOG_ADD'])
col1_row3.operator("scene.wow_add_liquid", text='Liquid', icon_value=ui_icons['WOW_STUDIO_LIQUID_ADD'])
col1_row4.operator("scene.wow_add_light", text='Light', icon='LIGHT')
col1_row4.operator("scene.wow_add_scale_reference", text='Scale',
icon_value=ui_icons['WOW_STUDIO_SCALE_ADD'])
col1_row5.operator("scene.wow_wmo_texture_import", text='Texture', icon='IMAGE_DATA')
else:
col.separator()
col1_col = col.column(align=True)
col1_row0 = col1_col.row(align=True)
col1_row1 = col1_col.row(align=True)
col1_row2 = col1_col.row(align=True)
col1_row3 = col1_col.row(align=True)
col1_row4 = col1_col.row(align=True)
col.separator()
if proj_prefs := get_project_preferences():
col1_row0.prop(proj_prefs, 'import_method', text='')
col1_row2.operator("scene.wow_wmo_import_doodad_from_wmv", text='M2',
icon_value=ui_icons['WOW_STUDIO_DOODADS_ADD'])
@classmethod
def poll(cls, context):
return panel_poll(cls, context)
class WMO_PT_tools_object_mode_actions(bpy.types.Panel):
bl_label = 'Actions'
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_context = 'objectmode'
bl_category = 'WMO'
def draw(self, context):
layout = self.layout.split()
col = layout.column(align=True)
col.separator()
box_col = col.column(align=True)
box_col.operator("scene.wow_wmo_generate_minimaps", text='Generate minimaps', icon='SHADING_RENDERED')
if bpy.context.selected_objects:
box_col.operator("scene.wow_wmo_generate_materials", text='Generate materials', icon='MATERIAL')
box_col.operator("scene.wow_fill_textures", text='Fill texture paths', icon='SEQ_SPLITVIEW')
box_col.operator("scene.wow_quick_collision", text='Quick collision', icon='MOD_TRIANGULATE')
box_col.operator("scene.wow_set_portal_dir_alg", text='Set portal direction', icon='ORIENTATION_NORMAL')
box_col.operator("scene.wow_bake_portal_relations", text='Bake portal relations', icon='FULLSCREEN_EXIT')
col.separator()
@classmethod
def poll(cls, context):
return context.scene is not None and context.scene.wow_scene.type == 'WMO' and bpy.context.scene.wow_scene.doodadset_mode == False
class WMO_PT_tools_object_mode_doodads(bpy.types.Panel):
bl_label = 'Doodads'
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_context = 'objectmode'
bl_category = 'WMO'
@classmethod
def poll(cls, context):
return panel_poll(cls, context) and bpy.context.selected_objects
def draw(self, context):
layout = self.layout.split()
col = layout.column(align=True)
col.separator()
box_col2 = col.column(align=True)
box_col2.operator("scene.wow_doodads_bake_color", text='Bake color', icon='SHADING_RENDERED')
box_col2.operator("scene.wow_doodad_set_color", text='Set color', icon='SHADING_SOLID')
box_col2.operator("scene.wow_doodad_set_template_action", text='Template action', icon='STICKY_UVS_LOC')
col.separator()
class WMO_MT_mesh_wow_components_add(bpy.types.Menu):
bl_label = "WoW"
bl_options = {'REGISTER'}
def draw(self, context):
layout = self.layout
col = layout.column()
col.operator("scene.wow_add_fog", text='Fog', icon_value=ui_icons['WOW_STUDIO_FOG_ADD'])
col.operator("scene.wow_add_liquid", text='Liquid', icon_value=ui_icons['WOW_STUDIO_LIQUID_ADD'])
col.operator("scene.wow_add_scale_reference", text='Scale', icon_value=ui_icons['WOW_STUDIO_SCALE_ADD'])
col.operator("scene.wow_add_light", text='Light', icon='LIGHT')
if hasattr(bpy, "wow_game_data") and bpy.wow_game_data.files:
col.operator("scene.wow_wmo_import_doodad_from_wmv", text='M2',
icon_value=ui_icons['WOW_STUDIO_DOODADS_ADD'])
col.operator("scene.wow_import_last_wmo_from_wmv", text='WMO', icon_value=ui_icons['WOW_STUDIO_WMO_ADD'])
@classmethod
def poll(cls, context):
return context.scene is not None and context.scene.wow_scene.type == 'WMO'
def wow_components_add_menu_item(self, context):
self.layout.menu("WMO_MT_mesh_wow_components_add", icon_value=ui_icons['WOW_STUDIO_WOW'])
def render_viewport_toggles_right(self, context):
if hasattr(context.scene, 'wow_scene') \
and hasattr(context.scene.wow_scene, 'type') \
and context.scene.wow_scene.type == 'WMO':
layout = self.layout
row = layout.row(align=True)
row.popover( panel="WMO_PT_tools_object_mode_display"
, text=''
, icon='HIDE_OFF'
)
def register():
bpy.types.Scene.wow_visibility = bpy.props.EnumProperty(
items=[
('0', "Outdoor", "Display outdoor groups", 'SELECT_SET', 0x1),
('1', "Indoor", "Display indoor groups", 'OBJECT_HIDDEN', 0x2),
('2', "Portals", "Display portals", 'FULLSCREEN_ENTER', 0x4),
('3', "Fogs", "Display fogs", 'MOD_FLUID', 0x8),
('4', "Liquids", "Display liquids", 'MOD_OCEAN', 0x10),
('5', "Lights", "Display lights", 'LIGHT', 0x20),
('6', "Collision", "Display collision", 'CON_SIZELIMIT', 0x40)],
options={'ENUM_FLAG'},
default={'0', '1', '2', '3', '4', '5'},
update=update_wow_visibility
)
bpy.types.Scene.wow_enable_culling = bpy.props.EnumProperty(
items=[
('culling', "Backface Culling",
"Enable materials backface culling unless that material has the two-sided flag for a realistic WoW rendering",
'XRAY', 0x1)],
options={'ENUM_FLAG'},
# default={},
update=update_wow_wmo_culling
)
bpy.types.Scene.wow_doodad_visibility = bpy.props.EnumProperty(
name="",
description="Switch doodad sets",
items=get_doodad_sets,
update=switch_doodad_set
)
bpy.types.VIEW3D_MT_add.prepend(wow_components_add_menu_item)
bpy.types.VIEW3D_HT_header.append(render_viewport_toggles_right)
def unregister():
del bpy.types.Scene.wow_visibility
del bpy.types.Scene.wow_doodad_visibility
del bpy.types.Scene.wow_enable_culling
bpy.types.VIEW3D_MT_add.remove(wow_components_add_menu_item)
bpy.types.VIEW3D_MT_add.remove(render_viewport_toggles_right)
@@ -0,0 +1,47 @@
import bpy
from ..custom_objects import WoWWMOGroup
from .common import panel_poll
class WMO_PT_vertex_info(bpy.types.Panel):
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "data"
bl_label = "WMO Vertex Info"
def draw(self, context):
self.layout.use_property_split = True
self.layout.prop_search(context.object.wow_wmo_vertex_info, "vertex_group",
context.object, "vertex_groups", text="Collision vertex group"
)
self.layout.prop(context.object.wow_wmo_vertex_info, "node_size", slider=True)
@classmethod
def poll(cls, context):
obj = context.object
return (panel_poll(cls, context)
and obj is not None
and WoWWMOGroup.match(obj)
)
class WowVertexInfoPropertyGroup(bpy.types.PropertyGroup):
vertex_group: bpy.props.StringProperty()
node_size: bpy.props.IntProperty(
name="Node max size",
description="Max count of faces for a node in bsp tree on export. 0 = Dynamic based on face count",
default=0, min=0,
soft_max=1000
)
def register():
bpy.types.Object.wow_wmo_vertex_info = bpy.props.PointerProperty(type=WowVertexInfoPropertyGroup)
def unregister():
del bpy.types.Object.wow_wmo_vertex_info
@@ -0,0 +1,440 @@
import io
import os
import traceback
import bpy
from ...utils.misc import load_game_data
from ..utils.materials import load_texture
from ...utils.node_builder import NodeTreeBuilder
from ...pywowlib.io_utils.types import *
from ...ui.preferences import get_project_preferences
# This file is implementing basic M2 geometry parsing in prodedural style for the sake of performance.
class Submesh:
__slots__ = (
"start_vertex",
"n_vertices",
"start_triangle",
"n_triangles",
"texture_id",
"blend_mode"
)
def __init__(self, f):
skip(f, 4)
self.start_vertex = uint16.read(f)
self.n_vertices = uint16.read(f)
self.start_triangle = uint16.read(f)
self.n_triangles = uint16.read(f)
self.texture_id = None
self.blend_mode = 0
skip(f, 36)
def skip(f, n_bytes):
f.seek(f.tell() + n_bytes)
def import_doodad_model(asset_dir: str, filepath: str, placeholder: bool) -> bpy.types.Object:
"""Import World of Warcraft M2 model to scene."""
game_data = load_game_data()
if placeholder:
m2_path = os.path.splitext('Spells\\Errorcube.m2')[0] + ".m2"
skin_path = os.path.splitext('Spells\\Errorcube.m2')[0] + "00.skin"
real_m2_path = os.path.splitext(filepath)[0] + ".m2"
m2_name = os.path.basename(os.path.splitext(real_m2_path)[0])
else:
m2_path = os.path.splitext(filepath)[0] + ".m2"
skin_path = os.path.splitext(filepath)[0] + "00.skin"
m2_name = os.path.basename(os.path.splitext(m2_path)[0])
try:
file, _ = game_data.read_file(m2_path)
m2_file = io.BytesIO(file)
except KeyError:
raise FileNotFoundError("\nModel <<{}>> not found in WoW file system.".format(filepath))
try:
file, _ = game_data.read_file(skin_path)
skin_file = io.BytesIO(file)
except KeyError:
raise FileNotFoundError("\nSkin file for model <<{}>> not found in WoW file system.".format(filepath))
###### M2 file parsing ######
f = m2_file
magic = f.read(4).decode("utf-8")
while magic not in ('MD20', 'MD21'):
skip(f, uint32.read(f))
magic = f.read(4).decode("utf-8")
# read flags
f.seek(16)
global_flags = uint32.read(f)
# read vertex positions
f.seek(60)
n_vertices = uint32.read(f)
f.seek(uint32.read(f))
vertices = [(0.0, 0.0, 0.0)] * n_vertices
normals = [(0.0, 0.0, 0.0)] * n_vertices
uv_coords = [(0.0, 0.0)] * n_vertices
# if n_vertices == 0:
# addon_dir = os.path.dirname(__file__)
# placeholder_blend_path = os.path.join(addon_dir, "utils", "placeholders", "placeholder.blend")
# directory = placeholder_blend_path + "\\Object\\"
# bpy.ops.wm.append(filename='placeholder', directory=directory)
# return import_doodad_model(asset_dir, filepath, True)
for i in range(n_vertices):
vertices[i] = float32.read(f, 3)
skip(f, 8)
normals[i] = float32.read(f, 3)
uv_coords[i] = float32.read(f, 2)
skip(f, 8)
# read render flags and blending modes
f.seek(112)
n_render_flags = uint32.read(f)
f.seek(uint32.read(f))
blend_modes = [] # skip render flags for now
for _ in range(n_render_flags):
skip(f, 2)
blend_modes.append(uint16.read(f))
# read texture lookup
f.seek(128)
n_tex_lookups = uint32.read(f)
f.seek(uint32.read(f))
texture_lookup_table = [uint16.read(f) for _ in range(n_tex_lookups)]
# read texture names
f.seek(80)
n_textures = uint32.read(f)
f.seek(uint32.read(f))
texture_paths = []
for _ in range(n_textures):
skip(f, 8)
len_tex = uint32.read(f)
ofs_tex = uint32.read(f)
if not len_tex:
texture_paths.append(None)
continue
pos = f.tell()
f.seek(ofs_tex)
texture_paths.append(f.read(len_tex).decode('utf-8').rstrip('\0'))
f.seek(pos)
# read blend mode overrides
if global_flags & 0x08:
f.seek(304)
n_blendmode_overrides = uint32.read(f)
f.seek(uint32.read(f))
blend_mode_overrides = []
for _ in range(n_blendmode_overrides):
blend_mode_overrides.append(uint16.read(f))
###### Skin ######
f = skin_file
padding = 0
# indices
try:
magic = f.read(4).decode("utf-8")
except UnicodeDecodeError:
padding = 4
f.seek(0)
n_indices = uint32.read(f)
f.seek(uint32.read(f))
indices = unpack('{}H'.format(n_indices), f.read(2 * n_indices))
# triangles
f.seek(12 - padding)
n_triangles = uint32.read(f) // 3
f.seek(uint32.read(f))
triangles = [(0, 0, 0)] * n_triangles
for i in range(n_triangles):
triangles[i] = unpack('HHH', f.read(6))
# submeshes
f.seek(28 - padding)
n_submeshes = uint32.read(f)
f.seek(uint32.read(f))
submeshes = [Submesh(f) for _ in range(n_submeshes)]
# texture units
f.seek(36 - padding)
n_tex_units = uint32.read(f)
f.seek(uint32.read(f))
for _ in range(n_tex_units):
skip(f, 2)
shader_id = uint16.read(f)
submesh_id = uint16.read(f)
if submeshes[submesh_id].texture_id is not None:
skip(f, 18)
continue
skip(f, 4)
render_flag_index = uint16.read(f)
skip(f, 4)
submeshes[submesh_id].texture_id = uint16.read(f)
skip(f, 6)
submeshes[submesh_id].blend_mode = blend_modes[render_flag_index]
'''
# determine blending mode
if global_flags & 0x08 and shader_id < len(blend_mode_overrides):
submeshes[submesh_id].blend_mode = blend_mode_overrides[shader_id]
else:
submeshes[submesh_id].blend_mode = blend_modes[render_flag_index]
'''
###### Build blender object ######
faces = [[0, 0, 0]] * n_triangles
for i, tri in enumerate(triangles):
face = []
for index in tri:
face.append(indices[index])
faces[i] = face
# create mesh
mesh = bpy.data.meshes.new(m2_name)
mesh.from_pydata(vertices, [], faces)
for poly in mesh.polygons:
poly.use_smooth = True
# set normals
custom_normals = [(0.0, 0.0, 0.0)] * len(mesh.loops)
mesh.use_auto_smooth = True
for i, loop in enumerate(mesh.loops):
custom_normals[i] = normals[loop.vertex_index]
mesh.normals_split_custom_set(custom_normals)
# set uv
uv_layer1 = mesh.uv_layers.new(name="UVMap")
for i in range(len(uv_layer1.data)):
uv = uv_coords[mesh.loops[i].vertex_index]
uv_layer1.data[i].uv = (uv[0], 1 - uv[1])
# unpack and convert textures
game_data.extract_textures_as_png(asset_dir, texture_paths)
def import_placeholder(addon_relative_path, placeholder_object_name):
current_dir = os.path.dirname(__file__)
base_dir = os.path.dirname(os.path.dirname(current_dir))
blend_path = os.path.join(base_dir, addon_relative_path)
directory = os.path.join(blend_path, "Object\\")
try:
bpy.ops.wm.append(filename=placeholder_object_name, directory=directory)
#obj = bpy.ops.wm.append(filename=placeholder_object_name, directory=directory)
if placeholder_object_name in bpy.data.objects:
placeholder = bpy.data.objects.get(placeholder_object_name)
nplaceholder = bpy.data.objects.new(m2_name, placeholder.data.copy())
#placeholder_object_name.delete()
#placeholder_duplicate = placeholder.copy()
#placeholder_duplicate.data = placeholder.data.copy()
bpy.data.objects.remove(placeholder, do_unlink=True)
return nplaceholder
except Exception as e:
print(f"Failed to import placeholder: {e}")
return None
# create object
if len(mesh.vertices) == 0 or placeholder:
placeholder_object_name = 'Placeholder'
addon_relative_path = 'utils\\placeholder\\placeholder.blend'
nobj = import_placeholder(addon_relative_path, placeholder_object_name)
nobj.wow_wmo_doodad.path = filepath
nobj.wow_wmo_doodad.enabled = True
image_name = "Placeholder"
img = bpy.data.images.get('Placeholder_Texture')
if not img:
img = bpy.data.images.new(name=image_name, width=512, height=512, alpha=True, float_buffer=False)
img.generated_type = 'UV_GRID'
#img = bpy.data.images['Placeholder_Texture']
mat = bpy.data.materials.get('Placeholder')
if mat:
nobj.data.materials.append(mat)
for block in bpy.data.meshes:
if block.users == 0:
bpy.data.meshes.remove(block)
for block in bpy.data.materials:
if block.users == 0:
bpy.data.materials.remove(block)
for block in bpy.data.textures:
if block.users == 0:
bpy.data.textures.remove(block)
for block in bpy.data.images:
if block.users == 0:
bpy.data.images.remove(block)
return nobj
else:
mat = bpy.data.materials.new(name="Placeholder")
mat.use_nodes = True
node_tree = mat.node_tree
tree_builder = NodeTreeBuilder(node_tree)
tex_image = tree_builder.add_node('ShaderNodeTexImage', "Texture", 0, 0)
tex_image.image = img
doodad_color= tree_builder.add_node('ShaderNodeAttribute', "DoodadColor", 0, 2)
mix_rgb = tree_builder.add_node('ShaderNodeMixRGB', "ApplyColor", 1, 1)
mix_rgb.inputs['Fac'].default_value = 1.0
mix_rgb.blend_type = 'MULTIPLY'
transparent_bsdf = tree_builder.add_node('ShaderNodeBsdfTransparent', "Transparent", 2, 0)
bsdf = tree_builder.add_node('ShaderNodeBsdfDiffuse', "Diffuse", 2, 2)
mix_shader = tree_builder.add_node('ShaderNodeMixShader', "MixShader", 3, 0)
mix_shader.inputs['Fac'].default_value = 1.0
output = tree_builder.add_node('ShaderNodeOutputMaterial', "Output", 4, 1)
mat.node_tree.links.new(tex_image.outputs['Color'], mix_rgb.inputs['Color1'])
mat.node_tree.links.new(doodad_color.outputs['Color'], mix_rgb.inputs['Color2'])
mat.node_tree.links.new(mix_rgb.outputs['Color'], bsdf.inputs['Color'])
mat.node_tree.links.new(bsdf.outputs['BSDF'], mix_shader.inputs[2])
mat.node_tree.links.new(transparent_bsdf.outputs['BSDF'], mix_shader.inputs[1])
mat.node_tree.links.new(mix_shader.outputs['Shader'], output.inputs['Surface'])
mat.blend_method = 'OPAQUE'
nobj.data.materials.append(mat)
return nobj
else:
nobj = bpy.data.objects.new(m2_name, mesh)
nobj.wow_wmo_doodad.path = filepath
nobj.wow_wmo_doodad.enabled = True
# set textures
texture_dir = get_project_preferences().cache_dir_path
textures = {}
for i, submesh in enumerate(submeshes):
# tex_path = os.path.splitext(texture_paths[texture_lookup_table[submesh.texture_id]])[0] + '.png'
tex_path = texture_paths[texture_lookup_table[submesh.texture_id]]
# add support for unix filesystems
if os.name != 'nt':
tex_path = tex_path.replace('\\', '/')
img = None
# try:
# img = bpy.data.images.load(os.path.join(asset_dir, tex_path), check_existing=True)
# except RuntimeError:
# traceback.print_exc()
# print("\nFailed to load texture: <<{}>>. File is missing or corrupted.".format(tex_path))
try:
img = load_texture(textures, tex_path, texture_dir)
except:
print("\nFailed to load texture: <<{}>>. File is missing or corrupted.".format(tex_path))
pass
if img:
for j in range(submesh.start_triangle // 3, (submesh.start_triangle + submesh.n_triangles) // 3):
mesh.polygons[j].material_index = i
mat = bpy.data.materials.new(name="{}_{}".format(m2_name, i))
mat.use_nodes = True
node_tree = mat.node_tree
tree_builder = NodeTreeBuilder(node_tree)
tex_image = tree_builder.add_node('ShaderNodeTexImage', "Texture", 0, 0)
tex_image.image = img
# doodad_color = tree_builder.add_node('ShaderNodeRGB', "DoodadColor", 0, 2)
doodad_color= tree_builder.add_node('ShaderNodeAttribute', "DoodadColor", 0, 2)
mix_rgb = tree_builder.add_node('ShaderNodeMixRGB', "ApplyColor", 1, 1)
mix_rgb.inputs['Fac'].default_value = 1.0
mix_rgb.blend_type = 'MULTIPLY'
transparent_bsdf = tree_builder.add_node('ShaderNodeBsdfTransparent', "Transparent", 2, 0)
bsdf = tree_builder.add_node('ShaderNodeBsdfDiffuse', "Diffuse", 2, 2)
mix_shader = tree_builder.add_node('ShaderNodeMixShader', "MixShader", 3, 0)
mix_shader.inputs['Fac'].default_value = 1.0
output = tree_builder.add_node('ShaderNodeOutputMaterial', "Output", 4, 1)
mat.node_tree.links.new(tex_image.outputs['Color'], mix_rgb.inputs['Color1'])
if submesh.blend_mode not in (0, 3):
mat.node_tree.links.new(tex_image.outputs['Alpha'], mix_shader.inputs['Fac'])
mat.node_tree.links.new(doodad_color.outputs['Color'], mix_rgb.inputs['Color2'])
mat.node_tree.links.new(mix_rgb.outputs['Color'], bsdf.inputs['Color'])
mat.node_tree.links.new(bsdf.outputs['BSDF'], mix_shader.inputs[2])
mat.node_tree.links.new(transparent_bsdf.outputs['BSDF'], mix_shader.inputs[1])
mat.node_tree.links.new(mix_shader.outputs['Shader'], output.inputs['Surface'])
# configure blending
if submesh.blend_mode == 0:
mat.blend_method = 'OPAQUE'
elif submesh.blend_mode == 1:
mat.blend_method = 'CLIP'
mat.alpha_threshold = 0.9
else:
mat.blend_method = 'BLEND'
mesh.materials.append(mat)
return nobj
def import_doodad(m2_path: str, cache_path: str) -> bpy.types.Object:
try:
obj = import_doodad_model(cache_path, m2_path, False)
except:
obj = import_doodad_model(cache_path, m2_path, True)
traceback.print_exc()
print("\nFailed to import model: <<{}>>. Placeholder is imported instead.".format(m2_path))
obj.name = os.path.splitext(m2_path.split('\\')[-1])[0]
return obj
@@ -0,0 +1,47 @@
import bpy
def create_fog_object(name='WoW Fog', radius=1.0, location=None, color=(1.0, 1.0, 1.0, 1.0)):
bpy.ops.mesh.primitive_uv_sphere_add(radius=radius
, location=bpy.context.scene.cursor.location if location is None else location
)
fog = bpy.context.view_layer.objects.active
fog.name = name
# applying real object transformation
bpy.ops.object.shade_smooth()
mesh = fog.data
material = bpy.data.materials.new(name=name)
mesh.materials.append(material)
material.use_nodes = True
node_tree = material.node_tree
for node in node_tree.nodes:
node_tree.nodes.remove(node)
output = material.node_tree.nodes.new('ShaderNodeOutputMaterial')
diffuse = material.node_tree.nodes.new('ShaderNodeBsdfDiffuse')
transparent = material.node_tree.nodes.new('ShaderNodeBsdfTransparent')
mix = material.node_tree.nodes.new('ShaderNodeMixShader')
color_node = material.node_tree.nodes.new('ShaderNodeAttribute')
node_tree.links.new(output.inputs['Surface'], mix.outputs['Shader'])
node_tree.links.new(mix.inputs[1], transparent.outputs['BSDF'])
node_tree.links.new(mix.inputs[2], diffuse.outputs['BSDF'])
node_tree.links.new(diffuse.inputs['Color'], color_node.outputs['Color'])
mix.inputs['Fac'].default_value = 0.3
# diffuse.inputs['Color'].default_value = color
# color_node.inputs('Type').
color_node.attribute_name = 'wow_wmo_fog.color1'
color_node.attribute_type = 'OBJECT'
fog.hide_set(False if "3" in bpy.context.scene.wow_visibility else True)
bpy.context.view_layer.active_layer_collection.collection.objects.unlink(fog)
return fog
@@ -0,0 +1,90 @@
import bpy
import os
from ...pywowlib.blp import BLP2PNG
from typing import Dict
from ...utils.misc import load_game_data
# old: read from extracted cache images
def load_texture_file(textures : dict, filepath : str, texture_dir : str) -> bpy.types.Image:
new_filename = os.path.splitext(filepath)[0] + '.png'
if os.name != 'nt':
new_filename = new_filename.replace('\\', '/')
texture = textures.get(filepath)
# if image is not loaded, do it
if not texture:
tex_img = bpy.data.images.load(os.path.join(texture_dir, new_filename))
tex_img.wow_wmo_texture.path = filepath
tex_img.name = os.path.basename(new_filename)
texture = tex_img
textures[filepath] = texture
return texture
def load_texture(textures : dict, filepath : str, texture_dir : str) -> bpy.types.Image:
""" Load texture image from game data and add it to the blender scene """
new_filename = os.path.splitext(filepath)[0] + '.png'
if os.name != 'nt':
new_filename = new_filename.replace('\\', '/')
texture = textures.get(filepath)
if not texture:
# try getting it from cache first ?
save_path = os.path.join(texture_dir, new_filename)
# check file exists in the directory
if os.path.isfile(save_path):
# pretty much load_texture_file()
tex_img = bpy.data.images.load(os.path.join(texture_dir, new_filename))
texture = tex_img
else:
# if not, load from game data and save it to directory.
game_data = load_game_data()
result = game_data.read_file(filepath, "", 'blp', True)
if result is None:
print("\nFailed to load texture: <<{}>> from gamedata.".format(filepath))
return None
tex_img: bpy.types.Image = BLP2PNG().create_image(result[0])
# save it to the cache folder
tex_img.save(filepath= save_path)
tex_img.filepath = save_path
texture = tex_img
filepath = filepath.replace('/', '\\')
texture.wow_wmo_texture.path = filepath
texture.wow_m2_texture.path = filepath
texture.name = os.path.basename(new_filename)
textures[filepath] = texture
return texture
def add_ghost_material() -> bpy.types.Material:
""" Add ghost material """
mat = bpy.data.materials.get("WowMaterial_ghost")
if not mat:
mat = bpy.data.materials.new("WowMaterial_ghost")
mat.blend_method = 'BLEND'
mat.use_nodes = True
mat.node_tree.nodes.remove(mat.node_tree.nodes.get('Principled BSDF'))
material_output = mat.node_tree.nodes.get('Material Output')
transparent = mat.node_tree.nodes.new('ShaderNodeBsdfTransparent')
mat.node_tree.links.new(material_output.inputs[0], transparent.outputs[0])
mat.node_tree.nodes["Transparent BSDF"].inputs[0].default_value = (0.38, 0.89, 0.37, 1)
return mat
@@ -0,0 +1,104 @@
from typing import Union
from ...ui.preferences import get_project_preferences
def wmv_get_last_wmo() -> Union[None, str]:
"""Get the path of last WMO model from WoWModelViewer or similar log."""
project_preferences = get_project_preferences()
if project_preferences.wmv_path:
lines = open(project_preferences.wmv_path).readlines()
for line in reversed(lines):
if 'Loading WMO' in line:
return line[22:].rstrip("\n")
def wmv_get_last_m2() -> Union[None, str]:
"""Get the path of last M2 model from WoWModelViewer or similar log."""
project_preferences = get_project_preferences()
if project_preferences.wmv_path:
lines = open(project_preferences.wmv_path).readlines()
for line in reversed(lines):
if 'Loading model:' in line:
return line[25:].split(",", 1)[0].rstrip("\n")
def wmv_get_last_texture() -> Union[None, str]:
"""Get the path of last texture from WoWModelViewer or similar log."""
project_preferences = get_project_preferences()
if project_preferences.wmv_path:
lines = open(project_preferences.wmv_path).readlines()
for line in reversed(lines):
if 'Loading texture' in line:
return line[27:].rstrip("\n")
def wow_export_get_last_texture() -> Union[None,str]:
"""Get the path of last texture from WoWExport or similar log."""
project_preferences = get_project_preferences()
if project_preferences.wow_export_path:
lines = open(project_preferences.wow_export_path).readlines()
for line in reversed(lines):
if 'Previewing texture file' in line:
return line[35:].split(",", 1)[0].rstrip("\n")
def wow_export_get_last_m2() -> Union[None, str]:
"""Get the path of last M2 model from WoWExport or similar log."""
project_preferences = get_project_preferences()
if project_preferences.wow_export_path:
lines = open(project_preferences.wow_export_path).readlines()
for line in reversed(lines):
if 'Previewing model' in line:
return line[28:].split(",", 1)[0].rstrip("\n")
def noggit_red_get_last_m2() -> Union[None, str]:
"""Get the path of last M2 model from Noggit Red or similar log."""
project_preferences = get_project_preferences()
if project_preferences.noggit_red_path:
lines = open(project_preferences.noggit_red_path).readlines()
for line in reversed(lines):
if 'Loaded file' in line and 'm2' in line:
start = line.find("'") + 1
end = line.find(".m2") + 3
return line[start:end]
def noggit_red_get_last_wmo() -> Union[None, str]:
"""Get the path of last WMO model from Noggit Red or similar log."""
project_preferences = get_project_preferences()
if project_preferences.noggit_red_path:
lines = open(project_preferences.noggit_red_path).readlines()
for line in reversed(lines):
if 'Loaded file' in line and 'wmo' in line:
start = line.find("'") + 1
end = line.find(".wmo") + 4
return line[start:end]
def wow_export_get_last_wmo() -> Union[None, str]:
"""Get the path of last WMO model from WoWExport or similar log."""
project_preferences = get_project_preferences()
if project_preferences.wow_export_path:
lines = open(project_preferences.wow_export_path).readlines()
for line in reversed(lines):
if 'Previewing model' in line:
return line[28:].split(",", 1)[0].rstrip("\n")
@@ -0,0 +1,929 @@
import hashlib
import math
import bpy
import bmesh
import typing
from mathutils import Vector, Matrix
from bmesh.types import BMVert
from math import sqrt, atan2, pi
from typing import Dict, List
from .bl_render import update_wmo_mat_node_tree, load_wmo_shader_dependencies, BlenderWMOMaterialRenderFlags
from .utils.fogs import create_fog_object
from .utils.materials import add_ghost_material, load_texture
from .utils.doodads import import_doodad
from .wmo_scene_group import BlenderWMOSceneGroup
from ..ui.preferences import get_project_preferences
from ..utils.misc import find_nearest_object
from ..wbs_kernel.wmo_utils import CWMOGeometryBatcher, WMOGeometryBatcherMeshParams
from .ui.collections import get_wmo_collection, SpecialCollections, get_wmo_groups_list
from ..utils.collections import get_current_wow_model_collection
from ..pywowlib.file_formats.wmo_format_root import GroupInfo, PortalInfo, PortalRelation, Fog
from ..pywowlib.wmo_file import WMOFile
from ..pywowlib import WoWVersions
from ..third_party.tqdm import tqdm
class BlenderWMOScene:
""" This class is used for assembling a Blender scene from a WNO file or saving the scene back to it."""
def __init__(self, wmo: WMOFile, prefs):
self.wmo: WMOFile = wmo
self.settings = prefs
self.bl_materials: Dict[int, bpy.types.Material] = {}
self.bl_groups: List[BlenderWMOSceneGroup] = []
self.bl_portals: List[bpy.types.Object] = []
self.bl_fogs: List[bpy.types.Object] = []
self.bl_lights: List[bpy.types.Object] = []
self.bl_liquids: List[bpy.types.Object] = []
self.bl_doodad_sets: Dict[str, bpy.types.Object] = {}
# used for export:
self.groups_eval: List[bpy.types.Mesh] = []
self.group_batch_params: List[WMOGeometryBatcherMeshParams] = []
self.portals_relations: Dict[bpy.types.Object, List[bpy.types.Object]] = {}
self.lights_relations: Dict[bpy.types.Object, List[int]] = {}
self.doodads_relations: Dict[bpy.types.Object, List[int]] = {}
self.export_group_ids: Dict[bpy.types.Object, List[int]] = {}
def load_materials(self, texture_dir=None):
""" Load materials from WoW WMO root file """
project_preferences = get_project_preferences()
if texture_dir is None:
texture_dir = project_preferences.cache_dir_path
self.bl_materials = {0xFF : add_ghost_material()}
if 'MO_WMOShader' not in bpy.data.node_groups:
load_wmo_shader_dependencies(reload_shader=True)
textures = {}
for index, wmo_material in tqdm(list(enumerate(self.wmo.momt.materials)), desc='Importing materials', ascii=True):
texture1 = self.wmo.motx.get_string(wmo_material.texture1_ofs)
texture2 = self.wmo.motx.get_string(wmo_material.texture2_ofs)
mat = bpy.data.materials.new(texture1.split('\\')[-1][:-4] + '.png')
self.bl_materials[index] = mat
try:
mat.wow_wmo_material.shader = str(wmo_material.shader)
except TypeError:
print("Incorrect shader id \"{}\". Most likely badly retro-ported WMO.".format(str(wmo_material.shader)))
mat.wow_wmo_material.shader = "0"
mat.wow_wmo_material.blending_mode = str(wmo_material.blend_mode)
mat.wow_wmo_material.emissive_color = [x / 255 for x in wmo_material.emissive_color]
mat.wow_wmo_material.diff_color = (wmo_material.diff_color[2] / 255,
wmo_material.diff_color[1] / 255,
wmo_material.diff_color[0] / 255,
wmo_material.diff_color[3] / 255
)
try:
mat.wow_wmo_material.terrain_type = str(wmo_material.terrain_type)
except TypeError as e:
print('Terrain type not found for ', mat, e)
mat.wow_wmo_material.terrain_type = '0'
mat_flags = set()
bit = 1
while bit <= 0x80:
if wmo_material.flags & bit:
mat_flags.add(str(bit))
bit <<= 1
mat.wow_wmo_material.flags = mat_flags
# create texture slots and load textures
if texture1:
try:
tex = load_texture(textures, texture1, texture_dir)
mat.wow_wmo_material.diff_texture_1 = tex
except:
pass
if texture2:
try:
tex = load_texture(textures, texture2, texture_dir)
mat.wow_wmo_material.diff_texture_2 = tex
except:
pass
update_wmo_mat_node_tree(mat)
# set render flags
pass_index = 0
if wmo_material.flags & 0x1:
pass_index |= BlenderWMOMaterialRenderFlags.Unlit
if wmo_material.flags & 0x10:
pass_index |= BlenderWMOMaterialRenderFlags.SIDN
if wmo_material.shader in (3, 5, 6, 7, 8, 9, 11, 12, 13, 15):
pass_index |= BlenderWMOMaterialRenderFlags.IsTwoLayered
if wmo_material.blend_mode in (0, 8, 9):
pass_index |= BlenderWMOMaterialRenderFlags.IsOpaque
# configure blending
if wmo_material.blend_mode in (0, 8, 9):
mat.blend_method = 'OPAQUE'
elif wmo_material.blend_mode == 1:
mat.blend_method = 'CLIP'
mat.alpha_threshold = 0.9
# TODO : those blending modes don't exist anymore in 2.9+
# elif wmo_material.blend_mode in (3, 7, 10):
# mat.blend_method = 'ADD'
# elif wmo_material.blend_mode in (4, 5):
# mat.blend_method = 'MULTIPLY'
else:
mat.blend_method = 'BLEND'
mat.pass_index = pass_index
def load_lights(self):
""" Load WoW WMO MOLT lights """
bl_light_types = ['POINT', 'SPOT', 'SUN', 'POINT']
scn = bpy.context.scene
light_collection = get_wmo_collection(scn, SpecialCollections.Lights)
for i, wmo_light in tqdm(list(enumerate(self.wmo.molt.lights)), desc='Importing lights', ascii=True):
try:
l_type = bl_light_types[wmo_light.light_type]
except IndexError:
raise Exception("Light type unknown : {} (light nbr : {})".format(str(wmo_light.LightType), str(i)))
light_name = "{}_Light_{}".format(self.wmo.display_name, str(i).zfill(2))
light = bpy.data.lights.new(light_name, l_type)
obj = bpy.data.objects.new(light_name, light)
obj.location = self.wmo.molt.lights[i].position
if wmo_light.type in {0, 2, 3}:
light.type = 'POINT'
elif wmo_light.type == 1:
light.type = 'SPOT'
# TODO set rotation for spot lights. currently wmo_light.unknown1
light.color = (wmo_light.color[2] / 255, wmo_light.color[1] / 255, wmo_light.color[0] / 255)
light.energy = wmo_light.intensity
if wmo_light.light_type in {0, 1}:
light.falloff_type = 'INVERSE_LINEAR'
light.distance = wmo_light.unknown4 / 2
obj.wow_wmo_light.enabled = True
obj.wow_wmo_light.light_type = str(wmo_light.light_type)
obj.wow_wmo_light.type = bool(wmo_light.type)
obj.wow_wmo_light.use_attenuation = bool(wmo_light.use_attenuation)
obj.wow_wmo_light.padding = bool(wmo_light.padding)
obj.wow_wmo_light.type = bool(wmo_light.type)
obj.wow_wmo_light.color = light.color
obj.wow_wmo_light.color_alpha = wmo_light.color[3] / 255
obj.wow_wmo_light.intensity = wmo_light.intensity
obj.wow_wmo_light.attenuation_start = wmo_light.attenuation_start
obj.wow_wmo_light.attenuation_end = wmo_light.attenuation_end
self.bl_lights.append(light)
# move lights to collection
light_collection.objects.link(obj)
def load_fogs(self):
""" Load fogs from WMO Root File"""
fog_collection = get_wmo_collection(bpy.context.scene, SpecialCollections.Fogs)
for i, wmo_fog in tqdm(list(enumerate(self.wmo.mfog.fogs)), desc='Importing fogs', ascii=True):
fog_obj = create_fog_object( name="{}_Fog_{}".format(self.wmo.display_name, str(i).zfill(2))
, location=wmo_fog.position
#, radius=wmo_fog.big_radius
, color=(wmo_fog.color1[2] / 255,
wmo_fog.color1[1] / 255,
wmo_fog.color1[0] / 255,
0.0
)
)
fog_obj.scale = (wmo_fog.big_radius,wmo_fog.big_radius,wmo_fog.big_radius)
# applying object properties
fog_obj.wow_wmo_fog.enabled = True
fog_obj.wow_wmo_fog.ignore_radius = wmo_fog.flags & 0x01
try:
fog_obj.wow_wmo_fog.unknown = wmo_fog.flags & 0x10
except:
fog_obj.wow_wmo_fog.unknown = False
if wmo_fog.small_radius != 0:
fog_obj.wow_wmo_fog.inner_radius = wmo_fog.small_radius / wmo_fog.big_radius * 100
else:
fog_obj.wow_wmo_fog.inner_radius = 0
fog_obj.wow_wmo_fog.end_dist = wmo_fog.end_dist
fog_obj.wow_wmo_fog.start_factor = wmo_fog.start_factor
fog_obj.wow_wmo_fog.color1 = (wmo_fog.color1[2] / 255, wmo_fog.color1[1] / 255, wmo_fog.color1[0] / 255)
fog_obj.wow_wmo_fog.end_dist2 = wmo_fog.end_dist2
fog_obj.wow_wmo_fog.start_factor2 = wmo_fog.start_factor2
fog_obj.wow_wmo_fog.color2 = (wmo_fog.color2[2] / 255, wmo_fog.color2[1] / 255, wmo_fog.color2[0] / 255)
self.bl_fogs.append(fog_obj)
# move fogs to collection
fog_collection.objects.link(fog_obj)
def load_doodads(self):
cache_path = self.settings.cache_dir_path
doodad_prototypes = {}
scene = bpy.context.scene
doodad_collection = get_wmo_collection(scene, SpecialCollections.Doodads)
with tqdm(self.wmo.modd.definitions, desc='Importing doodads', ascii=True) as progress:
for doodad_set in self.wmo.mods.sets:
# replace anchor object by collections
doodadset_coll = bpy.data.collections.get(doodad_set.name)
if not doodadset_coll:
doodadset_coll = bpy.data.collections.new(doodad_set.name)
doodad_collection.children.link(doodadset_coll)
doodadset_coll.color_tag = 'COLOR_04'
for i in range(doodad_set.start_doodad, doodad_set.start_doodad + doodad_set.n_doodads):
doodad = self.wmo.modd.definitions[i]
doodad_path = self.wmo.modn.get_string(doodad.name_ofs)
path_hash = str(hashlib.md5(doodad_path.encode('utf-8')).hexdigest())
proto_obj = doodad_prototypes.get(path_hash)
if not proto_obj:
nobj = import_doodad(doodad_path, cache_path)
doodad_prototypes[path_hash] = nobj
else:
nobj = proto_obj.copy()
# nobj.data = nobj.data.copy()
# for j, mat in enumerate(nobj.data.materials):
# nobj.data.materials[j] = mat.copy()
# also link to base collection ?
doodadset_coll.objects.link(nobj)
bpy.context.view_layer.objects.active = nobj
nobj.wow_wmo_doodad.color = (doodad.color[2] / 255,
doodad.color[1] / 255,
doodad.color[0] / 255,
doodad.color[3] / 255
)
flags = []
bit = 1
while bit <= 0x8:
if doodad.flags & bit:
flags.append(str(bit))
bit <<= 1
nobj.wow_wmo_doodad.flags = set(flags)
# place the object correctly on the scene
nobj.location = doodad.position
nobj.scale = (doodad.scale, doodad.scale, doodad.scale)
nobj.rotation_mode = 'QUATERNION'
nobj.rotation_quaternion = (doodad.rotation[3],
doodad.rotation[0],
doodad.rotation[1],
doodad.rotation[2])
nobj.hide_set(True)
# doodad_collection.objects.link(nobj)
progress.update(1)
def load_portals(self):
""" Load WoW WMO portal planes """
portal_collection = get_wmo_collection(bpy.context.scene, SpecialCollections.Portals)
vert_count = 0
for index, portal in tqdm(list(enumerate(self.wmo.mopt.infos)), desc='Importing portals', ascii=True):
portal_name = "{}_Portal_{}".format(self.wmo.display_name, str(index).zfill(3))
verts = []
face = []
faces = []
for j in range(portal.n_vertices):
if len(face) < 4:
verts.append(self.wmo.mopv.portal_vertices[vert_count])
face.append(j)
vert_count += 1
faces.append(face)
mesh = bpy.data.meshes.new(portal_name)
obj = bpy.data.objects.new(portal_name, mesh)
mesh.from_pydata(verts, [], faces)
self.bl_portals.append(obj)
# assign portal material
portal_mat = bpy.data.materials.get("WowMaterial_ghost_Portal")
if portal_mat is None:
portal_mat = bpy.data.materials.new("WowMaterial_ghost_Portal")
portal_mat.blend_method = 'BLEND'
portal_mat.use_nodes = True
portal_mat.node_tree.nodes.remove(portal_mat.node_tree.nodes.get('Principled BSDF'))
material_output = portal_mat.node_tree.nodes.get('Material Output')
transparent = portal_mat.node_tree.nodes.new('ShaderNodeBsdfTransparent')
portal_mat.node_tree.links.new(material_output.inputs[0], transparent.outputs[0])
portal_mat.node_tree.nodes["Transparent BSDF"].inputs[0].default_value = (1, 0, 0, 1)
obj.data.materials.append(portal_mat)
# move portals to collection
portal_collection.objects.link(obj)
def load_portal_relations(self):
"""
Load portal relations from MOPR data.
Note that portals do not have to have a pair of relations. They may be linked to just one group.
In that the portal works in a one-sided way. Example is floating cathedral exterior in Stormwind.
"""
portal_relations = {}
for index, group in tqdm(list(enumerate(self.bl_groups)), desc='Importing portal relations', ascii=True):
portal_start = group.wmo_group.mogp.portal_start
portal_count = group.wmo_group.mogp.portal_count
if not portal_count:
continue
for i in range(portal_count):
relation = self.wmo.mopr.relations[portal_start + i]
# group from
this_portal_rels = portal_relations.setdefault(relation.portal_index, (set(), set()))
this_portal_rels[0].add(group.bl_object)
this_portal_rels[1].add(group.bl_object)
other_group = self.bl_groups[relation.group_index]
this_portal_rels[0].add(other_group.bl_object)
for portal_index, linked_groups in portal_relations.items():
assert(len(linked_groups) == 2 and f"Portal links {len(linked_groups)} groups. Expected 2.")
portal = self.bl_portals[portal_index]
l_linked_groups = list(linked_groups[0])
portal.wow_wmo_portal.first = l_linked_groups[0]
portal.wow_wmo_portal.second = l_linked_groups[1]
if portal.wow_wmo_portal.first not in linked_groups[1]:
portal.wow_wmo_portal.detail = "1"
elif portal.wow_wmo_portal.second not in linked_groups[1]:
portal.wow_wmo_portal.detail = "2"
def load_properties(self):
""" Load global WoW WMO properties """
wow_model_collection = get_current_wow_model_collection(bpy.context.scene, 'wow_wmo')
properties = wow_model_collection.wow_wmo
properties.ambient_color = (self.wmo.mohd.ambient_color[0] / 255,
self.wmo.mohd.ambient_color[1] / 255,
self.wmo.mohd.ambient_color[2] / 255,
self.wmo.mohd.ambient_color[3] / 255)
flags = set()
if self.wmo.mohd.flags & 0x1:
flags.add("0")
if self.wmo.mohd.flags & 0x2:
flags.add("2")
if self.wmo.mohd.flags & 0x8:
flags.add("1")
# if self.wmo.mohd.flags & 0x4:
# flags.add("3")
properties.flags = flags
properties.skybox_path = self.wmo.mosb.skybox
properties.wmo_id = self.wmo.mohd.id
def load_groups(self):
for i, group in tqdm(enumerate(self.wmo.groups), desc='Importing groups', ascii=True):
bl_group = BlenderWMOSceneGroup(self, group)
self.bl_groups.append(bl_group)
if not bl_group.name == 'antiportal':
bl_group.load_object(i)
def build_references(self, export_selected, export_method):
""" Build WMO references in Blender scene """
group_objects = []
scn = bpy.context.scene
material_id = 0
sorted_objects = sorted(get_wmo_groups_list(scn), key=lambda obj: obj.wow_wmo_group.export_order)
for i, group_object in tqdm(enumerate(sorted_objects), desc='Building group references', ascii=True):
if (export_selected and not group_object.select_get()) or group_object.hide_get():
continue
group_object : bpy.types.Object
bpy.ops.object.select_all(action='DESELECT')
group_object.select_set(True)
bpy.context.view_layer.objects.active = group_object
self.export_group_ids[group_object.name] = i
# self.groups_relations[group_object.name, relations]
self.portals_relations[group_object] = []
self.lights_relations[group_object] = []
self.doodads_relations[group_object] = []
group = self.wmo.add_group()
self.bl_groups.append(BlenderWMOSceneGroup(self, group, obj=group_object))
group_objects.append(group_object)
# only iterate materials in the wmo groups.
for material in group_object.data.materials:
# don't create duplicates
if material not in self.bl_materials.values():
self.bl_materials[material_id] = material
material_id += 1
group.export = not (export_method == 'PARTIAL' and not group_object.export)
# process portals
for i, portal_object in tqdm(enumerate(get_wmo_collection(scn, SpecialCollections.Portals).objects), desc='Building portal references', ascii=True):
self.bl_portals.append(portal_object)
portal_object.wow_wmo_portal.portal_id = i
# new system
if portal_object.wow_wmo_portal.first:
# self.groups_relations[portal_object.wow_wmo_portal.first.name].portals.append(portal_object.name)
self.portals_relations[portal_object.wow_wmo_portal.first].append(portal_object)
if portal_object.wow_wmo_portal.second:
# self.groups_relations[portal_object.wow_wmo_portal.second.name].portals.append(portal_object.name)
self.portals_relations[portal_object.wow_wmo_portal.second].append(portal_object)
# process fogs
for i, fog_object in tqdm(enumerate(get_wmo_collection(scn, SpecialCollections.Fogs).objects), desc='Building fog references', ascii=True):
self.bl_fogs.append(fog_object)
fog_object.wow_wmo_fog.fog_id = i
# process lights
for i, light_object in tqdm(enumerate(get_wmo_collection(scn, SpecialCollections.Lights).objects), desc='Building light references', ascii=True):
group = find_nearest_object(light_object, group_objects)
self.lights_relations[group].append(i)
self.bl_lights.append(light_object)
# process doodads
doodad_counter = 0
for i, doodad_set_collection in tqdm(enumerate(get_wmo_collection(scn, SpecialCollections.Doodads).children), desc='Building doodad references', ascii=True):
doodads = []
for doodad in doodad_set_collection.objects:
group = find_nearest_object(doodad, group_objects)
if group not in self.doodads_relations:
print("ERROR doodad group ref, nearest_object: " + group.name)
self.doodads_relations[group].append(doodad_counter)
doodad_counter += 1
doodads.append(doodad)
self.bl_doodad_sets[doodad_set_collection.name] = doodads
def save_materials(self):
""" Add material if not already added, then return index in root file """
for i, mat in tqdm(enumerate(self.bl_materials.values())
, desc='Saving materials'
, ascii=True
):
if not mat.wow_wmo_material.diff_texture_1:
raise ReferenceError('\nError: Material \"{}\" must have a diffuse texture.'.format(mat.name))
diff_texture_1 = mat.wow_wmo_material.diff_texture_1.wow_wmo_texture.path
diff_texture_1 = diff_texture_1.replace('/', '\\')
diff_texture_1 = diff_texture_1.lower()
diff_texture_2 = mat.wow_wmo_material.diff_texture_2.wow_wmo_texture.path \
if mat.wow_wmo_material.diff_texture_2 else ""
diff_texture_2 = diff_texture_2.replace('/', '\\')
diff_texture_2 = diff_texture_2.lower()
flags = 0
for flag in mat.wow_wmo_material.flags:
flags |= int(flag)
self.wmo.add_material(diff_texture_1
, diff_texture_2
, int(mat.wow_wmo_material.shader)
, int(mat.wow_wmo_material.blending_mode)
, int(mat.wow_wmo_material.terrain_type)
, flags
, ( int(mat.wow_wmo_material.emissive_color[0] * 255),
int(mat.wow_wmo_material.emissive_color[1] * 255),
int(mat.wow_wmo_material.emissive_color[2] * 255),
int(mat.wow_wmo_material.emissive_color[3] * 255)
)
, ( int(mat.wow_wmo_material.diff_color[2] * 255),
int(mat.wow_wmo_material.diff_color[1] * 255),
int(mat.wow_wmo_material.diff_color[0] * 255),
int(mat.wow_wmo_material.diff_color[3] * 255)
)
)
def add_group_info(self, flags, bounding_box, name, desc):
""" Add group info, then return offset of name and desc in a tuple """
group_info = GroupInfo()
group_info.flags = flags # 8
group_info.bounding_box_corner1 = [_ for _ in bounding_box[0]]
group_info.bounding_box_corner2 = [_ for _ in bounding_box[1]]
group_info.name_ofs = self.wmo.mogn.add_string(name) # 0xFFFFFFFF
if desc:
desc_ofs = self.wmo.mogn.add_string(desc)
else:
desc_ofs = 0
self.wmo.mogi.infos.append(group_info)
return group_info.name_ofs, desc_ofs
def save_doodad_sets(self):
""" Save doodads data from Blender scene to WMO root """
def normalize_quaternion(quat):
w, x, y, z = quat
norm = math.sqrt(w ** 2 + x ** 2 + y ** 2 + z ** 2)
return (w / norm, x / norm, y / norm, z / norm)
has_global = False
set_counter = 1
if len(self.bl_doodad_sets):
for set_name, doodads in tqdm(self.bl_doodad_sets.items(), desc='Saving doodad sets', ascii=True):
if set_name.startswith("Set_$DefaultGlobal"):
if not has_global:
set_name = "Set_$DefaultGlobal"
has_global = True
else:
set_name = f"Set_{set_counter:03}"
set_counter += 1
self.wmo.add_doodad_set(set_name, len(doodads))
for doodad in doodads:
path = doodad.wow_wmo_doodad.path.replace('/', '\\')
position = (doodad.matrix_world @ Vector((0, 0, 0))).to_tuple()
doodad.rotation_mode = 'QUATERNION'
doodad.rotation_quaternion = normalize_quaternion(doodad.rotation_quaternion)
rotation = (doodad.rotation_quaternion[1],
doodad.rotation_quaternion[2],
doodad.rotation_quaternion[3],
doodad.rotation_quaternion[0])
scale = doodad.scale[0]
doodad_color = [int(channel * 255) for channel in doodad.wow_wmo_doodad.color]
doodad_color = (doodad_color[2], doodad_color[1], doodad_color[0], doodad_color[3])
flags = 0
for flag in doodad.wow_wmo_doodad.flags:
flags |= int(flag)
self.wmo.add_doodad(path, position, rotation, scale, doodad_color, flags)
if not has_global:
self.wmo.add_doodad_set("Set_$DefaultGlobal", 0)
def save_lights(self):
for obj in tqdm(self.bl_lights, desc='Saving lights', ascii=True):
light_type = int(obj.wow_wmo_light.light_type)
unk1 = obj.data.distance * 2
unk2 = obj.wow_wmo_light.type
use_attenuation = obj.wow_wmo_light.use_attenuation
padding = obj.wow_wmo_light.padding
color = (int(obj.wow_wmo_light.color[2] * 255),
int(obj.wow_wmo_light.color[1] * 255),
int(obj.wow_wmo_light.color[0] * 255),
int(obj.wow_wmo_light.color_alpha * 255))
position = obj.location.to_tuple()
intensity = obj.wow_wmo_light.intensity
attenuation_start = obj.wow_wmo_light.attenuation_start
attenuation_end = obj.wow_wmo_light.attenuation_end
self.wmo.add_light(light_type, unk1, unk2, use_attenuation, padding, color,
position, intensity, attenuation_start, attenuation_end)
@staticmethod
def get_angle(vec_a: Vector, vec_b: Vector, vec_n: Vector) -> float:
return atan2(
-(vec_a.x * vec_b.y * vec_n.z + vec_b.x * vec_n.y * vec_a.z + vec_n.x * vec_a.y * vec_b.z
- vec_a.z * vec_b.y * vec_n.x - vec_b.z * vec_n.y * vec_a.x - vec_n.z * vec_a.y * vec_b.x),
-(vec_a.x * vec_b.x + vec_a.y * vec_b.y + vec_a.z * vec_b.z)) + pi
@staticmethod
def traverse(cur_vtx: BMVert
, nodes_to_hit: List
, nodes_hit: List
, origin: BMVert) -> List:
for edge in cur_vtx.link_edges:
other = edge.other_vert(cur_vtx)
if other == origin:
if not nodes_to_hit:
return nodes_hit
else:
continue
if other in nodes_hit:
continue
nodes_to_hit_new = nodes_to_hit.copy()
nodes_to_hit_new.remove(other)
nodes_hit_new = nodes_hit.copy()
nodes_hit_new.append(other)
result = BlenderWMOScene.traverse(other, nodes_to_hit_new, nodes_hit_new, origin)
if result:
return result
return []
@staticmethod
def sort_portal_vertices(vertices: List[BMVert], normal: Vector) -> List[BMVert]:
pos_n = Vector((0, 0, 0))
origin = None
for vtx in vertices:
if len(vtx.link_edges) == 2 and origin is None:
origin = vtx
pos_n += (vtx.co)
pos_n /= len(vertices)
vtx_a = origin.link_edges[0].other_vert(origin)
vtx_b = origin.link_edges[1].other_vert(origin)
vector_o = (origin.co) - pos_n
next_vtx = vtx_b if BlenderWMOScene.get_angle((vtx_a.co) - pos_n, vector_o, normal) \
< BlenderWMOScene.get_angle((vtx_b.co) - pos_n, vector_o, normal) else vtx_a
# traversing mesh
nodes_to_hit = list(vertices).copy()
nodes_to_hit.remove(origin)
nodes_to_hit.remove(next_vtx)
nodes_hit = [origin, next_vtx]
result = BlenderWMOScene.traverse(next_vtx, nodes_to_hit, nodes_hit, origin)
return result
def save_portals(self):
saved_portals_ids = []
depsgraph = bpy.context.evaluated_depsgraph_get()
self.wmo.mopt.infos = len(self.bl_portals) * [PortalInfo()]
for bl_group, group_mesh_eval in tqdm(zip(self.bl_groups, self.groups_eval), desc='Saving portals', ascii=True):
group_obj = bl_group.bl_object
portal_relations = self.portals_relations[group_obj]
bl_group.wmo_group.mogp.portal_start = len(self.wmo.mopr.relations)
for portal_obj in portal_relations:
portal_index = portal_obj.original.wow_wmo_portal.portal_id
portal_mesh = portal_obj.data
if portal_index not in saved_portals_ids:
portal_info = PortalInfo()
portal_info.start_vertex = len(self.wmo.mopv.portal_vertices)
v = []
bm = bmesh.new()
bm.from_mesh(portal_mesh)
bm.verts.ensure_lookup_table()
portal_matrix_normal = portal_obj.matrix_world.to_3x3().transposed().inverted()
portal_normal = (portal_matrix_normal @ portal_mesh.polygons[0].normal).normalized()
portal_verts = self.sort_portal_vertices(bm.verts, portal_mesh.polygons[0].normal)
for vertex in portal_verts:
vertex_pos = portal_obj.matrix_world @ vertex.co
self.wmo.mopv.portal_vertices.append(vertex_pos.to_tuple())
v.append(vertex_pos)
bm.free()
v_A = v[0][1] * v[1][2] - v[1][1] * v[0][2] - v[0][1] * v[2][2] + v[2][1] * v[0][2] + v[1][1] * \
v[2][2] - \
v[2][1] * v[1][2]
v_B = -v[0][0] * v[1][2] + v[2][0] * v[1][2] + v[1][0] * v[0][2] - v[2][0] * v[0][2] - v[1][0] * \
v[2][2] + \
v[0][0] * v[2][2]
v_C = v[2][0] * v[0][1] - v[1][0] * v[0][1] - v[0][0] * v[2][1] + v[1][0] * v[2][1] - v[2][0] * \
v[1][1] + \
v[0][0] * v[1][1]
v_D = -v[0][0] * v[1][1] * v[2][2] + v[0][0] * v[2][1] * v[1][2] + v[1][0] * v[0][1] * v[2][2] - \
v[1][0] * \
v[2][1] * v[0][2] - v[2][0] * v[0][1] * v[1][2] + v[2][0] * v[1][1] * v[0][2]
portal_info.unknown = v_D / sqrt(v_A * v_A + v_B * v_B + v_C * v_C)
portal_info.n_vertices = len(self.wmo.mopv.portal_vertices) - portal_info.start_vertex
portal_info.normal = portal_normal.to_tuple()
self.wmo.mopt.infos[portal_index] = portal_info
saved_portals_ids.append(portal_index)
first = portal_obj.original.wow_wmo_portal.first
second = portal_obj.original.wow_wmo_portal.second
# skip detail groups (see e.g. Stormwind cathedral)
if portal_obj.original.wow_wmo_portal.detail != '0':
if first.name == group_obj.original.name:
if portal_obj.original.wow_wmo_portal.detail == '1':
continue
else:
if portal_obj.original.wow_wmo_portal.detail == '2':
continue
# calculating portal relation
relation = PortalRelation()
relation.portal_index = portal_index
relation.group_index = self.export_group_ids[second.name] if first.name == group_obj.original.name \
else self.export_group_ids[first.name]
relation.side = bl_group.get_portal_direction(portal_obj, group_obj.evaluated_get(depsgraph))
self.wmo.mopr.relations.append(relation)
bl_group.wmo_group.mogp.portal_count = len(self.wmo.mopr.relations) - bl_group.wmo_group.mogp.portal_start
def prepare_groups(self):
for bl_group in tqdm(self.bl_groups, desc='Preparing groups', ascii=True):
if bl_group.wmo_group.export:
bl_group.doodads_relations = self.doodads_relations[bl_group.bl_object]
bl_group.lights_relations = self.lights_relations[bl_group.bl_object]
mesh, params = bl_group.create_batching_parameters()
self.groups_eval.append(mesh)
self.group_batch_params.append(params)
def save_groups(self):
for _ in tqdm(range(1), desc='Processing group geometry', ascii=True):
batcher = CWMOGeometryBatcher(self.group_batch_params)
for i, bl_group in enumerate(tqdm(self.bl_groups, desc='Saving groups', ascii=True)):
if bl_group.wmo_group.export:
bl_group.save(batcher, i)
def save_fogs(self):
for fog_obj in tqdm(self.bl_fogs, desc='Saving fogs', ascii=True):
big_radius = fog_obj.dimensions[2] / 2
# small radius % calculation is done by pywowlib
color1 = (int(fog_obj.wow_wmo_fog.color1[2] * 255),
int(fog_obj.wow_wmo_fog.color1[1] * 255),
int(fog_obj.wow_wmo_fog.color1[0] * 255),
0xFF)
color2 = (int(fog_obj.wow_wmo_fog.color2[2] * 255),
int(fog_obj.wow_wmo_fog.color2[1] * 255),
int(fog_obj.wow_wmo_fog.color2[0] * 255),
0xFF)
end_dist = fog_obj.wow_wmo_fog.end_dist
end_dist2 = fog_obj.wow_wmo_fog.end_dist2
position = fog_obj.location.to_tuple()
start_factor = fog_obj.wow_wmo_fog.start_factor
start_factor2 = fog_obj.wow_wmo_fog.start_factor2
flags = 0
if fog_obj.wow_wmo_fog.ignore_radius:
flags |= 0x01
if fog_obj.wow_wmo_fog.unknown:
flags |= 0x10
self.wmo.add_fog(big_radius, fog_obj.wow_wmo_fog.inner_radius, color1, color2, end_dist, end_dist2, position,
start_factor, start_factor2, flags)
def save_root_header(self):
wow_model_collection = get_current_wow_model_collection(bpy.context.scene, 'wow_wmo')
properties = wow_model_collection.wow_wmo
scene = bpy.context.scene
self.wmo.mver.version = 17
# setting up default fog with default blizzlike values.
if not len(self.wmo.mfog.fogs):
empty_fog = Fog()
empty_fog.color1 = (0xFF, 0xFF, 0xFF, 0xFF)
empty_fog.color2 = (0x00, 0x00, 0x00, 0xFF)
empty_fog.end_dist = 444.4445
empty_fog.end_dist2 = 222.2222
empty_fog.start_factor = 0.25
empty_fog.start_factor2 = -0.5
self.wmo.mfog.fogs.append(empty_fog)
bb = self.wmo.get_global_bounding_box()
self.wmo.mohd.bounding_box_corner1 = bb[0]
self.wmo.mohd.bounding_box_corner2 = bb[1]
# DBC foreign keys
self.wmo.mohd.id = properties.wmo_id
self.wmo.mosb.skybox = properties.skybox_path
self.wmo.mohd.ambient_color = [int(properties.ambient_color[0] * 255),
int(properties.ambient_color[1] * 255),
int(properties.ambient_color[2] * 255),
int(properties.ambient_color[3] * 255)]
self.wmo.mohd.n_materials = len(self.wmo.momt.materials)
self.wmo.mohd.n_groups = len(self.wmo.mogi.infos)
self.wmo.mohd.n_portals = len(self.wmo.mopt.infos)
self.wmo.mohd.n_models = self.wmo.modn.string_table.decode("ascii").count('.MDX')
self.wmo.mohd.n_lights = len(self.wmo.molt.lights)
self.wmo.mohd.n_doodads = len(self.wmo.modd.definitions)
self.wmo.mohd.n_sets = len(self.wmo.mods.sets)
flags = properties.flags
if "0" in flags:
self.wmo.mohd.flags |= 0x01
if "2" in flags:
self.wmo.mohd.flags |= 0x02
if "1" in flags:
self.wmo.mohd.flags |= 0x08
# if "3" in flags:
# self.wmo.mohd.flags |= 0x4
version = int(scene.wow_scene.version)
if version >= WoWVersions.WOTLK:
self.wmo.mohd.flags |= 0x4